v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of the five server faults were in the one file no test had ever stood up; `http.ts` now has an end-to-end suite on a real port. CHANGELOG has the reasoning. SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and move for whoever took the seat next — revoked now, in memory and on disk. The browser numbered intents from 1 per page load while the server remembered the seat's last number, so the first move after a reload was swallowed as a resend — the connect push carries the count and the client continues from it. Nothing serialised moves within a game and every write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200), and the boot's bare `JSON.parse` then took every game down — per-path write queues, a per-game move queue, and a boot that skips one bad file. An error after the SSE head was sent crashed the process. Bodies were unbounded before any secret check. BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed submit is `false`, not an unhandled rejection. The documentation renderer flattened nested bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel promised cars the engine refuses; it asks `acceptsCar` now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4d222a7eba
commit
e47cd3d400
+43
-18
@@ -109,6 +109,39 @@ export type Rendered = { html: string; headings: Heading[] };
|
||||
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
|
||||
* defaults to leaving them alone so the function is testable on its own.
|
||||
*/
|
||||
/**
|
||||
* One list at one indent level. An item is its first line plus any lines indented under it; the
|
||||
* more-indented BULLETS among those are the item's own nested list and render recursively, while
|
||||
* plain indented lines are wrapped continuations of its text.
|
||||
*/
|
||||
function listHtml(block: string[], text: (s: string) => string): string {
|
||||
const first = /^(\s*)([-*+]|\d+[.)])\s+/.exec(block.find((l) => l.trim() !== '') ?? '');
|
||||
if (!first) return '';
|
||||
const ordered = /\d/.test(first[2]!);
|
||||
const base = first[1]!.length;
|
||||
const items: { text: string[]; sub: string[] }[] = [];
|
||||
for (const l of block) {
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||
const current = items[items.length - 1];
|
||||
if (m && m[1]!.length <= base) {
|
||||
items.push({ text: [m[3]!], sub: [] });
|
||||
} else if (!current) {
|
||||
continue;
|
||||
} else if (current.sub.length > 0 || (m && m[1]!.length > base)) {
|
||||
// Once a nested list has begun, everything further belongs to it, wrapped lines included.
|
||||
current.sub.push(l);
|
||||
} else if (l.trim() !== '') {
|
||||
current.text.push(l.trim());
|
||||
}
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
`<${tag}>` +
|
||||
items.map((it) => `<li>${text(it.text.join(' '))}${it.sub.length > 0 ? listHtml(it.sub, text) : ''}</li>`).join('') +
|
||||
`</${tag}>`
|
||||
);
|
||||
}
|
||||
|
||||
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
|
||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
||||
const out: string[] = [];
|
||||
@@ -196,13 +229,14 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists. A bullet or a number opens one; continuation lines are indented under their item.
|
||||
// Lists. A bullet or a number opens one; continuation lines are indented under their item, and
|
||||
// a more-indented bullet under an item is a nested list, rendered by recursion (v0.8.4 — the
|
||||
// comment said so before and the code appended the nested bullet to its parent as text, so the
|
||||
// published home-deck page carried a literal "- " mid-sentence).
|
||||
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
const ordered = /\d/.test(bullet[2]!);
|
||||
const baseIndent = bullet[1]!.length;
|
||||
const items: string[] = [];
|
||||
let current: string[] | null = null;
|
||||
const block: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!;
|
||||
if (l.trim() === '') {
|
||||
@@ -210,28 +244,19 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
|
||||
const next = lines[i + 1] ?? '';
|
||||
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
|
||||
if (!continues) break;
|
||||
block.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||
if (m && m[1]!.length <= baseIndent) {
|
||||
if (current) items.push(current.join(' '));
|
||||
current = [m[3]!];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (m || /^\s{2,}\S/.test(l)) {
|
||||
// A nested item or a wrapped continuation. Nesting is rendered by recursion on the block.
|
||||
if (!current) break;
|
||||
current.push(l.trim());
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
|
||||
if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
|
||||
block.push(l);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (current) items.push(current.join(' '));
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
ln(`<${tag}>${items.map((it) => `<li>${text(it)}</li>`).join('')}</${tag}>`);
|
||||
ln(listHtml(block, text));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user