/** * A small Markdown renderer, for the player-facing documentation only. * * WHY NOT A LIBRARY. This project has no runtime dependencies at all, and the guide uses a small, * known subset of Markdown — headings, paragraphs, lists, tables, links, code spans, block quotes * and rules. A Markdown library would be the first dependency in the tree, pulled in to render five * files whose whole vocabulary fits below. `docs/` is written by hand, not by users, so this does * not have to survive hostile input; it has to render what those five documents actually contain * and fail loudly on anything else. * * WHY NOT HAND-WRITTEN HTML. The Markdown is the one copy (TODO #15a). An HTML twin drifts from it * on the first edit, which is the failure the whole documentation pass was about. * * ESCAPING IS UNCONDITIONAL. Every scrap of text goes through `esc` before any markup is added, and * the inline pass only ever inserts tags around already-escaped content. A `<` in the prose is a * less-than sign, not the start of an element — there is no raw-HTML passthrough, deliberately. */ /** HTML-escape. Ampersand first, or it double-escapes the entities added after it. */ export function esc(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** A heading found while rendering, for the contents list the page builds from it. */ export type Heading = { level: number; text: string; id: string }; /** * `## 3. The shape of a Stage` → `the-shape-of-a-stage`. * * The leading number is dropped: it is a position in the document, and a link that carries it * breaks when a section is inserted above. Duplicate slugs get a numeric suffix rather than * silently pointing at the first one. */ function slug(text: string, taken: Set): string { const base = text .toLowerCase() // A whole section number, dotted or not: "4.2 Local Operations" and "7. FAQ" both lose it. .replace(/^\d+(?:\.\d+)*[.)]?\s+/, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'section'; let id = base; for (let n = 2; taken.has(id); n++) id = `${base}-${n}`; taken.add(id); return id; } /** * Inline markup, applied to text that is ALREADY escaped. * * Order matters: code spans are taken out first and put back last, so `**` inside backticks stays * literal. That is not a corner case here — the rules reference quotes field names like * `**Version**` when describing the page header. */ function inline(escaped: string, linkHref: (href: string) => string): string { const code: string[] = []; let s = escaped.replace(/`([^`]+)`/g, (_m, body: string) => { code.push(`${body}`); return `\u0000${code.length - 1}\u0000`; }); // Links: [text](target). The target is rewritten so a link between documents lands on the // rendered page rather than the Markdown source. s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => { const target = linkHref(href); const external = /^https?:/.test(target); return `${text}`; }); // Bold before italic, or `**x**` is read as an empty italic wrapping a bold. s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1$2'); return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)]!); } /** One table row's cells, from `| a | b |`. */ function cells(line: string): string[] { return line .replace(/^\s*\|/, '') .replace(/\|\s*$/, '') .split('|') .map((c) => c.trim()); } /** `---`, `:--`, `--:` and `:-:` → the CSS alignment a column wants. */ function alignments(sep: string): (string | null)[] { return cells(sep).map((c) => { const left = c.startsWith(':'); const right = c.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return null; }); } const isTableSep = (line: string): boolean => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'); export type Rendered = { html: string; headings: Heading[] }; /** * Render a Markdown document to HTML. * * `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. */ 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[] = []; const headings: Heading[] = []; const taken = new Set(); const ln = (s: string): void => void out.push(s); const text = (s: string): string => inline(esc(s), linkHref); let i = 0; while (i < lines.length) { const line = lines[i]!; // The generated-card markers, and any other HTML comment: structural, never shown. if (/^\s*')) i++; i++; continue; } if (line.trim() === '') { i++; continue; } if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { ln('
'); i++; continue; } const heading = /^(#{1,6})\s+(.*)$/.exec(line); if (heading) { const level = heading[1]!.length; const raw = heading[2]!.trim(); const id = slug(raw, taken); headings.push({ level, text: raw.replace(/[*`]/g, ''), id }); // The anchor is a link to itself, so a section can be pointed at without a separate widget. ln( `${text(raw)}` + `#`, ); i++; continue; } // Fenced code. if (/^\s*```/.test(line)) { i++; const body: string[] = []; while (i < lines.length && !/^\s*```/.test(lines[i]!)) body.push(lines[i++]!); i++; ln(`
${esc(body.join('\n'))}
`); continue; } // Tables: a header row, an alignment row, then body rows. if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]!)) { const head = cells(line); const align = alignments(lines[i + 1]!); i += 2; const body: string[][] = []; while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') body.push(cells(lines[i++]!)); const th = head .map((c, n) => `${text(c)}`) .join(''); const rows = body .map( (r) => '' + r.map((c, n) => `${text(c)}`).join('') + '', ) .join(''); // Wrapped so a wide table scrolls inside the page rather than widening it on a phone. ln(`
${th}${rows}
`); continue; } // Block quote: consecutive `>` lines, rendered through this same function so a quote may hold // a list or a table — the rules reference puts both inside one. if (/^\s*>/.test(line)) { const body: string[] = []; while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, '')); ln(`
${renderMarkdown(body.join('\n'), linkHref).html}
`); continue; } // Lists. A bullet or a number opens one; continuation lines are indented under their item. 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; while (i < lines.length) { const l = lines[i]!; if (l.trim() === '') { // A blank line ends the list unless the next line is still inside it. const next = lines[i + 1] ?? ''; const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next); if (!continues) break; 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()); i++; continue; } break; } if (current) items.push(current.join(' ')); const tag = ordered ? 'ol' : 'ul'; ln(`<${tag}>${items.map((it) => `
  • ${text(it)}
  • `).join('')}`); continue; } // Anything else is a paragraph, running to the next blank line or block opener. const para: string[] = []; while (i < lines.length) { const l = lines[i]!; if ( l.trim() === '' || /^(#{1,6})\s/.test(l) || /^\s*>/.test(l) || /^\s*```/.test(l) || /^\s*