From 2fe4682857a0052b8c487911b2ff8c39f8a2be8a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:11:53 +0100 Subject: [PATCH 1/9] feat(markdown): render Markdown files as a preview with a source toggle --- DESIGN.md | 105 ++++++++++++++++++++++++++++++++++++ src/client/editor.ts | 51 ++++++++++++++++-- src/client/markdown.ts | 64 ++++++++++++++++++++++ src/client/mdrender.ts | 17 ++++++ src/client/mdview.ts | 41 ++++++++++++++ src/client/styles.css | 52 ++++++++++++++++++ test/client.mdview.test.ts | 88 ++++++++++++++++++++++++++++++ test/fixtures/md-cases.json | 23 ++++++++ test/helpers/fakedom.ts | 3 ++ test/markdown.parse.test.ts | 25 +++++++++ 10 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 src/client/markdown.ts create mode 100644 src/client/mdrender.ts create mode 100644 src/client/mdview.ts create mode 100644 test/client.mdview.test.ts create mode 100644 test/fixtures/md-cases.json create mode 100644 test/markdown.parse.test.ts diff --git a/DESIGN.md b/DESIGN.md index 9d4b4bf..a1dd7b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -542,3 +542,108 @@ single request. - **Coverage.** The worker (`search.worker.ts`), the build script, `app.ts` and `editor.ts` are excluded from line coverage: they run in a separate thread, the bundler or the real DOM, and are exercised through the endpoint, build and manual runs instead. + +## 14. Round 5: Markdown rendering + +**Brief.** Render `.md` and `.markdown` files as formatted documents, with the source one click away, no external +dependencies of any kind (the parser and renderer are written in this repo), and repo content treated as untrusted. +Four panelists reviewed it: the interaction designer, the application security engineer, the systems/performance +engineer, and the frontend architect & test lead. Before this, a Markdown file opened as highlighted source. + +### Positions and decisions + +**Preview and source (interaction designer).** A text tab for a Markdown file opens as a rendered preview. The +existing `tab.asSource` flag and `viewToggle` button, which already switch an SVG between image and source, are reused: +"View source" in preview, "Preview" in source, same button and position. There is no second tab for the same path. +- Opening with a line (search hit, `file:line`, `:` in the palette, Go to line) forces source view, because a line has + no meaning in the preview. Find (⌘F) in the preview switches that tab to source and opens the find bar there, so it + never falls through to the browser's find silently. +- The mode is per tab and survives live reloads: a file edited on disk while in source stays in source. +- A palette command "Toggle Markdown Preview" is added. +- Prose uses the system UI font with a centred column of about 78ch; code uses the existing mono font. All colours come + from the existing theme tokens. Headings h1/h2 have a hairline underline; tables have hairline borders; task-list + checkboxes are disabled, because the product is read-only. Text size follows the zoom. +- Deferred: an "On this page" outline, a Cmd/Ctrl+Shift+V shortcut, find inside the preview, link hover in the status + bar, copy button on fences, flashing changed blocks, persisting the mode across restarts. +- **Decision: adopted** (except the deferred items above). + +**Escaping model (application security engineer).** Script execution in the page equals shell access, so the escaping +model is the primary defence and the CSP is the backstop. The CSP is not loosened. +- The renderer builds DOM with `createElement` and `textContent` only. It never uses `innerHTML` and never the `html:` + prop of `h`. The security engineer first proposed a string renderer with one `innerHTML`; the performance engineer + and the architect argued for DOM building (no escaping-bug class, nodes can be replaced individually, and the fake + DOM in tests cannot inspect `innerHTML`). The security engineer accepted, on the condition that a test walks the + output tree and fails on any tag outside a closed set (p, h1-6, ul/ol/li, blockquote, pre/code, table family, a, + img, em, strong, del, hr, br, span with a `tok-*` class, input checkbox disabled), any `on*`, `style`, `srcdoc` + or `name` attribute, and any `id` that is not `md-` prefixed. +- Raw HTML (inline and blocks, comments included) is shown as literal text, never parsed. No allowlist in v1. +- Link URLs go through one pure function, `safeHref`. Allowed: `http:`, `https:`, `mailto:`, `#fragment` and relative + paths. Everything else (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`, unknown schemes, protocol-relative + `//host`, backslashes) is rendered as its label text with no ``. The URL is probed after stripping control, space + and zero-width characters and decoding entities, so `java\tscript:` and `javascript:` are caught. +- External links get `rel="noopener noreferrer nofollow"` and `target="_blank"`. Relative links never navigate: a + delegated click handler resolves the path against the document's folder (collapsing `.` and `..`, percent-decoding + once, rejecting anything that escapes the root) and opens it through the editor. The server's `resolveSafe` stays the + real boundary. `#fragment` clicks scroll inside the preview and never touch `location.hash`; heading ids use an + `md-` prefix, to avoid DOM clobbering. +- Relative images load only through `api.rawBlobUrl`, into an `` with a blob URL (SVG is script-inert there and is + never inlined). The extension must be an image type the server serves, so `![](.env)` is never fetched. Blob URLs are + revoked on re-render and on close. At most 100 images per render. +- Remote images (`http(s)`, `//host`, `data:`) never load. The CSP already blocks them and `img-src` is not widened, so + consent loading is impossible without relaxing it. The renderer shows a placeholder with the alt text and the host, + and a link to open the URL in a new tab on an explicit click. A per-image "load" action is deferred until a CSP + decision is made. The interaction designer wanted consent loading; the security and performance engineers wanted + none in v1; the architect noted the CSP forces "none". +- Trojan Source: code spans, code blocks and link titles show C0/C1 controls and bidi overrides as visible escapes, + reusing the `HIDDEN` rule that now lives in `util.ts` (moved from `search.ts`). Blocks get `dir="auto"`. The fence + info string is only a lookup key (`[a-z0-9+#-]{1,20}`, mapped to a known language), never emitted into an attribute. +- **Decision: adopted.** + +**Parser and limits (performance engineer).** Rendering is client-side from the text already fetched. No server change +and no new endpoint. +- The parse is linear: a line-oriented block pass with an explicit container stack (no recursion), and a + CommonMark-style delimiter stack for emphasis. No regex with nested quantifiers touches untrusted text. +- Hard limits degrade to literal text and never throw: container nesting 32, inline nesting 32, 10,000 link + definitions, 200 table columns, 10,000 characters per inline line, about 200,000 rendered nodes. Tests run the + adversarial corpus (200k `*`, 100k `[`, 100k `> `, 100k `- `, 1e5 backticks, `![](` chains, a 1 MB single line) + with generous time bounds, and check that doubling the input does not more than triple the time. +- Files over 1 MiB open in source with a "Too large to preview · Render anyway" note. The panel proposed 2 MiB + (performance) and 1 MiB (designer, architect, security); the lower cap won, because there is no chunked rendering. + A truncated file (`x-cv-truncated`) shows the existing truncation pill above the preview. +- Fenced code highlights with `lexLine` from `highlight.ts`, threading its state line by line. The stateful per-file + `Highlighter` is not used. Fences over 2,000 lines or 200 KB render as plain text. The architect and the performance + engineer differed on `Highlighter` versus `lexLine`; `lexLine` won because it needs no file-wide state. +- A live update re-parses (cheap and linear), builds the new tree off-screen, swaps it with `replaceChildren` in one + frame, and restores `scrollTop`. Identical text skips the render. A reload while in source mode does not render the + preview; it is rendered when shown. Closing a tab drops the tree and the parsed blocks. +- Deferred: chunked/idle rendering, incremental node reuse, a parsing worker, lazy highlighting by visibility, + `content-visibility`. The 1 MiB cap and the node cap bound the cost until measurements say otherwise. +- **Decision: adopted** (chunked rendering and node reuse deferred). + +**Structure and tests (frontend architect & test lead).** Three modules with hard boundaries: +- `src/client/markdown.ts` is pure: `parseMarkdown(src)` returns a typed AST; it also exports `isMarkdown`, + `safeHref`, `resolveRepoPath` and `slugify` (GitHub-style, de-duplicated with `-1`, `-2`). It has no DOM and no + imports besides types, so its tests run without the fake DOM. +- `src/client/mdrender.ts` turns the AST into DOM and `src/client/mdview.ts` is `MarkdownView { el, setText, dispose }`, + mirroring `CodeView.setText`. `editor.ts` gets `md?: MarkdownView` on `Tab` and a guard on every `tab.code` + consumer (status, go to line, find, git refresh, font size). +- Syntax: CommonMark core (ATX/setext headings, paragraphs, fenced and indented code, nested block quotes, nested and + loose/tight lists, thematic break, link reference definitions, emphasis, code spans, links, images, autolinks, + escapes, hard breaks, a small entity table decoded to text) plus GFM (tables with alignment, task lists, + strikethrough, bare URL autolinks). Not supported in v1: raw HTML, footnotes, math, diagrams, front matter. Full + CommonMark conformance is not a goal; correctness is pinned by hand-written cases in `test/fixtures/` (authored in + this repo, not copied from the spec) plus a seeded fuzz test that checks the safety invariants on 2,000 random inputs. +- `editor.ts` and `app.ts` cannot be loaded under the fake DOM, so they are excluded from line coverage, as in Round 4. + Everything with logic lives in the three modules above, which are covered. +- **Decision: adopted.** + +### Slices (built test-first) +1. `markdown.ts` core + `MarkdownView` + editor wiring: headings, paragraphs, source/preview toggle, live update with + scroll kept, `tab.code` guards, `file:line` forces source, find and Go to line switch to source. DESIGN.md section. +2. Block and inline core: lists, quotes, code blocks, thematic break, emphasis, code spans, escapes, entities, hard + breaks, caps and the adversarial perf corpus. +3. Links: `safeHref`, `resolveRepoPath`, external and repo-relative links, `#fragment` scrolling, heading slugs, autolinks. +4. GFM: tables, task lists, strikethrough; theme styles in `styles.css`. +5. Fenced code with `lexLine` highlighting, size caps, info-string allowlist. +6. Images (relative via blob, remote placeholder), size gate with "Render anyway", palette command, README and + SECURITY notes, fuzz test. diff --git a/src/client/editor.ts b/src/client/editor.ts index a0cbb08..18658d7 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -2,6 +2,8 @@ import { api } from "./api.ts"; import { CodeView } from "./codeview.ts"; import { languageFor, languageName } from "./highlight.ts"; import { fileIcon, icons } from "./icons.ts"; +import { isMarkdown } from "./markdown.ts"; +import { MarkdownView } from "./mdview.ts"; import { basename, debounce, esc, formatBytes, h, MOD } from "./util.ts"; import { GitState, relativeTime } from "./git.ts"; @@ -14,6 +16,8 @@ export interface Tab { el: HTMLElement; tabEl: HTMLElement; code?: CodeView; + /** Rendered Markdown preview; mutually exclusive with `code`. */ + md?: MarkdownView; image?: ImageView; lang: string; size: number; @@ -85,6 +89,7 @@ export class Editor { // A single unpinned "preview" tab is reused while browsing. const preview = !opts.pin ? this.tabs.find((t) => !t.pinned) : undefined; tab = this.createTab(path, !!opts.pin); + if (opts.line && isMarkdown(path)) tab.asSource = true; // a line only means something in the source if (preview) { const i = this.tabs.indexOf(preview); this.disposeTab(preview); @@ -98,6 +103,7 @@ export class Editor { } else { if (opts.pin) tab.pinned = true; this.activate(tab); + if (opts.line && tab.md) await this.toSource(tab); } if (opts.line && tab.code) { if (opts.col !== undefined && opts.len) tab.code.showRange(opts.line - 1, opts.col, opts.col + opts.len); @@ -139,8 +145,19 @@ export class Editor { tab.deleted = false; if (f.kind === "text") { tab.eol = /\r\n/.test(f.text.slice(0, 10000)) ? "CRLF" : "LF"; - if (tab.code && reload) tab.code.setText(f.text); + if (isMarkdown(tab.path) && !tab.asSource) { + tab.code = undefined; + if (tab.md && reload) tab.md.setText(f.text); + else { + tab.md?.dispose(); + tab.md = new MarkdownView(f.text, this.fontSize); + tab.el.replaceChildren(tab.md.el); + tab.el.prepend(this.viewToggle(tab, "View source", icons.code)); + } + } else if (tab.code && reload) tab.code.setText(f.text); else { + tab.md?.dispose(); + tab.md = undefined; tab.el.replaceChildren(); tab.code = new CodeView(f.text, tab.lang, this.fontSize); const code = tab.code; @@ -152,9 +169,12 @@ export class Editor { }; tab.el.append(tab.code.el); if (tab.path.endsWith(".svg")) tab.el.prepend(this.viewToggle(tab, "Preview image", icons.image)); + else if (isMarkdown(tab.path)) tab.el.prepend(this.viewToggle(tab, "Preview", icons.image)); } await this.refreshGit(tab, !reload); } else if (f.kind === "image") { + tab.md?.dispose(); + tab.md = undefined; if (tab.image && reload) tab.image.reload(tab.mtime); else { tab.code = undefined; @@ -193,6 +213,8 @@ export class Editor { tab.asSource = !tab.asSource; tab.code = undefined; tab.image = undefined; + tab.md?.dispose(); + tab.md = undefined; void this.load(tab); }, }, @@ -236,7 +258,16 @@ export class Editor { for (const t of [...this.tabs]) this.close(t); } + /** Leaves the Markdown preview for the source view (find and line jumps only make sense there). */ + private async toSource(tab: Tab) { + tab.asSource = true; + tab.md?.dispose(); + tab.md = undefined; + await this.load(tab); + } + private disposeTab(tab: Tab) { + tab.md?.dispose(); tab.el.remove(); tab.tabEl.remove(); } @@ -297,11 +328,16 @@ export class Editor { setFontSize(px: number) { this.fontSize = px; - for (const t of this.tabs) t.code?.setFontSize(px); + for (const t of this.tabs) { + t.code?.setFontSize(px); + t.md?.setFontSize(px); + } } openFind() { - this.active?.code?.openFind(); + const t = this.active; + if (t?.md) return void this.toSource(t).then(() => t.code?.openFind()); + t?.code?.openFind(); } nextChange(dir: 1 | -1) { @@ -309,7 +345,9 @@ export class Editor { } goToLine(line: number) { - this.active?.code?.revealLine(line - 1); + const t = this.active; + if (t?.md) return void this.toSource(t).then(() => t.code?.revealLine(line - 1)); + t?.code?.revealLine(line - 1); } private status() { @@ -326,6 +364,11 @@ export class Editor { s.line = t.code.activeLine >= 0 ? t.code.activeLine + 1 : undefined; s.diff = t.code.gitStats; } + if (t.md) { + s.lang = "Markdown"; + s.eol = t.eol; + s.encoding = t.encoding; + } if (t.image) { s.dims = t.image.dims; s.zoom = t.image.zoomLabel; diff --git a/src/client/markdown.ts b/src/client/markdown.ts new file mode 100644 index 0000000..39a758e --- /dev/null +++ b/src/client/markdown.ts @@ -0,0 +1,64 @@ +// Pure Markdown parser: no DOM, no dependencies, linear time. Block pass is line-oriented. + +export interface Text { + type: "text"; + value: string; +} +export type Inline = Text; +export interface Heading { + type: "heading"; + level: 1 | 2 | 3 | 4 | 5 | 6; + children: Inline[]; +} +export interface Paragraph { + type: "paragraph"; + children: Inline[]; +} +export type Block = Heading | Paragraph; + +/** True for `.md` / `.markdown` paths, case-insensitive. */ +export function isMarkdown(path: string): boolean { + const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + return name.endsWith(".md") || name.endsWith(".markdown"); +} + +const isWs = (c: string | undefined) => c === " " || c === "\t"; + +/** ATX heading: up to 3 spaces, 1-6 `#`, then whitespace or end of line. */ +function atx(line: string): Heading | null { + let i = 0; + while (i < 3 && line[i] === " ") i++; + let n = 0; + while (line[i + n] === "#") n++; + if (n < 1 || n > 6) return null; + const rest = line[i + n]; + if (rest !== undefined && !isWs(rest)) return null; + let text = line.slice(i + n).trim(); + // Optional closing sequence of `#`, preceded by whitespace (or the whole content). + let e = text.length; + while (e > 0 && text[e - 1] === "#") e--; + if (e < text.length && (e === 0 || isWs(text[e - 1]))) text = text.slice(0, e).trim(); + return { type: "heading", level: n as Heading["level"], children: text ? [{ type: "text", value: text }] : [] }; +} + +export function parseMarkdown(src: string): Block[] { + const blocks: Block[] = []; + let para: string[] = []; + const flush = () => { + if (para.length) blocks.push({ type: "paragraph", children: [{ type: "text", value: para.join("\n") }] }); + para = []; + }; + for (const raw of src.split(/\r\n|\r|\n/)) { + if (raw.trim() === "") { + flush(); + continue; + } + const hd = atx(raw); + if (hd) { + flush(); + blocks.push(hd); + } else para.push(raw.trim()); + } + flush(); + return blocks; +} diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts new file mode 100644 index 0000000..d7041f5 --- /dev/null +++ b/src/client/mdrender.ts @@ -0,0 +1,17 @@ +import type { Block, Inline } from "./markdown.ts"; + +function inline(parent: HTMLElement, nodes: Inline[]) { + for (const n of nodes) parent.append(n.value); +} + +/** Turns the AST into DOM using createElement/textContent only; never innerHTML. */ +export function renderMarkdown(blocks: Block[]): HTMLElement { + const root = document.createElement("div"); + root.className = "md-doc"; + for (const b of blocks) { + const el = document.createElement(b.type === "heading" ? `h${b.level}` : "p"); + inline(el, b.children); + root.append(el); + } + return root; +} diff --git a/src/client/mdview.ts b/src/client/mdview.ts new file mode 100644 index 0000000..d328f1d --- /dev/null +++ b/src/client/mdview.ts @@ -0,0 +1,41 @@ +import { parseMarkdown } from "./markdown.ts"; +import { renderMarkdown } from "./mdrender.ts"; + +/** Rendered Markdown, mirroring CodeView.setText. `el` is the scroll container. */ +export class MarkdownView { + readonly el: HTMLElement; + /** Number of times the tree was rebuilt. */ + renders = 0; + private text = ""; + + constructor(text: string, fontSize = 13) { + this.el = document.createElement("div"); + this.el.className = "md-view"; + this.setFontSize(fontSize); + this.build(text); + } + + private build(text: string) { + this.text = text; + this.renders++; + this.el.replaceChildren(renderMarkdown(parseMarkdown(text))); + } + + /** Re-renders unless the text is identical; keeps scrollTop. Returns true when the tree was rebuilt. */ + setText(text: string): boolean { + if (text === this.text) return false; + const top = this.el.scrollTop; + this.build(text); + this.el.scrollTop = top; + return true; + } + + setFontSize(px: number) { + this.el.style.fontSize = `${px}px`; + } + + dispose() { + this.text = ""; + this.el.replaceChildren(); + } +} diff --git a/src/client/styles.css b/src/client/styles.css index 3453c67..551146d 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -854,6 +854,58 @@ svg { border-color: var(--accent); } +/* ───────────────────────── markdown preview ───────────────────────── */ +.md-view { + flex: 1; + min-height: 0; + overflow: auto; + padding: 24px 32px 64px; + background: var(--bg-editor); + color: var(--fg); + font-family: var(--font-ui); + line-height: 1.6; +} +.md-doc { + max-width: 78ch; + margin: 0 auto; +} +.md-doc p { + margin: 0 0 1em; +} +.md-doc h1, +.md-doc h2, +.md-doc h3, +.md-doc h4, +.md-doc h5, +.md-doc h6 { + color: var(--fg-strong); + line-height: 1.25; + margin: 1.4em 0 0.6em; +} +.md-doc h1 { + font-size: 2em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border-strong); +} +.md-doc h2 { + font-size: 1.5em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border); +} +.md-doc h3 { + font-size: 1.25em; +} +.md-doc h4 { + font-size: 1em; +} +.md-doc h5 { + font-size: 0.875em; +} +.md-doc h6 { + font-size: 0.85em; + color: var(--fg-muted); +} + /* ───────────────────────── code view ───────────────────────── */ .code { position: relative; diff --git a/test/client.mdview.test.ts b/test/client.mdview.test.ts new file mode 100644 index 0000000..a34855f --- /dev/null +++ b/test/client.mdview.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; + +let V: typeof import("../src/client/mdview.ts"); +beforeAll(async () => { + installDom(); + try { + V = await import("../src/client/mdview.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const SRC = "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six\n\nSome *text* here.\n"; +const q = (v: { el: unknown }, sel: string) => (v.el as FakeElement).querySelectorAll(sel); + +describe("AC-2 MarkdownView renders the source as a document", () => { + test("h1..h6 and p are built from the source", () => { + const v = new V.MarkdownView(SRC); + for (const [i, name] of ["One", "Two", "Three", "Four", "Five", "Six"].entries()) { + const hs = q(v, "h" + (i + 1)); + expect(hs.length).toBe(1); + expect(hs[0]!.textContent).toBe(name); + } + expect(q(v, "p").length).toBeGreaterThanOrEqual(1); + }); + + test("no pre of the raw source and no raw Markdown markers in text", () => { + const v = new V.MarkdownView(SRC); + expect(q(v, "pre").length).toBe(0); + expect((v.el as FakeElement).textContent).not.toContain("# One"); + expect((v.el as FakeElement).textContent).toContain("One"); + }); + + test("the container carries the md-view class", () => { + const v = new V.MarkdownView("x"); + expect((v.el as FakeElement).classList.contains("md-view")).toBe(true); + }); + + test("empty text renders an empty tree without throwing", () => { + const v = new V.MarkdownView(""); + expect(q(v, "p").length).toBe(0); + expect(v.renders).toBe(1); + }); +}); + +describe("AC-4 live update", () => { + test("new text replaces the tree and returns true", () => { + const v = new V.MarkdownView("# Old"); + expect(v.setText("# New")).toBe(true); + const hs = q(v, "h1"); + expect(hs.length).toBe(1); + expect(hs[0]!.textContent).toBe("New"); + expect(v.renders).toBe(2); + }); + + test("scrollTop is preserved across a re-render", () => { + const v = new V.MarkdownView(SRC); + (v.el as FakeElement).scrollTop = 321; + v.setText(SRC + "\nmore\n"); + expect((v.el as FakeElement).scrollTop).toBe(321); + }); + + test("identical text does not rebuild the tree", () => { + const v = new V.MarkdownView(SRC); + const first = (v.el as FakeElement).children[0]; + const n = v.renders; + expect(v.setText(SRC)).toBe(false); + expect(v.renders).toBe(n); + expect((v.el as FakeElement).children[0]).toBe(first); + }); + + test("setFontSize and dispose do not throw, and a disposed view is emptied", () => { + const v = new V.MarkdownView(SRC); + v.setFontSize(18); + expect((v.el as FakeElement).style["fontSize"] ?? (v.el as FakeElement).getAttribute("style") ?? "").toContain("18"); + v.dispose(); + expect(q(v, "h1").length).toBe(0); + }); +}); + +describe("AC-2 rendering is DOM-built, never HTML-parsed", () => { + test("raw HTML in the source stays text", () => { + const v = new V.MarkdownView("# x\n\n"); + expect(q(v, "b").length).toBe(0); + expect(q(v, "script").length).toBe(0); + expect((v.el as FakeElement).textContent).toContain(""); + }); +}); diff --git a/test/fixtures/md-cases.json b/test/fixtures/md-cases.json new file mode 100644 index 0000000..98328d1 --- /dev/null +++ b/test/fixtures/md-cases.json @@ -0,0 +1,23 @@ +[ + { "name": "atx h1", "src": "# Title", "ast": [{ "type": "heading", "level": 1, "children": [{ "type": "text", "value": "Title" }] }] }, + { "name": "atx h6", "src": "###### Deep", "ast": [{ "type": "heading", "level": 6, "children": [{ "type": "text", "value": "Deep" }] }] }, + { "name": "atx closing hashes and trailing spaces", "src": "## Sub ## ", "ast": [{ "type": "heading", "level": 2, "children": [{ "type": "text", "value": "Sub" }] }] }, + { "name": "seven hashes is a paragraph", "src": "####### x", "ast": [{ "type": "paragraph", "children": [{ "type": "text", "value": "####### x" }] }] }, + { "name": "hash without space is a paragraph", "src": "#nospace", "ast": [{ "type": "paragraph", "children": [{ "type": "text", "value": "#nospace" }] }] }, + { "name": "paragraph", "src": "Hello world", "ast": [{ "type": "paragraph", "children": [{ "type": "text", "value": "Hello world" }] }] }, + { + "name": "heading then two paragraphs", + "src": "# A\n\nfirst\n\n\nsecond\n", + "ast": [ + { "type": "heading", "level": 1, "children": [{ "type": "text", "value": "A" }] }, + { "type": "paragraph", "children": [{ "type": "text", "value": "first" }] }, + { "type": "paragraph", "children": [{ "type": "text", "value": "second" }] } + ] + }, + { "name": "empty", "src": "", "ast": [] }, + { "name": "only blank lines", "src": "\n \n\n", "ast": [] }, + { "name": "crlf line endings", "src": "# A\r\n\r\nb\r\n", "ast": [ + { "type": "heading", "level": 1, "children": [{ "type": "text", "value": "A" }] }, + { "type": "paragraph", "children": [{ "type": "text", "value": "b" }] } + ] } +] diff --git a/test/helpers/fakedom.ts b/test/helpers/fakedom.ts index 9c79f90..7eba168 100644 --- a/test/helpers/fakedom.ts +++ b/test/helpers/fakedom.ts @@ -71,6 +71,8 @@ export class FakeElement extends Target { private _html: string | null = null; focused = false; scrolled = 0; + /** Like a browser, emptying the element clamps scrollTop to 0 (see replaceChildren). */ + scrollTop = 0; rect = { width: 100, height: 40, left: 0, top: 0 }; constructor(public tag: string, private doc: FakeDocument) { @@ -154,6 +156,7 @@ export class FakeElement extends Target { this._html = null; for (const c of this.children) c.parent = null; this.children = []; + this.scrollTop = 0; this.append(...cs); } remove() { diff --git a/test/markdown.parse.test.ts b/test/markdown.parse.test.ts new file mode 100644 index 0000000..dbaf634 --- /dev/null +++ b/test/markdown.parse.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import cases from "./fixtures/md-cases.json"; + +// Loaded dynamically so a missing module fails by assertion rather than by a load error. +let M: typeof import("../src/client/markdown.ts"); +try { + M = await import("../src/client/markdown.ts"); +} catch {} + +describe("AC-1 isMarkdown", () => { + test.each(["README.MD", "a.markdown", ".md", "docs/guide.Md", "x/y/z.MARKDOWN"])("%s is Markdown", (p) => { + expect(M.isMarkdown(p)).toBe(true); + }); + test.each(["x.md.txt", "md", "a.mdx", "notes.txt", "a.md/b", "", "markdown", "a.mdown"])("%s is not Markdown", (p) => { + expect(M.isMarkdown(p)).toBe(false); + }); +}); + +describe("parseMarkdown headings and paragraphs (fixtures)", () => { + for (const c of cases as { name: string; src: string; ast: unknown }[]) { + test(c.name, () => { + expect(M.parseMarkdown(c.src)).toEqual(c.ast as never); + }); + } +}); From c641ab8a9d4b1b46ac9e569d816abd51ee7e246f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:16:20 +0100 Subject: [PATCH 2/9] fix(markdown): leave the preview when a Markdown tab turns binary or a directory --- src/client/editor.ts | 22 +++++++----- src/client/viewstate.ts | 32 +++++++++++++++++ test/client.viewstate.test.ts | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 src/client/viewstate.ts create mode 100644 test/client.viewstate.test.ts diff --git a/src/client/editor.ts b/src/client/editor.ts index 18658d7..571e179 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -4,6 +4,7 @@ import { languageFor, languageName } from "./highlight.ts"; import { fileIcon, icons } from "./icons.ts"; import { isMarkdown } from "./markdown.ts"; import { MarkdownView } from "./mdview.ts"; +import { planView } from "./viewstate.ts"; import { basename, debounce, esc, formatBytes, h, MOD } from "./util.ts"; import { GitState, relativeTime } from "./git.ts"; @@ -143,21 +144,24 @@ export class Editor { tab.truncated = f.truncated; tab.encoding = f.encoding; tab.deleted = false; + const plan = planView({ kind: f.kind, path: tab.path, asSource: tab.asSource, reload, hasMd: !!tab.md, hasCode: !!tab.code }); + // The tab keeps only the views the plan names, so a Markdown tab that goes binary/dir/image can't get stuck. + if (!plan.md) { + tab.md?.dispose(); + tab.md = undefined; + } + if (!plan.code) tab.code = undefined; if (f.kind === "text") { tab.eol = /\r\n/.test(f.text.slice(0, 10000)) ? "CRLF" : "LF"; - if (isMarkdown(tab.path) && !tab.asSource) { - tab.code = undefined; - if (tab.md && reload) tab.md.setText(f.text); + if (plan.view === "md") { + if (plan.reuse) tab.md!.setText(f.text); else { - tab.md?.dispose(); tab.md = new MarkdownView(f.text, this.fontSize); tab.el.replaceChildren(tab.md.el); tab.el.prepend(this.viewToggle(tab, "View source", icons.code)); } - } else if (tab.code && reload) tab.code.setText(f.text); + } else if (plan.reuse) tab.code!.setText(f.text); else { - tab.md?.dispose(); - tab.md = undefined; tab.el.replaceChildren(); tab.code = new CodeView(f.text, tab.lang, this.fontSize); const code = tab.code; @@ -173,8 +177,6 @@ export class Editor { } await this.refreshGit(tab, !reload); } else if (f.kind === "image") { - tab.md?.dispose(); - tab.md = undefined; if (tab.image && reload) tab.image.reload(tab.mtime); else { tab.code = undefined; @@ -198,6 +200,8 @@ export class Editor { } tab.kind = "error"; tab.code = undefined; + tab.md?.dispose(); + tab.md = undefined; tab.el.replaceChildren(h("div", { class: "notice error" }, h("strong", null, "Can't open file"), h("span", null, (e as Error).message))); } if (tab === this.active) this.status(); diff --git a/src/client/viewstate.ts b/src/client/viewstate.ts new file mode 100644 index 0000000..f81309f --- /dev/null +++ b/src/client/viewstate.ts @@ -0,0 +1,32 @@ +import { isMarkdown } from "./markdown.ts"; + +// Pure per-load view decision for a tab, so the editor's branching is testable without a DOM. + +export type LoadKind = "text" | "image" | "binary" | "error" | "dir"; +export type ViewName = "md" | "code" | "image" | "binary" | "dir" | "error"; + +export interface ViewInput { + kind: LoadKind; + path: string; + asSource: boolean; + /** A live reload of an already open tab (not the first load or a mode switch). */ + reload: boolean; + hasMd: boolean; + hasCode: boolean; +} + +export interface ViewPlan { + view: ViewName; + /** Update the existing view in place instead of building a new one. */ + reuse: boolean; + /** Whether the tab keeps a MarkdownView after this load (otherwise dispose and clear it). */ + md: boolean; + /** Whether the tab keeps a CodeView after this load (otherwise clear it). */ + code: boolean; +} + +export function planView(i: ViewInput): ViewPlan { + if (i.kind !== "text") return { view: i.kind, reuse: false, md: false, code: false }; + if (isMarkdown(i.path) && !i.asSource) return { view: "md", reuse: i.reload && i.hasMd, md: true, code: false }; + return { view: "code", reuse: i.reload && i.hasCode, md: false, code: true }; +} diff --git a/test/client.viewstate.test.ts b/test/client.viewstate.test.ts new file mode 100644 index 0000000..2c25bbf --- /dev/null +++ b/test/client.viewstate.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; + +let V: typeof import("../src/client/viewstate.ts"); +try { + V = await import("../src/client/viewstate.ts"); +} catch {} + +type In = import("../src/client/viewstate.ts").ViewInput; +type Plan = import("../src/client/viewstate.ts").ViewPlan; +const base: In = { kind: "text", path: "a.md", asSource: false, reload: false, hasMd: false, hasCode: false }; + +/** Applies a load to a tab state, the way the editor must: the plan says what the tab keeps. */ +function step(s: { hasMd: boolean; hasCode: boolean }, kind: In["kind"], extra: Partial = {}) { + const plan = V.planView({ ...base, kind, reload: true, ...s, ...extra }); + return { plan, state: { hasMd: plan.md, hasCode: plan.code } }; +} +const none = { hasMd: false, hasCode: false }; + +describe("planView", () => { + test("first load of a Markdown file is a fresh preview", () => { + expect(V.planView(base)).toEqual({ view: "md", reuse: false, md: true, code: false }); + }); + test("live reload of a preview reuses it", () => { + expect(V.planView({ ...base, reload: true, hasMd: true })).toEqual({ view: "md", reuse: true, md: true, code: false }); + }); + test("Markdown in source mode shows code; reload reuses it only when a CodeView exists", () => { + expect(V.planView({ ...base, asSource: true })).toEqual({ view: "code", reuse: false, md: false, code: true }); + expect(V.planView({ ...base, asSource: true, reload: true, hasCode: true })).toEqual({ view: "code", reuse: true, md: false, code: true }); + expect(V.planView({ ...base, asSource: true, reload: true, hasCode: false }).reuse).toBe(false); + }); + test("a stale MarkdownView is dropped when switching to source, and a stale CodeView when previewing", () => { + expect(V.planView({ ...base, asSource: true, hasMd: true }).md).toBe(false); + expect(V.planView({ ...base, hasCode: true }).code).toBe(false); + }); + test("non-Markdown text is code", () => { + expect(V.planView({ ...base, path: "a.ts" })).toEqual({ view: "code", reuse: false, md: false, code: true }); + expect(V.planView({ ...base, path: "a.md.txt" }).view).toBe("code"); + }); + test.each(["image", "binary", "dir", "error"] as const)("%s keeps neither a MarkdownView nor a CodeView", (kind) => { + const p: Plan = V.planView({ ...base, kind, reload: true, hasMd: true, hasCode: true }); + expect(p).toEqual({ view: kind, reuse: false, md: false, code: false }); + }); +}); + +describe("stuck sequences (review SL01 major)", () => { + test("md -> binary -> md shows the preview again, freshly built", () => { + let s = step(none, "text", { reload: false }); + expect(s.plan.view).toBe("md"); + s = step(s.state, "binary"); + expect(s.state).toEqual(none); + s = step(s.state, "text"); + expect(s.plan).toEqual({ view: "md", reuse: false, md: true, code: false }); + }); + test("md -> dir -> md shows the preview again, freshly built", () => { + let s = step(none, "text", { reload: false }); + s = step(s.state, "dir"); + expect(s.state).toEqual(none); + s = step(s.state, "text"); + expect(s.plan).toEqual({ view: "md", reuse: false, md: true, code: false }); + }); + test("md source -> binary -> md source builds a fresh CodeView", () => { + let s = step(none, "text", { reload: false, asSource: true }); + s = step(s.state, "binary", { asSource: true }); + s = step(s.state, "text", { asSource: true }); + expect(s.plan).toEqual({ view: "code", reuse: false, md: false, code: true }); + }); +}); From ba843e6b423ad75c94fe17b03154165cbc0a89bf Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:27:39 +0100 Subject: [PATCH 3/9] feat(markdown): CommonMark block and inline core with hard limits --- src/client/markdown.ts | 948 ++++++++++++++++++++++++++++++++++- src/client/mdrender.ts | 114 ++++- src/client/styles.css | 46 ++ test/client.mdrender.test.ts | 145 ++++++ test/fixtures/md-core.json | 871 ++++++++++++++++++++++++++++++++ test/fixtures/md-inline.json | 675 +++++++++++++++++++++++++ test/markdown.core.test.ts | 46 ++ test/markdown.limits.test.ts | 137 +++++ 8 files changed, 2960 insertions(+), 22 deletions(-) create mode 100644 test/client.mdrender.test.ts create mode 100644 test/fixtures/md-core.json create mode 100644 test/fixtures/md-inline.json create mode 100644 test/markdown.core.test.ts create mode 100644 test/markdown.limits.test.ts diff --git a/src/client/markdown.ts b/src/client/markdown.ts index 39a758e..9f08321 100644 --- a/src/client/markdown.ts +++ b/src/client/markdown.ts @@ -1,10 +1,35 @@ -// Pure Markdown parser: no DOM, no dependencies, linear time. Block pass is line-oriented. +// Pure Markdown parser: no DOM, no dependencies, linear time. The block pass is line-oriented with +// nesting capped at 32 (so recursion is bounded); the inline pass uses a CommonMark-style delimiter +// stack. Every limit degrades to literal text and nothing here throws on any input. export interface Text { type: "text"; value: string; } -export type Inline = Text; +export interface Emphasis { + type: "em" | "strong"; + children: Inline[]; +} +export interface CodeSpan { + type: "codespan"; + value: string; +} +export interface Break { + type: "break"; +} +export interface Link { + type: "link"; + href: string; + title: string | null; + children: Inline[]; +} +export interface Image { + type: "image"; + src: string; + alt: string; + title: string | null; +} +export type Inline = Text | Emphasis | CodeSpan | Break | Link | Image; export interface Heading { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; @@ -14,7 +39,32 @@ export interface Paragraph { type: "paragraph"; children: Inline[]; } -export type Block = Heading | Paragraph; +export interface ThematicBreak { + type: "hr"; +} +/** Fenced (lang = first word of the info string) or indented (lang = null) code. */ +export interface CodeBlock { + type: "code"; + lang: string | null; + value: string; +} +export interface BlockQuote { + type: "blockquote"; + children: Block[]; +} +export interface ListItem { + type: "item"; + children: Block[]; +} +export interface List { + type: "list"; + ordered: boolean; + /** Start number of an ordered list; null for bullet lists. */ + start: number | null; + tight: boolean; + items: ListItem[]; +} +export type Block = Heading | Paragraph | ThematicBreak | CodeBlock | BlockQuote | List; /** True for `.md` / `.markdown` paths, case-insensitive. */ export function isMarkdown(path: string): boolean { @@ -22,12 +72,40 @@ export function isMarkdown(path: string): boolean { return name.endsWith(".md") || name.endsWith(".markdown"); } + +const MAX_DEPTH = 32; // container nesting and inline nesting +const MAX_LINE = 10_000; // inline characters per line before the text is left literal +const MAX_DEFS = 10_000; // link reference definitions +const MAX_SCAN = 1500; // characters scanned for a link destination/title +const MAX_LABEL = 999; + const isWs = (c: string | undefined) => c === " " || c === "\t"; -/** ATX heading: up to 3 spaces, 1-6 `#`, then whitespace or end of line. */ -function atx(line: string): Heading | null { +function expandTabs(line: string): string { + let i = 0; + while (i < line.length && (line[i] === " " || line[i] === "\t")) i++; + if (!line.slice(0, i).includes("\t")) return line; + let col = 0; + for (let k = 0; k < i; k++) col = line[k] === "\t" ? col + 4 - (col % 4) : col + 1; + return " ".repeat(col) + line.slice(i); +} +function indent(line: string): number { let i = 0; - while (i < 3 && line[i] === " ") i++; + while (line.charCodeAt(i) === 32) i++; + return i; +} +function isBlank(line: string): boolean { + for (let i = 0; i < line.length; i++) { + const c = line.charCodeAt(i); + if (c !== 32 && c !== 9) return false; + } + return true; +} + +/** ATX heading: up to 3 spaces, 1-6 `#`, then whitespace or end of line. */ +function atx(line: string): { level: Heading["level"]; text: string } | null { + const i = indent(line); + if (i > 3) return null; let n = 0; while (line[i + n] === "#") n++; if (n < 1 || n > 6) return null; @@ -38,27 +116,867 @@ function atx(line: string): Heading | null { let e = text.length; while (e > 0 && text[e - 1] === "#") e--; if (e < text.length && (e === 0 || isWs(text[e - 1]))) text = text.slice(0, e).trim(); - return { type: "heading", level: n as Heading["level"], children: text ? [{ type: "text", value: text }] : [] }; + return { level: n as Heading["level"], text }; } -export function parseMarkdown(src: string): Block[] { +/** Thematic break: 3+ of `-`, `_` or `*` (spaces allowed between), indent <= 3. */ +function isHr(line: string): boolean { + const i = indent(line); + if (i > 3) return false; + const ch = line[i]; + if (ch !== "-" && ch !== "_" && ch !== "*") return false; + let n = 0; + for (let k = i; k < line.length; k++) { + const c = line[k]; + if (c === ch) n++; + else if (c !== " " && c !== "\t") return false; + } + return n >= 3; +} + +/** Setext underline: `=` run (level 1) or `-` run (level 2), trailing spaces only. */ +function setext(line: string): 1 | 2 | null { + const i = indent(line); + if (i > 3) return null; + const ch = line[i]; + if (ch !== "=" && ch !== "-") return null; + let k = i; + while (line[k] === ch) k++; + while (k < line.length && isWs(line[k])) k++; + return k === line.length ? (ch === "=" ? 1 : 2) : null; +} + +interface Fence { + ch: string; + len: number; + indent: number; + lang: string | null; +} +function fenceOpen(line: string): Fence | null { + const i = indent(line); + if (i > 3) return null; + const ch = line[i]; + if (ch !== "`" && ch !== "~") return null; + let n = 0; + while (line[i + n] === ch) n++; + if (n < 3) return null; + const info = line.slice(i + n).trim(); + if (ch === "`" && info.includes("`")) return null; + const word = info.split(/[ \t]/, 1)[0] ?? ""; + return { ch, len: n, indent: i, lang: word ? unescapeStr(word) : null }; +} +function fenceClose(line: string, f: Fence): boolean { + const i = indent(line); + if (i > 3) return false; + let n = 0; + while (line[i + n] === f.ch) n++; + return n >= f.len && isBlank(line.slice(i + n)); +} + +/** Position just after a `>` marker (and one optional space), or -1. */ +function quoteAt(line: string): number { + const i = indent(line); + if (i > 3 || line[i] !== ">") return -1; + return line[i + 1] === " " ? i + 2 : i + 1; +} + +interface Marker { + key: string; + ordered: boolean; + num: number; + offset: number; + empty: boolean; +} +function listMarker(line: string): Marker | null { + const i = indent(line); + if (i > 3) return null; + const c = line[i]; + let end: number; + let key: string; + let ordered = false; + let num = 0; + if (c === "-" || c === "+" || c === "*") { + end = i + 1; + key = "b" + c; + } else if (c !== undefined && c >= "0" && c <= "9") { + let k = i; + while (k < line.length && k - i < 10 && line[k]! >= "0" && line[k]! <= "9") k++; + if (k - i > 9) return null; + const d = line[k]; + if (d !== "." && d !== ")") return null; + ordered = true; + num = Number(line.slice(i, k)); + end = k + 1; + key = "o" + d; + } else return null; + if (end < line.length && !isWs(line[end])) return null; + let sp = 0; + while (isWs(line[end + sp])) sp++; + const empty = end + sp >= line.length; + const offset = empty || sp >= 5 ? end + 1 : end + sp; + return { key, ordered, num, offset, empty }; +} + +/** Would this line start a block that interrupts an open paragraph (or ends a lazy continuation)? */ +function startsBlock(line: string, container: boolean): boolean { + if (indent(line) > 3) return false; + if (isHr(line) || atx(line) || fenceOpen(line)) return true; + if (!container) return false; + if (quoteAt(line) >= 0) return true; + const m = listMarker(line); + return !!m && !m.empty && (!m.ordered || m.num === 1); +} + +/** Is a paragraph still open after this line (for lazy continuation)? */ +function paraOpen(line: string): boolean { + let s = line; + for (let n = 0; n < MAX_DEPTH + 1; n++) { + const q = quoteAt(s); + if (q >= 0) { + s = s.slice(q); + continue; + } + const m = listMarker(s); + if (m && !m.empty && !isHr(s)) { + s = s.slice(m.offset); + continue; + } + break; + } + return !isBlank(s) && !isHr(s) && !atx(s) && !fenceOpen(s) && indent(s) < 4; +} + +// --------------------------------------------------------------------------------------------- +// Entities, escapes + +const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; +const PUNCT = /[!-/:-@[-`{-~]/; + +function decodeEntity(s: string, i: number): { str: string; len: number } | null { + const end = s.indexOf(";", i + 2); + if (end < 0 || end - i > 12) return null; + const body = s.slice(i + 1, end); + let str: string | undefined; + if (body[0] === "#") { + const hex = body[1] === "x" || body[1] === "X"; + const digits = body.slice(hex ? 2 : 1); + if (!(hex ? /^[0-9a-fA-F]{1,6}$/ : /^[0-9]{1,7}$/).test(digits)) return null; + const cp = parseInt(digits, hex ? 16 : 10); + str = cp === 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff) ? "�" : String.fromCodePoint(cp); + } else if (Object.hasOwn(ENTITIES, body)) str = ENTITIES[body]; + return str === undefined ? null : { str, len: end + 1 - i }; +} + +/** Resolve backslash escapes and entities in a link destination, title or info string. */ +function unescapeStr(s: string): string { + if (!s.includes("\\") && !s.includes("&")) return s; + let out = ""; + for (let i = 0; i < s.length; i++) { + const c = s[i]!; + if (c === "\\" && i + 1 < s.length && PUNCT.test(s[i + 1]!)) out += s[++i]; + else if (c === "&") { + const e = decodeEntity(s, i); + if (e) { + out += e.str; + i += e.len - 1; + } else out += c; + } else out += c; + } + return out; +} + +// --------------------------------------------------------------------------------------------- +// Link reference definitions + +type Defs = Map; +const normLabel = (l: string) => l.trim().replace(/\s+/g, " ").toLowerCase(); + +function parseDef(line: string): { label: string; href: string; title: string | null } | null { + let i = indent(line); + if (i > 3 || line[i] !== "[") return null; + let j = i + 1; + while (j < line.length && j - i <= MAX_LABEL && line[j] !== "]") { + if (line[j] === "[") return null; + if (line[j] === "\\") j++; + j++; + } + if (line[j] !== "]" || line[j + 1] !== ":") return null; + const label = line.slice(i + 1, j); + if (isBlank(label)) return null; + let k = j + 2; + while (isWs(line[k])) k++; + let href: string; + if (line[k] === "<") { + const e = line.indexOf(">", k + 1); + if (e < 0) return null; + href = line.slice(k + 1, e); + k = e + 1; + } else { + const st = k; + while (k < line.length && !isWs(line[k])) k++; + href = line.slice(st, k); + if (!href) return null; + } + const afterDest = k; + while (isWs(line[k])) k++; + if (k >= line.length) return { label, href: unescapeStr(href), title: null }; + if (k === afterDest) return null; + const open = line[k]!; + const close = open === "(" ? ")" : open; + if (open !== '"' && open !== "'" && open !== "(") return null; + const rest = line.slice(k).trimEnd(); + if (rest.length < 2 || rest[rest.length - 1] !== close) return null; + return { label, href: unescapeStr(href), title: unescapeStr(rest.slice(1, -1)) }; +} + +// --------------------------------------------------------------------------------------------- +// Block pass + +interface Pending { + node: Heading | Paragraph; + text: string; +} +interface Ctx { + defs: Defs; + pending: Pending[]; +} + +interface FenceTrack { + f: Fence | null; +} +function track(t: FenceTrack, line: string) { + if (t.f) { + if (fenceClose(line, t.f)) t.f = null; + } else t.f = fenceOpen(line); +} + +function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[]; gap: boolean } { const blocks: Block[] = []; + const n = lines.length; + const canNest = depth < MAX_DEPTH; + let gap = false; + let blank = false; let para: string[] = []; + let paraBlank = false; + + const add = (b: Block) => { + if (blank && blocks.length) gap = true; + blank = false; + blocks.push(b); + }; + const addText = (b: Heading | Paragraph, text: string) => { + ctx.pending.push({ node: b, text }); + blocks.push(b); + }; const flush = () => { - if (para.length) blocks.push({ type: "paragraph", children: [{ type: "text", value: para.join("\n") }] }); + if (!para.length) return; + let from = 0; + while (from < para.length && ctx.defs.size < MAX_DEFS) { + const d = parseDef(para[from]!); + if (!d) break; + const key = normLabel(d.label); + if (!ctx.defs.has(key)) ctx.defs.set(key, { href: d.href, title: d.title }); + from++; + } + const rest = from ? para.slice(from) : para; para = []; + if (!rest.length) { + if (paraBlank && blocks.length) gap = true; + return; + } + if (paraBlank && blocks.length) gap = true; + addText({ type: "paragraph", children: [] }, rest.join("\n").trimEnd()); }; - for (const raw of src.split(/\r\n|\r|\n/)) { - if (raw.trim() === "") { + + let i = 0; + while (i < n) { + const line = lines[i]!; + if (isBlank(line)) { flush(); + blank = true; + i++; continue; } - const hd = atx(raw); - if (hd) { + if (para.length) { + const su = setext(line); + if (su) { + if (paraBlank && blocks.length) gap = true; + addText({ type: "heading", level: su, children: [] }, para.map((l) => l.trim()).join("\n")); + para = []; + i++; + continue; + } + if (!startsBlock(line, canNest)) { + para.push(line.trimStart()); + i++; + continue; + } flush(); - blocks.push(hd); - } else para.push(raw.trim()); + } + const ind = indent(line); + if (ind >= 4) { + const code: string[] = []; + let last = i; + let j = i; + while (j < n && (isBlank(lines[j]!) || indent(lines[j]!) >= 4)) { + code.push(lines[j]!.slice(4)); + if (!isBlank(lines[j]!)) last = j; + j++; + } + code.length = last - i + 1; + add({ type: "code", lang: null, value: code.join("\n") }); + i = last + 1; + continue; + } + const f = fenceOpen(line); + if (f) { + const body: string[] = []; + let j = i + 1; + while (j < n && !fenceClose(lines[j]!, f)) { + const l = lines[j]!; + let strip = 0; + while (strip < f.indent && l[strip] === " ") strip++; + body.push(l.slice(strip)); + j++; + } + add({ type: "code", lang: f.lang, value: body.join("\n") }); + i = j + 1; + continue; + } + const h = atx(line); + if (h) { + if (blank && blocks.length) gap = true; + blank = false; + addText({ type: "heading", level: h.level, children: [] }, h.text); + i++; + continue; + } + if (isHr(line)) { + add({ type: "hr" }); + i++; + continue; + } + if (canNest && quoteAt(line) >= 0) { + const inner: string[] = []; + const t: FenceTrack = { f: null }; + while (i < n) { + const L = lines[i]!; + const q = quoteAt(L); + if (q >= 0) { + const s = L.slice(q); + inner.push(s); + track(t, s); + } else if (!isBlank(L) && inner.length && !t.f && paraOpen(inner[inner.length - 1]!) && !startsBlock(L, true) && !listMarker(L)) inner.push(L); + else break; + i++; + } + add({ type: "blockquote", children: parseBlocks(inner, depth + 1, ctx).blocks }); + continue; + } + const m = canNest ? listMarker(line) : null; + if (m) { + const items: ListItem[] = []; + let loose = false; + while (i < n) { + const cur = listMarker(lines[i]!); + if (!cur || cur.key !== m.key || isHr(lines[i]!)) break; + const itemLines = [lines[i]!.slice(cur.offset)]; + const t: FenceTrack = { f: null }; + track(t, itemLines[0]!); + i++; + let blanks = 0; + if (!(cur.empty && i < n && isBlank(lines[i]!))) { + while (i < n) { + const L = lines[i]!; + if (isBlank(L)) { + blanks++; + i++; + continue; + } + if (indent(L) >= cur.offset) { + for (; blanks > 0; blanks--) itemLines.push(""); + const s = L.slice(cur.offset); + itemLines.push(s); + track(t, s); + } else if (blanks === 0 && !t.f && paraOpen(itemLines[itemLines.length - 1]!) && !startsBlock(L, true) && !listMarker(L)) itemLines.push(L); + else break; + i++; + } + } + const r = parseBlocks(itemLines, depth + 1, ctx); + if (r.gap) loose = true; + items.push({ type: "item", children: r.blocks }); + const nxt = i < n ? listMarker(lines[i]!) : null; + if (nxt && nxt.key === m.key && !isHr(lines[i]!)) { + if (blanks > 0) loose = true; + } else { + i -= blanks; + break; + } + } + add({ type: "list", ordered: m.ordered, start: m.ordered ? m.num : null, tight: !loose, items }); + continue; + } + paraBlank = blank; + blank = false; + para.push(line.trimStart()); + i++; } flush(); + return { blocks, gap }; +} + +// --------------------------------------------------------------------------------------------- +// Inline pass + +interface N { + k: 0 | 1 | 2; // text | finished node | delimiter run + v: string; + node?: Inline; + d: number; // nesting depth of a finished node + prev: N | null; + next: N | null; + ch: string; + n: number; // remaining delimiter characters + orig: number; + open: boolean; + close: boolean; + dprev: N | null; + dnext: N | null; +} +const mk = (k: 0 | 1 | 2, v = ""): N => ({ k, v, d: 0, prev: null, next: null, ch: "", n: 0, orig: 0, open: false, close: false, dprev: null, dnext: null }); + +interface Bracket { + n: N; + img: boolean; + src: number; // index just after the `[` + dBottom: N | null; + serial: number; +} + +const WS = /\s/; +const UPUNCT = /[\p{P}\p{S}]/u; + +function toInline(nodes: N[]): Inline[] { + const out: Inline[] = []; + const text = (s: string) => { + if (!s) return; + const last = out[out.length - 1]; + if (last && last.type === "text") last.value += s; + else out.push({ type: "text", value: s }); + }; + for (const x of nodes) { + if (x.k === 0) text(x.v); + else if (x.k === 2) text(x.ch.repeat(x.n)); + else if (x.node!.type === "text") text((x.node as Text).value); + else out.push(x.node!); + } + return out; +} +function plain(nodes: Inline[]): string { + let s = ""; + for (const x of nodes) { + if (x.type === "text" || x.type === "codespan") s += x.value; + else if (x.type === "image") s += x.alt; + else if (x.type === "break") s += " "; + else s += plain(x.children); + } + return s; +} + +/** Destination and optional title of an inline link, starting just after the `(`. */ +function linkTail(s: string, p: number): { href: string; title: string | null; end: number } | null { + const lim = Math.min(s.length, p + MAX_SCAN); + let k = p; + const skip = () => { + while (k < lim && (s[k] === " " || s[k] === "\n" || s[k] === "\t")) k++; + }; + skip(); + let href: string; + if (s[k] === "<") { + let e = k + 1; + while (e < lim && s[e] !== ">" && s[e] !== "\n" && s[e] !== "<") e++; + if (s[e] !== ">") return null; + href = unescapeStr(s.slice(k + 1, e)); + k = e + 1; + } else { + const st = k; + let depth = 0; + while (k < lim) { + const c = s[k]!; + if (c === "\\" && k + 1 < lim && PUNCT.test(s[k + 1]!)) { + k += 2; + continue; + } + if (c <= " " || c === "\x7f") break; + if (c === "(") { + if (++depth > MAX_DEPTH) return null; + } else if (c === ")") { + if (depth === 0) break; + depth--; + } + k++; + } + if (depth !== 0) return null; + href = unescapeStr(s.slice(st, k)); + } + const before = k; + skip(); + let title: string | null = null; + const o = s[k]; + if (k > before && (o === '"' || o === "'" || o === "(")) { + const close = o === "(" ? ")" : o; + let e = k + 1; + while (e < lim && s[e] !== close) e += s[e] === "\\" ? 2 : 1; + if (e >= lim || s[e] !== close) return null; + title = unescapeStr(s.slice(k + 1, e)); + k = e + 1; + skip(); + } + if (s[k] !== ")") return null; + return { href, title, end: k + 1 }; +} + +/** End index of a `[label]` starting at p (just after the `[`), or -1. */ +function labelEnd(s: string, p: number): number { + const lim = Math.min(s.length, p + MAX_LABEL + 1); + for (let k = p; k < lim; k++) { + const c = s[k]; + if (c === "\\") k++; + else if (c === "[") return -1; + else if (c === "]") return k; + } + return -1; +} + +function parseInline(s: string, defs: Defs): Inline[] { + let head: N | null = null; + let tail: N | null = null; + let dhead: N | null = null; + let dtail: N | null = null; + const brackets: Bracket[] = []; + let serial = 0; + let linkSerial = 0; + + const append = (x: N) => { + x.prev = tail; + x.next = null; + if (tail) tail.next = x; + else head = x; + tail = x; + }; + const unlink = (x: N) => { + if (x.prev) x.prev.next = x.next; + else head = x.next; + if (x.next) x.next.prev = x.prev; + else tail = x.prev; + }; + const dunlink = (x: N) => { + if (x.dprev) x.dprev.dnext = x.dnext; + else dhead = x.dnext; + if (x.dnext) x.dnext.dprev = x.dprev; + else dtail = x.dprev; + }; + const text = (v: string) => { + if (v) { + const x = mk(0, v); + append(x); + } + }; + const nodeOf = (node: Inline, d: number) => { + const x = mk(1); + x.node = node; + x.d = d; + return x; + }; + const range = (after: N | null): N[] => { + const out: N[] = []; + for (let x = after ? after.next : head; x; x = x.next) out.push(x); + return out; + }; + const depthOf = (kids: N[]) => { + let d = 0; + for (const x of kids) if (x.k === 1 && x.d > d) d = x.d; + return d; + }; + + function processEmph(bottom: N | null) { + const ob = new Map(); + let closer = bottom ? bottom.dnext : dhead; + while (closer) { + if (!closer.close) { + closer = closer.dnext; + continue; + } + const key = closer.ch + (closer.orig % 3) + (closer.open ? 1 : 0); + const lim = ob.has(key) ? ob.get(key)! : bottom; + let opener = closer.dprev; + let found: N | null = null; + while (opener && opener !== bottom && opener !== lim) { + if (opener.ch === closer.ch && opener.open) { + const odd = (closer.open || opener.close) && closer.orig % 3 !== 0 && (opener.orig + closer.orig) % 3 === 0; + if (!odd) { + found = opener; + break; + } + } + opener = opener.dprev; + } + if (found) { + const kids: N[] = []; + for (let x = found.next!; x !== closer; x = x.next!) kids.push(x); + const d = depthOf(kids); + if (d + 1 > MAX_DEPTH) found = null; + else { + const use = closer.n >= 2 && found.n >= 2 ? 2 : 1; + const en = nodeOf({ type: use === 2 ? "strong" : "em", children: toInline(kids) }, d + 1); + found.next = en; + en.prev = found; + en.next = closer; + closer.prev = en; + found.dnext = closer; + closer.dprev = found; + found.n -= use; + closer.n -= use; + if (found.n === 0) { + unlink(found); + dunlink(found); + } + if (closer.n === 0) { + const nx = closer.dnext; + unlink(closer); + dunlink(closer); + closer = nx; + } + continue; + } + } + const old: N = closer; + closer = closer.dnext; + ob.set(key, old.dprev); + if (!old.open) dunlink(old); + } + if (bottom) { + bottom.dnext = null; + dtail = bottom; + } else { + dhead = null; + dtail = null; + } + } + + // Backtick runs, indexed lazily by length with a monotonic pointer per length. + let runs: Map | null = null; + const closingRun = (len: number, from: number): number => { + if (!runs) { + runs = new Map(); + for (let k = 0; k < s.length; ) { + if (s.charCodeAt(k) !== 96) { + k++; + continue; + } + let e = k; + while (s.charCodeAt(e) === 96) e++; + let r = runs.get(e - k); + if (!r) runs.set(e - k, (r = { pos: [], ptr: 0 })); + r.pos.push(k); + k = e; + } + } + const r = runs.get(len); + if (!r) return -1; + while (r.ptr < r.pos.length && r.pos[r.ptr]! < from) r.ptr++; + return r.ptr < r.pos.length ? r.pos[r.ptr]! : -1; + }; + + let run = 0; + let i = 0; + const len = s.length; + const flush = (to: number) => { + if (to > run) text(s.slice(run, to)); + }; + while (i < len) { + const c = s.charCodeAt(i); + switch (c) { + case 92: { + // backslash + const nx = s[i + 1]; + flush(i); + if (nx === "\n") { + append(nodeOf({ type: "break" }, 0)); + i += 2; + while (s[i] === " ") i++; + } else if (nx !== undefined && PUNCT.test(nx)) { + text(nx); + i += 2; + } else { + text("\\"); + i++; + } + run = i; + break; + } + case 96: { + let e = i; + while (s.charCodeAt(e) === 96) e++; + const n = e - i; + const close = closingRun(n, e); + flush(i); + if (close < 0) text(s.slice(i, e)); + else { + let v = s.slice(e, close).replace(/\n/g, " "); + if (v.length > 2 && v[0] === " " && v[v.length - 1] === " " && !isBlank(v)) v = v.slice(1, -1); + append(nodeOf({ type: "codespan", value: v }, 0)); + e = close + n; + } + i = run = e; + break; + } + case 42: + case 95: { + let e = i; + while (s.charCodeAt(e) === c) e++; + flush(i); + const prev = i > 0 ? s[i - 1]! : "\n"; + const next = e < len ? s[e]! : "\n"; + const wsP = WS.test(prev); + const wsN = WS.test(next); + const puP = UPUNCT.test(prev); + const puN = UPUNCT.test(next); + const left = !wsN && (!puN || wsP || puP); + const right = !wsP && (!puP || wsN || puN); + const x = mk(2); + x.ch = c === 42 ? "*" : "_"; + x.n = x.orig = e - i; + x.open = c === 42 ? left : left && (!right || puP); + x.close = c === 42 ? right : right && (!left || puN); + append(x); + x.dprev = dtail; + if (dtail) dtail.dnext = x; + else dhead = x; + dtail = x; + i = run = e; + break; + } + case 33: + case 91: { + if (c === 33 && s[i + 1] !== "[") { + i++; + break; + } + flush(i); + const img = c === 33; + const x = mk(0, img ? "![" : "["); + append(x); + brackets.push({ n: x, img, src: i + (img ? 2 : 1), dBottom: dtail, serial: ++serial }); + i = run = i + (img ? 2 : 1); + break; + } + case 93: { + flush(i); + const b = brackets[brackets.length - 1]; + if (!b || (!b.img && b.serial < linkSerial)) { + if (b) brackets.pop(); + text("]"); + i = run = i + 1; + break; + } + let href: string | null = null; + let title: string | null = null; + let end = i + 1; + if (s[i + 1] === "(") { + const t = linkTail(s, i + 2); + if (t) ({ href, title, end } = t); + } + if (href === null) { + let def: { href: string; title: string | null } | undefined; + const own = () => (i - b.src <= MAX_LABEL ? s.slice(b.src, i) : null); + if (s[i + 1] === "[") { + const e = labelEnd(s, i + 2); + if (e >= 0) { + const lab = e === i + 2 ? own() : s.slice(i + 2, e); + if (lab !== null) def = defs.get(normLabel(lab)); + end = e + 1; + } + } else { + const lab = own(); + if (lab !== null) def = defs.get(normLabel(lab)); + } + if (def) ({ href, title } = def); + } + if (href !== null) { + processEmph(b.dBottom); + const kids = range(b.n); + const d = depthOf(kids); + if (d + 1 <= MAX_DEPTH) { + const children = toInline(kids); + const node: Inline = b.img + ? { type: "image", src: href, alt: plain(children), title } + : { type: "link", href, title, children }; + // Cut the opener and everything after it, then add the finished node. + const before = b.n.prev; + if (before) before.next = null; + else head = null; + tail = before; + append(nodeOf(node, b.img ? 0 : d + 1)); + brackets.pop(); + if (!b.img) linkSerial = serial + 1; + i = run = end; + break; + } + } + brackets.pop(); + text("]"); + i = run = i + 1; + break; + } + case 38: { + const e = decodeEntity(s, i); + if (e) { + flush(i); + text(e.str); + i = run = i + e.len; + } else i++; + break; + } + case 10: { + let e = i; + while (e > run && s[e - 1] === " ") e--; + const hard = i - e >= 2; + flush(e); + if (hard) append(nodeOf({ type: "break" }, 0)); + else text("\n"); + i++; + while (s[i] === " ") i++; + run = i; + break; + } + default: + i++; + } + } + flush(len); + processEmph(null); + return toInline(range(null)); +} + +function tooLong(s: string): boolean { + if (s.length <= MAX_LINE) return false; + let from = 0; + for (;;) { + const e = s.indexOf("\n", from); + if ((e < 0 ? s.length : e) - from > MAX_LINE) return true; + if (e < 0) return false; + from = e + 1; + } +} + +export function parseMarkdown(src: string): Block[] { + const lines = src.split(/\r\n|\r|\n/).map(expandTabs); + const ctx: Ctx = { defs: new Map(), pending: [] }; + const { blocks } = parseBlocks(lines, 0, ctx); + for (const { node, text } of ctx.pending) { + node.children = !text ? [] : tooLong(text) ? [{ type: "text", value: text }] : parseInline(text, ctx.defs); + } return blocks; } diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts index d7041f5..bc22a4c 100644 --- a/src/client/mdrender.ts +++ b/src/client/mdrender.ts @@ -1,17 +1,117 @@ import type { Block, Inline } from "./markdown.ts"; -function inline(parent: HTMLElement, nodes: Inline[]) { - for (const n of nodes) parent.append(n.value); +/** Rendering stops once this many DOM nodes exist. */ +const MAX_NODES = 200_000; + +class Budget { + n = 0; + over = false; + /** Counts `k` nodes; false once the cap is exceeded. */ + take(k = 1): boolean { + this.n += k; + if (this.n > MAX_NODES) this.over = true; + return !this.over; + } +} + +function inline(parent: HTMLElement, nodes: Inline[], b: Budget) { + for (const n of nodes) { + if (!b.take()) return; + switch (n.type) { + case "text": + parent.append(n.value); + break; + case "em": + case "strong": { + const el = document.createElement(n.type); + parent.append(el); + inline(el, n.children, b); + break; + } + case "codespan": { + const el = document.createElement("code"); + el.textContent = n.value; + parent.append(el); + break; + } + case "break": + parent.append(document.createElement("br")); + break; + case "link": + // Links are made clickable (and URL-checked) in a later slice; the label shows as text. + inline(parent, n.children, b); + break; + case "image": + parent.append(n.alt); + break; + } + } +} + +function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) { + for (const blk of list) { + if (b.over || !b.take()) return; + switch (blk.type) { + case "heading": { + const el = document.createElement(`h${blk.level}`); + parent.append(el); + inline(el, blk.children, b); + break; + } + case "paragraph": + if (tight) { + inline(parent, blk.children, b); + } else { + const el = document.createElement("p"); + parent.append(el); + inline(el, blk.children, b); + } + break; + case "hr": + parent.append(document.createElement("hr")); + break; + case "code": { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = blk.value; + pre.append(code); + parent.append(pre); + b.take(2); + break; + } + case "blockquote": { + const el = document.createElement("blockquote"); + parent.append(el); + blocks(el, blk.children, b); + break; + } + case "list": { + const el = document.createElement(blk.ordered ? "ol" : "ul"); + if (blk.ordered && blk.start !== 1 && blk.start !== null) el.setAttribute("start", String(blk.start)); + parent.append(el); + for (const item of blk.items) { + if (!b.take()) return; + const li = document.createElement("li"); + el.append(li); + blocks(li, item.children, b, blk.tight); + } + break; + } + } + } } /** Turns the AST into DOM using createElement/textContent only; never innerHTML. */ -export function renderMarkdown(blocks: Block[]): HTMLElement { +export function renderMarkdown(list: Block[]): HTMLElement { const root = document.createElement("div"); root.className = "md-doc"; - for (const b of blocks) { - const el = document.createElement(b.type === "heading" ? `h${b.level}` : "p"); - inline(el, b.children); - root.append(el); + const b = new Budget(); + blocks(root, list, b); + if (b.over) { + const note = document.createElement("div"); + note.className = "md-truncated"; + note.textContent = "Preview truncated, view source"; + root.append(note); } return root; } diff --git a/src/client/styles.css b/src/client/styles.css index 551146d..b44bb8d 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -882,6 +882,52 @@ svg { line-height: 1.25; margin: 1.4em 0 0.6em; } +.md-doc blockquote { + margin: 0 0 1em; + padding: 0 1em; + color: var(--fg-muted); + border-left: 3px solid var(--border-strong); +} +.md-doc ul, +.md-doc ol { + margin: 0 0 1em; + padding-left: 2em; +} +.md-doc li > ul, +.md-doc li > ol { + margin-bottom: 0; +} +.md-doc hr { + border: 0; + border-top: 1px solid var(--border-strong); + margin: 1.5em 0; +} +.md-doc code { + font-family: var(--font-mono); + font-size: 0.92em; + background: var(--bg-elev); + border-radius: 3px; + padding: 0.1em 0.35em; +} +.md-doc pre { + margin: 0 0 1em; + padding: 0.8em 1em; + overflow-x: auto; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.md-doc pre code { + background: none; + padding: 0; +} +.md-truncated { + margin: 1em 0; + padding: 0.5em 0.8em; + color: var(--warn); + border: 1px solid var(--border-strong); + border-radius: var(--radius); +} .md-doc h1 { font-size: 2em; padding-bottom: 0.3em; diff --git a/test/client.mdrender.test.ts b/test/client.mdrender.test.ts new file mode 100644 index 0000000..e8bb9b1 --- /dev/null +++ b/test/client.mdrender.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; + +let M: typeof import("../src/client/markdown.ts"); +let R: typeof import("../src/client/mdrender.ts"); +beforeAll(async () => { + installDom(); + try { + M = await import("../src/client/markdown.ts"); + R = await import("../src/client/mdrender.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const render = (src: string) => R.renderMarkdown(M.parseMarkdown(src)) as unknown as FakeElement; +const q = (el: FakeElement, sel: string) => el.querySelectorAll(sel); +const names = (el: FakeElement): string[] => { + const out: string[] = []; + const walk = (e: FakeElement) => { + for (const c of e.children) if ("tag" in c) (out.push(c.tag), walk(c)); + }; + walk(el); + return out; +}; + +describe("block rendering", () => { + test("block quote", () => { + const el = render("> a\n>\n> > b"); + expect(q(el, "blockquote").length).toBe(2); + }); + + test("bullet list is ul/li, ordered list is ol with a start attribute only when not 1", () => { + expect(q(render("- a\n- b"), "ul").length).toBe(1); + expect(q(render("- a\n- b"), "li").length).toBe(2); + const ol3 = q(render("3. a\n4. b"), "ol")[0]!; + expect(ol3.getAttribute("start")).toBe("3"); + expect(q(render("1. a"), "ol")[0]!.getAttribute("start")).toBeNull(); + }); + + test("a tight list has no p inside li; a loose list does", () => { + expect(q(render("- a\n- b"), "p").length).toBe(0); + expect(q(render("- a\n\n- b"), "p").length).toBe(2); + expect(q(render("- a\n- b"), "li")[0]!.textContent).toBe("a"); + }); + + test("nested lists nest in the DOM", () => { + const el = render("- a\n - b\n- c"); + expect(q(el, "ul").length).toBe(2); + expect(q(q(el, "li")[0]!, "ul").length).toBe(1); + }); + + test("code block is pre > code and keeps the text verbatim", () => { + const el = render("```js\nlet = 1;\n y\n```"); + const pre = q(el, "pre"); + expect(pre.length).toBe(1); + expect(q(pre[0]!, "code").length).toBe(1); + expect(pre[0]!.textContent).toBe("let = 1;\n y"); + }); + + test("thematic break", () => { + expect(q(render("a\n\n***\n\nb"), "hr").length).toBe(1); + }); +}); + +describe("inline rendering", () => { + test("em, strong, code, br", () => { + const el = render("*a* **b** `c` d \ne"); + expect(q(el, "em")[0]!.textContent).toBe("a"); + expect(q(el, "strong")[0]!.textContent).toBe("b"); + expect(q(el, "code")[0]!.textContent).toBe("c"); + expect(q(el, "br").length).toBe(1); + }); + + test("entities appear as text", () => { + const el = render("<b> &"); + expect(el.textContent).toBe(" &"); + expect(q(el, "b").length).toBe(0); + }); + + test("backslash escapes produce no emphasis", () => { + const el = render("\\*a\\*"); + expect(q(el, "em").length).toBe(0); + expect(el.textContent).toBe("*a*"); + }); +}); + +describe("AC-8 raw HTML is text", () => { + const BAD = ["script", "img", "b", "div", "iframe", "style", "a", "i"]; + test.each([ + "", + "", + "", + "x", + "
\nhello\n
", + "a b c", + "
x", + ])("%s", (src) => { + const el = render(src); + for (const tag of BAD) expect(q(el, tag).length).toBe(0); + expect(names(el).every((t) => !BAD.includes(t))).toBe(true); + expect(el.textContent.replace(/\s+/g, "")).toContain(src.split("\n")[0]!.replace(/\s+/g, "").slice(0, 4)); + }); + + test("the characters are shown literally, and Markdown inside still renders as Markdown", () => { + const el = render("*x*"); + expect(el.textContent).toBe("x"); + expect(q(el, "em").length).toBe(1); + expect(q(el, "b").length).toBe(0); + }); + + test("a script tag keeps its angle brackets", () => { + expect(render("").textContent).toBe(""); + }); + + test("an HTML comment is shown", () => { + expect(render("").textContent).toBe(""); + }); +}); + +describe("AC-10 node cap", () => { + const para = (i: number) => ({ type: "paragraph", children: [{ type: "text", value: "p" + i }] }); + const many = (n: number) => Array.from({ length: n }, (_, i) => para(i)) as never; + + test("above 200,000 nodes rendering stops with a notice", () => { + const el = R.renderMarkdown(many(250_000)) as unknown as FakeElement; + const notice = q(el, ".md-truncated"); + expect(notice.length).toBe(1); + expect(notice[0]!.textContent).toBe("Preview truncated, view source"); + const rendered = q(el, "p").length; + expect(rendered).toBeGreaterThan(0); + expect(rendered).toBeLessThanOrEqual(200_000); + }); + + test("the notice is the last child", () => { + const el = R.renderMarkdown(many(250_000)) as unknown as FakeElement; + const last = el.children[el.children.length - 1] as FakeElement; + expect(last.classList.contains("md-truncated")).toBe(true); + }); + + test("under the cap there is no notice and everything renders", () => { + const el = R.renderMarkdown(many(1000)) as unknown as FakeElement; + expect(q(el, ".md-truncated").length).toBe(0); + expect(q(el, "p").length).toBe(1000); + }); +}); diff --git a/test/fixtures/md-core.json b/test/fixtures/md-core.json new file mode 100644 index 0000000..ebc6975 --- /dev/null +++ b/test/fixtures/md-core.json @@ -0,0 +1,871 @@ +[ + { + "name": "setext h1", + "src": "Title\n=====", + "ast": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "text", + "value": "Title" + } + ] + } + ] + }, + { + "name": "setext h2", + "src": "Sub\n---", + "ast": [ + { + "type": "heading", + "level": 2, + "children": [ + { + "type": "text", + "value": "Sub" + } + ] + } + ] + }, + { + "name": "setext multi-line content", + "src": "a\nb\n===", + "ast": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + }, + { + "name": "thematic breaks", + "src": "***\n\n___\n\n- - -", + "ast": [ + { + "type": "hr" + }, + { + "type": "hr" + }, + { + "type": "hr" + } + ] + }, + { + "name": "hr interrupts a paragraph", + "src": "a\n***\nb", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "hr" + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "name": "multi-line paragraph keeps a soft break", + "src": "a\nb", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + }, + { + "name": "indented code", + "src": " code\n more", + "ast": [ + { + "type": "code", + "lang": null, + "value": "code\nmore" + } + ] + }, + { + "name": "indented code keeps inner blank lines", + "src": " a\n\n b", + "ast": [ + { + "type": "code", + "lang": null, + "value": "a\n\nb" + } + ] + }, + { + "name": "indented code cannot interrupt a paragraph", + "src": "a\n b", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + }, + { + "name": "fenced code with info", + "src": "```js\nlet x;\n```", + "ast": [ + { + "type": "code", + "lang": "js", + "value": "let x;" + } + ] + }, + { + "name": "tilde fence without info", + "src": "~~~\na\n~~~", + "ast": [ + { + "type": "code", + "lang": null, + "value": "a" + } + ] + }, + { + "name": "unclosed fence runs to the end", + "src": "```\na\n\nb", + "ast": [ + { + "type": "code", + "lang": null, + "value": "a\n\nb" + } + ] + }, + { + "name": "block quote", + "src": "> a\n> b", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + } + ] + }, + { + "name": "nested block quotes", + "src": "> a\n> > b", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "lazy continuation", + "src": "> a\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + } + ] + }, + { + "name": "lazy continuation in nested quotes", + "src": "> > a\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\nb" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "blank line ends a quote", + "src": "> a\n\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "name": "fence inside a quote", + "src": "> ```\n> x\n> ```", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "code", + "lang": null, + "value": "x" + } + ] + } + ] + }, + { + "name": "tight bullet list", + "src": "- a\n- b", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ordered list keeps its start", + "src": "3. a\n4. b", + "ast": [ + { + "type": "list", + "ordered": true, + "start": 3, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ordered list with paren delimiter", + "src": "1) a\n2) b", + "ast": [ + { + "type": "list", + "ordered": true, + "start": 1, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ordered list may start at zero", + "src": "0. a", + "ast": [ + { + "type": "list", + "ordered": true, + "start": 0, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "loose list (blank line between items)", + "src": "- a\n\n- b", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": false, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "loose list (blank line inside an item)", + "src": "- a\n\n b\n- c", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": false, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "c" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "nested bullet list", + "src": "- a\n - b\n- c", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "c" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "bullet nested in ordered", + "src": "1. a\n - b", + "ast": [ + { + "type": "list", + "ordered": true, + "start": 1, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a different bullet character starts a new list", + "src": "- a\n* b", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a bullet list interrupts a paragraph", + "src": "a\n- b", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "an ordered list not starting at 1 does not interrupt", + "src": "a\n2. b", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\n2. b" + } + ] + } + ] + }, + { + "name": "heading inside a list item", + "src": "- # h", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "text", + "value": "h" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "quote inside a list item", + "src": "- > q", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "q" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "heading with inline content", + "src": "# *a* b", + "ast": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "text", + "value": " b" + } + ] + } + ] + } +] diff --git a/test/fixtures/md-inline.json b/test/fixtures/md-inline.json new file mode 100644 index 0000000..739b09f --- /dev/null +++ b/test/fixtures/md-inline.json @@ -0,0 +1,675 @@ +[ + { + "name": "em with stars", + "src": "*a*", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "em with underscores", + "src": "_a_", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "strong with stars", + "src": "**a**", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "strong", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "strong with underscores", + "src": "__a__", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "strong", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "triple stars nest em around strong", + "src": "***a***", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "em", + "children": [ + { + "type": "strong", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "nested emphasis", + "src": "*a **b** c*", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a " + }, + { + "type": "strong", + "children": [ + { + "type": "text", + "value": "b" + } + ] + }, + { + "type": "text", + "value": " c" + } + ] + } + ] + } + ] + }, + { + "name": "stars work intraword", + "src": "a*b*c", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + }, + { + "type": "em", + "children": [ + { + "type": "text", + "value": "b" + } + ] + }, + { + "type": "text", + "value": "c" + } + ] + } + ] + }, + { + "name": "underscores do not work intraword", + "src": "snake_case_name", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "snake_case_name" + } + ] + } + ] + }, + { + "name": "spaced stars are literal", + "src": "2 * 3 * 4", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "2 * 3 * 4" + } + ] + } + ] + }, + { + "name": "unmatched opener is literal", + "src": "*a", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "*a" + } + ] + } + ] + }, + { + "name": "unbalanced stars", + "src": "**a*", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "*" + }, + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "code span", + "src": "`a`", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "a" + } + ] + } + ] + }, + { + "name": "multi-backtick code span", + "src": "``a`b``", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "a`b" + } + ] + } + ] + }, + { + "name": "code span strips one surrounding space", + "src": "` a `", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "a" + } + ] + } + ] + }, + { + "name": "code span turns a newline into a space", + "src": "`a\nb`", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "a b" + } + ] + } + ] + }, + { + "name": "code span shields emphasis", + "src": "`*a*`", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "*a*" + } + ] + } + ] + }, + { + "name": "unmatched backtick is literal", + "src": "`a", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "`a" + } + ] + } + ] + }, + { + "name": "backslash escapes", + "src": "\\*a\\*", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "*a*" + } + ] + } + ] + }, + { + "name": "escaped backslash", + "src": "\\\\", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "\\" + } + ] + } + ] + }, + { + "name": "backslash before a letter stays", + "src": "\\a", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "\\a" + } + ] + } + ] + }, + { + "name": "hard break with a backslash", + "src": "a\\\nb", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + }, + { + "type": "break" + }, + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "name": "hard break with two spaces", + "src": "a \nb", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + }, + { + "type": "break" + }, + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "name": "named and numeric entities become text", + "src": "& < > " '   A B", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "& < > \" '   A B" + } + ] + } + ] + }, + { + "name": "entities decode to text only", + "src": "<b>x</b>", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "x" + } + ] + } + ] + }, + { + "name": "unknown entity stays literal", + "src": "&foo;", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "&foo;" + } + ] + } + ] + }, + { + "name": "entities are decoded once", + "src": "&lt;", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "<" + } + ] + } + ] + }, + { + "name": "NUL and out-of-range references become U+FFFD", + "src": "� �", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "� �" + } + ] + } + ] + }, + { + "name": "inline link", + "src": "[a](/u \"t\")", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/u", + "title": "t", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "inline link without title", + "src": "[a](/u)", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/u", + "title": null, + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + }, + { + "name": "link text may hold emphasis", + "src": "[*a*](u)", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "u", + "title": null, + "children": [ + { + "type": "em", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "full reference link", + "src": "[foo][bar]\n\n[bar]: /url \"T\"", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": "T", + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "collapsed reference link", + "src": "[foo][]\n\n[foo]: /url", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "shortcut reference link, labels are case-insensitive", + "src": "[Foo]\n\n[FOO]: /url", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "Foo" + } + ] + } + ] + } + ] + }, + { + "name": "undefined reference stays literal", + "src": "[nope]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[nope]" + } + ] + } + ] + }, + { + "name": "definitions produce no block", + "src": "[a]: /x\n\ntext", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "text" + } + ] + } + ] + }, + { + "name": "image", + "src": "![a *b*](x.png \"t\")", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "image", + "src": "x.png", + "alt": "a b", + "title": "t" + } + ] + } + ] + } +] diff --git a/test/markdown.core.test.ts b/test/markdown.core.test.ts new file mode 100644 index 0000000..12e178f --- /dev/null +++ b/test/markdown.core.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import core from "./fixtures/md-core.json"; +import inline from "./fixtures/md-inline.json"; + +// Loaded dynamically so a missing module fails by assertion rather than by a load error. +let M: typeof import("../src/client/markdown.ts"); +try { + M = await import("../src/client/markdown.ts"); +} catch {} + +type Case = { name: string; src: string; ast: unknown }; + +describe("AC-6 block structure (fixtures/md-core.json)", () => { + for (const c of core as Case[]) { + test(c.name, () => { + expect(M.parseMarkdown(c.src)).toEqual(c.ast as never); + }); + } +}); + +describe("AC-7 inline syntax (fixtures/md-inline.json)", () => { + for (const c of inline as Case[]) { + test(c.name, () => { + expect(M.parseMarkdown(c.src)).toEqual(c.ast as never); + }); + } +}); + +describe("AC-7 invariants", () => { + test("adjacent text nodes are always merged", () => { + const walk = (n: any): void => { + for (const k of ["children", "items"] as const) { + const kids = n[k] as any[] | undefined; + if (!kids) continue; + for (let i = 1; i < kids.length; i++) expect(kids[i].type === "text" && kids[i - 1].type === "text").toBe(false); + kids.forEach(walk); + } + }; + for (const c of [...(core as Case[]), ...(inline as Case[])]) for (const b of M.parseMarkdown(c.src)) walk(b); + }); + + test("entities never produce markup nodes", () => { + const [p] = M.parseMarkdown("<script>alert(1)</script>") as any[]; + expect(p.children).toEqual([{ type: "text", value: "" }]); + }); +}); diff --git a/test/markdown.limits.test.ts b/test/markdown.limits.test.ts new file mode 100644 index 0000000..e4ae020 --- /dev/null +++ b/test/markdown.limits.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, uninstallDom } from "./helpers/fakedom.ts"; + +let M: typeof import("../src/client/markdown.ts"); +let R: typeof import("../src/client/mdrender.ts"); +beforeAll(async () => { + installDom(); + try { + M = await import("../src/client/markdown.ts"); + R = await import("../src/client/mdrender.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const run = (src: string) => R.renderMarkdown(M.parseMarkdown(src)); +/** Best of three, so one scheduler hiccup cannot fail a bound. */ +function time(fn: () => void): number { + let best = Infinity; + for (let i = 0; i < 3; i++) { + const t0 = performance.now(); + fn(); + best = Math.min(best, performance.now() - t0); + } + return best; +} + +// Never run two bun test suites at once: these bounds are wall-clock. +const corpus: Record string> = { + "stars": (n) => "*".repeat(n), + "open brackets": (n) => "[".repeat(n), + "quote markers on one line": (n) => "> ".repeat(n), + "list markers on one line": (n) => "- ".repeat(n), + "backticks": (n) => "`".repeat(n), + "image openers": (n) => "![](".repeat(n), + "one huge line": (n) => "a".repeat(n * 10), + "nested quote lines": (n) => "> ".repeat(n) + "x", + "many short star lines": (n) => "*a ".repeat(Math.floor(n / 10)).split(" ").join("\n"), + "many bracket lines": (n) => "[a](\n".repeat(Math.floor(n / 10)), + "many quote lines": (n) => "> x\n".repeat(n), + "many list lines": (n) => "- x\n".repeat(n), +}; + +describe("AC-9 adversarial inputs", () => { + for (const [name, gen] of Object.entries(corpus)) { + test(`${name}: no throw, under 1 s`, () => { + const src = gen(100_000); + let ms = 0; + expect(() => { + const t0 = performance.now(); + run(src); + ms = performance.now() - t0; + }).not.toThrow(); + expect(ms).toBeLessThan(1000); + }); + + test(`${name}: doubling the input costs less than 3x`, () => { + const a = time(() => run(gen(40_000))); + const b = time(() => run(gen(80_000))); + // The 25 ms floor keeps timer noise on tiny runs from deciding the outcome. + expect(b).toBeLessThan(3 * Math.max(a, 25)); + }); + } + + test("100k levels of nesting do not throw or overflow the stack", () => { + expect(() => run("> ".repeat(100_000) + "x")).not.toThrow(); + expect(() => run("- ".repeat(100_000) + "x")).not.toThrow(); + }); +}); + +function containerDepth(blocks: any[]): number { + let d = 0; + for (const b of blocks) { + if (b.type === "blockquote") d = Math.max(d, 1 + containerDepth(b.children)); + else if (b.type === "list") for (const it of b.items) d = Math.max(d, 1 + containerDepth(it.children)); + } + return d; +} +function collect(nodes: any[], out: string[] = []): string[] { + for (const n of nodes) { + if (typeof n.value === "string") out.push(n.value); + if (n.children) collect(n.children, out); + if (n.items) collect(n.items, out); + } + return out; +} +function inlineDepth(nodes: any[]): number { + let d = 0; + for (const n of nodes) if (n.children) d = Math.max(d, 1 + inlineDepth(n.children)); + return d; +} + +describe("AC-9 caps degrade to literal text", () => { + test("block quote nesting beyond 32 becomes literal text", () => { + const ast = M.parseMarkdown("> ".repeat(100) + "x"); + expect(containerDepth(ast)).toBeLessThanOrEqual(32); + expect(containerDepth(ast)).toBeGreaterThan(1); + const text = collect(ast).join(""); + expect(text).toContain("x"); + expect(text).toContain(">"); + }); + + test("list nesting beyond 32 becomes literal text", () => { + const ast = M.parseMarkdown("- ".repeat(100) + "x"); + expect(containerDepth(ast)).toBeLessThanOrEqual(32); + expect(containerDepth(ast)).toBeGreaterThan(1); + expect(collect(ast).join("")).toContain("x"); + }); + + test("exactly 32 levels still nest", () => { + expect(containerDepth(M.parseMarkdown("> ".repeat(32) + "x"))).toBe(32); + }); + + test("inline nesting beyond 32 is capped", () => { + const n = 100; + let s = ""; + for (let i = 0; i < n; i++) s += (i % 2 ? "_" : "*") + "a "; + s += "a"; + for (let i = n - 1; i >= 0; i--) s += " a" + (i % 2 ? "_" : "*"); + const ast = M.parseMarkdown(s) as any[]; + expect(ast.length).toBe(1); + expect(inlineDepth(ast[0].children)).toBeLessThanOrEqual(32); + expect(inlineDepth(ast[0].children)).toBeGreaterThan(1); + }); + + test("a line over 10,000 inline characters is literal text", () => { + const line = "*a* ".repeat(4000).trim(); // 15,999 characters + expect(line.length).toBeGreaterThan(10_000); + const [p] = M.parseMarkdown(line) as any[]; + expect(p.children).toEqual([{ type: "text", value: line }]); + }); + + test("a line under the cap is still parsed", () => { + const line = "*a* ".repeat(100).trim(); + const [p] = M.parseMarkdown(line) as any[]; + expect(p.children.some((c: any) => c.type === "em")).toBe(true); + }); +}); From 43b66098feec2358febe6f83b95420d1cfc00aef Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:36:21 +0100 Subject: [PATCH 4/9] feat(markdown): safe links, repo-relative navigation and heading fragments --- src/client/app.ts | 13 ++ src/client/editor.ts | 7 +- src/client/markdown.ts | 156 ++++++++++++++++++++++++ src/client/mdrender.ts | 47 +++++++- src/client/mdview.ts | 58 ++++++++- test/client.mdlinks.test.ts | 219 +++++++++++++++++++++++++++++++++ test/markdown.url.test.ts | 235 ++++++++++++++++++++++++++++++++++++ 7 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 test/client.mdlinks.test.ts create mode 100644 test/markdown.url.test.ts diff --git a/src/client/app.ts b/src/client/app.ts index 7253ed1..0e16d25 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -221,6 +221,19 @@ class App { if (t) this.trackRecent(t.path); }; this.editor.onStatus = (s) => this.renderStatus(s); + this.editor.onOpenLink = async (p) => { + const type = await api.stat(p); + if (type === "file") { + await this.openFile(p, { pin: true }); + return true; + } + if (type === "dir") { + if (!this.L.sidebar) this.toggleSidebar(true); + await this.tree.reveal(p + "/"); + return true; + } + return false; + }; this.editor.onReveal = (p) => { if (!L.sidebar) this.toggleSidebar(); void this.tree.reveal(p); diff --git a/src/client/editor.ts b/src/client/editor.ts index 571e179..02463d2 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -68,6 +68,11 @@ export class Editor { onActive: (tab: Tab | null) => void = () => {}; onStatus: (s: StatusInfo | null) => void = () => {}; onReveal: (path: string) => void = () => {}; + /** Opens a repo-relative link from a Markdown preview; false when the target does not exist. */ + onOpenLink: (path: string) => Promise = async (p) => { + await this.open(p, { pin: true }); + return true; + }; /** Called whenever the set of tabs or the active tab changes. */ onChange: () => void = () => {}; git: GitState | null = null; @@ -156,7 +161,7 @@ export class Editor { if (plan.view === "md") { if (plan.reuse) tab.md!.setText(f.text); else { - tab.md = new MarkdownView(f.text, this.fontSize); + tab.md = new MarkdownView(f.text, this.fontSize, { path: tab.path, onOpen: (p) => this.onOpenLink(p) }); tab.el.replaceChildren(tab.md.el); tab.el.prepend(this.viewToggle(tab, "View source", icons.code)); } diff --git a/src/client/markdown.ts b/src/client/markdown.ts index 9f08321..ace99fb 100644 --- a/src/client/markdown.ts +++ b/src/client/markdown.ts @@ -649,6 +649,44 @@ function labelEnd(s: string, p: number): number { return -1; } +/** `` or `` starting at s[i] === "<". */ +function angleAutolink(s: string, i: number): { href: string; label: string; end: number } | null { + const lim = Math.min(s.length, i + 2000); + let k = i + 1; + while (k < lim && s[k] !== ">" && s[k] !== "<" && s[k]! > " ") k++; + if (s[k] !== ">" || k === i + 1) return null; + const body = s.slice(i + 1, k); + if (/^[a-zA-Z][a-zA-Z0-9+.-]{1,31}:/.test(body)) return { href: body, label: body, end: k + 1 }; + if (/^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(body)) return { href: "mailto:" + body, label: body, end: k + 1 }; + return null; +} + +/** A bare http(s) URL at s[i], without trailing punctuation; null when there is none. */ +function bareUrl(s: string, i: number): string | null { + const m = /^https?:\/\//i.exec(s.slice(i, i + 8)); + if (!m) return null; + const lim = Math.min(s.length, i + 2000); + let k = i + m[0].length; + while (k < lim && s[k]! > " " && s[k] !== "<") k++; + let end = k; + for (;;) { + const ch = s[end - 1]!; + if (end - 1 < i + m[0].length) return null; + if ("?!.,:*_~'\";".includes(ch)) end--; + else if (ch === ")") { + let open = 0; + let close = 0; + for (let q = i; q < end; q++) { + if (s[q] === "(") open++; + else if (s[q] === ")") close++; + } + if (close > open) end--; + else break; + } else break; + } + return end > i + m[0].length ? s.slice(i, end) : null; +} + function parseInline(s: string, defs: Defs): Inline[] { let head: N | null = null; let tail: N | null = null; @@ -930,6 +968,29 @@ function parseInline(s: string, defs: Defs): Inline[] { i = run = i + 1; break; } + case 60: { + const a = brackets.length ? null : angleAutolink(s, i); + if (!a) { + i++; + break; + } + flush(i); + append(nodeOf({ type: "link", href: a.href, title: null, children: [{ type: "text", value: a.label }] }, 1)); + i = run = a.end; + break; + } + case 104: + case 72: { + const u = brackets.length || (i > 0 && /[\p{L}\p{N}_]/u.test(s[i - 1]!)) ? null : bareUrl(s, i); + if (!u) { + i++; + break; + } + flush(i); + append(nodeOf({ type: "link", href: u, title: null, children: [{ type: "text", value: u }] }, 1)); + i = run = i + u.length; + break; + } case 38: { const e = decodeEntity(s, i); if (e) { @@ -980,3 +1041,98 @@ export function parseMarkdown(src: string): Block[] { } return blocks; } + +/** A link target that passed the allowlist. `repo` is a relative path inside the viewed repository. */ +export interface SafeHref { + kind: "external" | "fragment" | "repo"; + href: string; +} + +/** Allowlist for link URLs (http, https, mailto, #fragment, relative); null for anything else. */ +export function safeHref(raw: string): SafeHref | null { + if (raw.length > 4000) return null; + let dec = ""; + for (let i = 0; i < raw.length; i++) { + if (raw[i] === "&") { + const e = decodeEntity(raw, i); + if (e) { + dec += e.str; + i += e.len - 1; + continue; + } + } + dec += raw[i]; + } + const out = dec.replace(INVISIBLE, "").trim(); + const probe = dec.replace(PROBE_STRIP, ""); + if (!probe || out.includes("\\") || probe.includes("\\")) return null; + if (probe.startsWith("//")) return null; + if (probe.startsWith("#")) return { kind: "fragment", href: out }; + const m = /^([a-z][a-z0-9+.-]*):/i.exec(probe); + if (m) { + const sch = m[1]!.toLowerCase(); + return sch === "http" || sch === "https" || sch === "mailto" ? { kind: "external", href: probe } : null; + } + return { kind: "repo", href: out }; +} + +const INVISIBLE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff]/g; +const PROBE_STRIP = /[\s\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff]/g; + +/** A repo-relative path with the query (no `?`) and fragment (no `#`) split off. */ +export interface RepoPath { + path: string; + query: string; + hash: string; +} + +/** Resolves `rel` against the folder `base`; null when it would escape the repository root. */ +export function resolveRepoPath(base: string, rel: string): RepoPath | null { + if (!rel || rel.length > 4000 || /[\u0000-\u001f\u007f\\]/.test(rel) || /^[a-z][a-z0-9+.-]*:/i.test(rel)) return null; + let rest = rel; + let hash = ""; + let query = ""; + const h = rest.indexOf("#"); + if (h >= 0) { + hash = rest.slice(h + 1); + rest = rest.slice(0, h); + } + const qi = rest.indexOf("?"); + if (qi >= 0) { + query = rest.slice(qi + 1); + rest = rest.slice(0, qi); + } + try { + rest = decodeURIComponent(rest); + } catch { + // keep the raw text when it is not valid percent-encoding + } + if (/[\u0000-\u001f\u007f\\]/.test(rest)) return null; + const segs: string[] = rest.startsWith("/") ? [] : base.split("/").filter(Boolean); + for (const seg of rest.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") { + if (!segs.length) return null; + segs.pop(); + } else segs.push(seg); + } + if (!segs.length) return null; + return { path: segs.join("/"), query, hash }; +} + +/** GitHub-style heading slug; `seen` de-duplicates repeats with `-1`, `-2`. */ +export function slugify(text: string, seen?: Map): string { + const slug = text + .toLowerCase() + .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "") + .trim() + .replace(/\s+/g, "-"); + if (!seen) return slug; + const n = seen.get(slug); + if (n === undefined) { + seen.set(slug, 0); + return slug; + } + seen.set(slug, n + 1); + return `${slug}-${n + 1}`; +} diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts index bc22a4c..3696d93 100644 --- a/src/client/mdrender.ts +++ b/src/client/mdrender.ts @@ -1,9 +1,11 @@ -import type { Block, Inline } from "./markdown.ts"; +import { resolveRepoPath, safeHref, slugify, type Block, type Inline } from "./markdown.ts"; /** Rendering stops once this many DOM nodes exist. */ const MAX_NODES = 200_000; class Budget { + readonly seen = new Map(); + constructor(readonly base = "") {} n = 0; over = false; /** Counts `k` nodes; false once the cap is exceeded. */ @@ -14,6 +16,41 @@ class Budget { } } +function plainText(nodes: Inline[]): string { + let out = ""; + for (const n of nodes) { + if (n.type === "text" || n.type === "codespan") out += n.value; + else if (n.type === "em" || n.type === "strong" || n.type === "link") out += plainText(n.children); + else if (n.type === "image") out += n.alt; + } + return out; +} + +function link(parent: HTMLElement, n: Extract, b: Budget) { + const safe = safeHref(n.href); + let a: HTMLElement | null = null; + if (safe) { + a = document.createElement("a"); + if (safe.kind === "external") { + a.setAttribute("href", safe.href); + a.setAttribute("rel", "noopener noreferrer nofollow"); + a.setAttribute("target", "_blank"); + } else if (safe.kind === "fragment") { + a.setAttribute("href", safe.href); + } else { + const r = resolveRepoPath(b.base, safe.href); + if (r) { + a.setAttribute("href", "#"); + a.setAttribute("data-md-path", r.path); + a.setAttribute("data-md-hash", r.hash); + } else a = null; + } + } + if (a && n.title) a.setAttribute("title", n.title); + if (a) parent.append(a); + inline(a ?? parent, n.children, b); +} + function inline(parent: HTMLElement, nodes: Inline[], b: Budget) { for (const n of nodes) { if (!b.take()) return; @@ -38,8 +75,7 @@ function inline(parent: HTMLElement, nodes: Inline[], b: Budget) { parent.append(document.createElement("br")); break; case "link": - // Links are made clickable (and URL-checked) in a later slice; the label shows as text. - inline(parent, n.children, b); + link(parent, n, b); break; case "image": parent.append(n.alt); @@ -54,6 +90,7 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) { switch (blk.type) { case "heading": { const el = document.createElement(`h${blk.level}`); + el.setAttribute("id", "md-" + slugify(plainText(blk.children), b.seen)); parent.append(el); inline(el, blk.children, b); break; @@ -102,10 +139,10 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) { } /** Turns the AST into DOM using createElement/textContent only; never innerHTML. */ -export function renderMarkdown(list: Block[]): HTMLElement { +export function renderMarkdown(list: Block[], opts: { base?: string } = {}): HTMLElement { const root = document.createElement("div"); root.className = "md-doc"; - const b = new Budget(); + const b = new Budget(opts.base); blocks(root, list, b); if (b.over) { const note = document.createElement("div"); diff --git a/src/client/mdview.ts b/src/client/mdview.ts index d328f1d..a982905 100644 --- a/src/client/mdview.ts +++ b/src/client/mdview.ts @@ -1,24 +1,77 @@ import { parseMarkdown } from "./markdown.ts"; import { renderMarkdown } from "./mdrender.ts"; +export interface MarkdownViewOptions { + /** Repo-relative path of the document; relative links resolve against its folder. */ + path?: string; + /** Opens a resolved repo path; resolve/return false when the target does not exist. */ + onOpen?: (path: string, hash: string) => boolean | void | Promise; +} + /** Rendered Markdown, mirroring CodeView.setText. `el` is the scroll container. */ export class MarkdownView { readonly el: HTMLElement; /** Number of times the tree was rebuilt. */ renders = 0; private text = ""; + private root: HTMLElement | null = null; - constructor(text: string, fontSize = 13) { + constructor(text: string, fontSize = 13, private opts: MarkdownViewOptions = {}) { this.el = document.createElement("div"); this.el.className = "md-view"; this.setFontSize(fontSize); + this.el.addEventListener("click", (e) => this.onClick(e)); this.build(text); } + private onClick(e: MouseEvent) { + const a = (e.target as Element | null)?.closest?.("a"); + if (!a || !this.el.contains(a)) return; + const path = a.getAttribute("data-md-path"); + const href = a.getAttribute("href") ?? ""; + if (path !== null) { + e.preventDefault(); + const done = (ok: boolean | void) => { + if (ok === false) this.notice("File not found"); + }; + try { + const r = this.opts.onOpen?.(path, a.getAttribute("data-md-hash") ?? ""); + if (r && typeof (r as Promise).then === "function") (r as Promise).then(done, () => done(false)); + else done(r as boolean | void); + } catch { + done(false); + } + } else if (href.startsWith("#")) { + e.preventDefault(); + let frag = href.slice(1); + try { + frag = decodeURIComponent(frag); + } catch { + // use as written + } + const id = "md-" + frag.toLowerCase(); + for (const hd of Array.from(this.el.querySelectorAll("h1,h2,h3,h4,h5,h6"))) { + if (hd.getAttribute("id") === id) { + hd.scrollIntoView({ block: "start" }); + break; + } + } + } + } + + private notice(msg: string) { + const n = document.createElement("div"); + n.className = "md-notice"; + n.textContent = msg; + if (this.root) this.el.replaceChildren(n, this.root); + } + private build(text: string) { this.text = text; this.renders++; - this.el.replaceChildren(renderMarkdown(parseMarkdown(text))); + const dir = this.opts.path ? this.opts.path.slice(0, Math.max(0, this.opts.path.lastIndexOf("/"))) : ""; + this.root = renderMarkdown(parseMarkdown(text), { base: dir }); + this.el.replaceChildren(this.root); } /** Re-renders unless the text is identical; keeps scrollTop. Returns true when the tree was rebuilt. */ @@ -36,6 +89,7 @@ export class MarkdownView { dispose() { this.text = ""; + this.root = null; this.el.replaceChildren(); } } diff --git a/test/client.mdlinks.test.ts b/test/client.mdlinks.test.ts new file mode 100644 index 0000000..1a052c4 --- /dev/null +++ b/test/client.mdlinks.test.ts @@ -0,0 +1,219 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; + +let M: typeof import("../src/client/markdown.ts"); +let R: typeof import("../src/client/mdrender.ts"); +let V: typeof import("../src/client/mdview.ts"); +let dom: Dom; +beforeAll(async () => { + dom = installDom({ hash: "#orig" }); + try { + M = await import("../src/client/markdown.ts"); + R = await import("../src/client/mdrender.ts"); + V = await import("../src/client/mdview.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const render = (src: string, base = "") => R.renderMarkdown(M.parseMarkdown(src), { base }) as unknown as FakeElement; +const q = (el: FakeElement, sel: string) => el.querySelectorAll(sel); +const one = (el: FakeElement, sel: string) => { + const r = q(el, sel); + expect(r.length).toBeGreaterThan(0); + return r[0]!; +}; +const text = (e: FakeElement) => e.textContent; +const click = (el: FakeElement) => { + const ev = new FakeEvent("click", { button: 0 }); + el.dispatchEvent(ev); + return ev; +}; +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("AC-13 external links", () => { + test("http, https and mailto links carry rel and target", () => { + const el = render("[a](http://x.y) [b](https://x.y/z) [c](mailto:a@b.co)"); + const as = q(el, "a"); + expect(as.length).toBe(3); + for (const a of as) { + expect(a.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + expect(a.getAttribute("target")).toBe("_blank"); + } + expect(as[0]!.getAttribute("href")).toBe("http://x.y"); + expect(text(as[1]!)).toBe("b"); + }); + + test("link title becomes the title attribute", () => { + const a = one(render('[a](https://x.y "Tip")'), "a"); + expect(a.getAttribute("title")).toBe("Tip"); + }); + + test("disallowed schemes render as plain label text with no anchor", () => { + for (const url of ["javascript:alert(1)", "JaVaScRiPt:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "blob:http://x/y", "//evil.example/x", "javascript:alert(1)"]) { + const el = render(`[label](${url})`); + expect(q(el, "a").length).toBe(0); + expect(text(el)).toContain("label"); + } + }); + + test("a disallowed autolink renders no anchor", () => { + expect(q(render(""), "a").length).toBe(0); + }); + + test("no href anywhere contains a raw disallowed scheme", () => { + const el = render("[a](java\tscript:x) [b](https://ok.example) [c](x-foo:bar)"); + for (const a of q(el, "a")) expect(a.getAttribute("href")).toMatch(/^(https?:|mailto:|#|[^:]*$)/); + }); +}); + +describe("AC-14 autolinks render", () => { + test("bare URL and angle autolink are safe external links", () => { + const el = render("see https://x.y/z and "); + const as = q(el, "a"); + expect(as.length).toBe(2); + for (const a of as) { + expect(a.getAttribute("target")).toBe("_blank"); + expect(a.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + } + expect(as[0]!.getAttribute("href")).toBe("https://x.y/z"); + expect(as[1]!.getAttribute("href")).toBe("https://q.r"); + }); +}); + +describe("AC-13 repo links", () => { + test("a relative link is resolved against the document folder", () => { + const a = one(render("[d](../other/b.md#sec)", "docs/sub"), "a"); + expect(a.getAttribute("data-md-path")).toBe("docs/other/b.md"); + expect(a.getAttribute("data-md-hash")).toBe("sec"); + expect(a.getAttribute("target")).toBeNull(); + }); + + test("a link that escapes the root renders as plain text", () => { + const el = render("[x](../../../etc/passwd)", "docs"); + expect(q(el, "a").length).toBe(0); + expect(text(el)).toContain("x"); + }); + + test("href never contains the raw path of an API endpoint", () => { + const el = render("[x](a.md) [y](/api/raw?path=.env)", ""); + for (const a of q(el, "a")) expect(a.getAttribute("href") ?? "").not.toContain("/api/"); + }); + + test("clicking calls onOpen with the resolved path and hash and prevents default", () => { + const calls: [string, string][] = []; + const v = new V.MarkdownView("[d](b.md#sec)", 13, { path: "docs/a.md", onOpen: (p, h) => void calls.push([p, h]) }); + const a = one(v.el as FakeElement, "a"); + const ev = click(a); + expect(ev.defaultPrevented).toBe(true); + expect(calls).toEqual([["docs/b.md", "sec"]]); + }); + + test("clicking a link label nested in emphasis still opens", () => { + const calls: string[] = []; + const v = new V.MarkdownView("[*em*](x.md)", 13, { path: "a.md", onOpen: (p) => void calls.push(p) }); + click(one(v.el as FakeElement, "em")); + expect(calls).toEqual(["x.md"]); + }); + + test("a missing target shows a File not found notice", async () => { + const v = new V.MarkdownView("[d](gone.md)", 13, { path: "a.md", onOpen: () => false }); + click(one(v.el as FakeElement, "a")); + await tick(); + expect((v.el as FakeElement).textContent).toContain("File not found"); + const v2 = new V.MarkdownView("[d](gone.md)", 13, { path: "a.md", onOpen: async () => false }); + click(one(v2.el as FakeElement, "a")); + await tick(); + expect((v2.el as FakeElement).textContent).toContain("File not found"); + }); + + test("a found target shows no notice", async () => { + const v = new V.MarkdownView("[d](here.md)", 13, { path: "a.md", onOpen: async () => true }); + click(one(v.el as FakeElement, "a")); + await tick(); + expect((v.el as FakeElement).textContent).not.toContain("File not found"); + }); + + test("external links are not intercepted", () => { + const calls: string[] = []; + const v = new V.MarkdownView("[x](https://x.y)", 13, { path: "a.md", onOpen: (p) => void calls.push(p) }); + const ev = click(one(v.el as FakeElement, "a")); + expect(calls).toEqual([]); + expect(ev.defaultPrevented).toBe(false); + }); + + test("clicking non-link content does nothing", () => { + const calls: string[] = []; + const v = new V.MarkdownView("# T\n\ntext", 13, { path: "a.md", onOpen: (p) => void calls.push(p) }); + const ev = click(one(v.el as FakeElement, "p")); + expect(calls).toEqual([]); + expect(ev.defaultPrevented).toBe(false); + }); + + test("a document without a path resolves against the root", () => { + const calls: string[] = []; + const v = new V.MarkdownView("[d](b.md)", 13, { onOpen: (p) => void calls.push(p) }); + click(one(v.el as FakeElement, "a")); + expect(calls).toEqual(["b.md"]); + }); +}); + +describe("AC-13 fragments", () => { + const SRC = "# Intro\n\n## Hello World\n\ntext\n\n## Hello World\n\n[go](#hello-world) [go2](#hello-world-1)\n"; + + test("headings get md- prefixed GitHub style ids, duplicates -1", () => { + const el = render(SRC); + const ids = q(el, "h1,h2").map((h) => h.getAttribute("id")); + expect(ids).toEqual(["md-intro", "md-hello-world", "md-hello-world-1"]); + }); + + test("every id in the tree is md- prefixed", () => { + const el = render(SRC + "\n- a\n- b\n\n> q\n"); + const walk = (e: FakeElement) => { + for (const c of e.children) { + if (!("tag" in c)) continue; + const id = c.getAttribute("id"); + if (id !== null) expect(id.startsWith("md-")).toBe(true); + walk(c); + } + }; + walk(el); + }); + + test("a fragment link keeps its #href and has no target or rel", () => { + const a = one(render(SRC), "a"); + expect(a.getAttribute("href")).toBe("#hello-world"); + expect(a.getAttribute("target")).toBeNull(); + }); + + test("clicking scrolls to the heading and never touches location.hash", () => { + const v = new V.MarkdownView(SRC, 13, { path: "a.md" }); + const heads = q(v.el as FakeElement, "h2"); + expect(heads.length).toBe(2); + const links = q(v.el as FakeElement, "a"); + expect(links.length).toBe(2); + const [link1, link2] = links; + const before = dom.location.hash; + const calls = dom.history.calls.length; + const ev = click(link1!); + expect(ev.defaultPrevented).toBe(true); + expect(heads[0]!.scrolled).toBe(1); + expect(heads[1]!.scrolled).toBe(0); + click(link2!); + expect(heads[1]!.scrolled).toBe(1); + expect(dom.location.hash).toBe(before); + expect(dom.history.calls.length).toBe(calls); + }); + + test("a fragment with no matching heading is prevented and harmless", () => { + const v = new V.MarkdownView("[x](#nope)\n\n# T", 13, { path: "a.md" }); + const ev = click(one(v.el as FakeElement, "a")); + expect(ev.defaultPrevented).toBe(true); + expect(dom.location.hash).toBe("#orig"); + }); + + test("fragment link matching is case-insensitive like GitHub slugs", () => { + const v = new V.MarkdownView("# Title\n\n[x](#Title)", 13, { path: "a.md" }); + click(one(v.el as FakeElement, "a")); + expect(one(v.el as FakeElement, "h1").scrolled).toBe(1); + }); +}); diff --git a/test/markdown.url.test.ts b/test/markdown.url.test.ts new file mode 100644 index 0000000..7346df7 --- /dev/null +++ b/test/markdown.url.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test"; +import { parseMarkdown, resolveRepoPath, safeHref, slugify, type Inline, type Block } from "../src/client/markdown.ts"; + +describe("AC-11 safeHref", () => { + const rejected: [string, string][] = [ + ["javascript plain", "javascript:alert(1)"], + ["mixed case", "JaVaScRiPt:alert(1)"], + ["tab inside", "java\tscript:alert(1)"], + ["newline inside", "java\nscript:alert(1)"], + ["hex entity", "javascript:alert(1)"], + ["decimal entity", "javascript:alert(1)"], + ["entity newline", "java script:alert(1)"], + ["leading control", "\u0001javascript:alert(1)"], + ["leading space", " javascript:alert(1)"], + ["zero width space", "jav\u200Bascript:alert(1)"], + ["zero width joiner", "java\u200Dscript:alert(1)"], + ["bom", "\uFEFFjavascript:alert(1)"], + ["data", "data:text/html,"], + ["data upper", "DATA:text/html;base64,AAAA"], + ["vbscript", "vbscript:msgbox(1)"], + ["file", "file:///etc/passwd"], + ["blob", "blob:http://127.0.0.1/abc"], + ["protocol relative", "//evil.example/x"], + ["backslash host", "\\\\evil.example\\x"], + ["slash backslash", "/\\evil.example"], + ["backslash path", "a\\b.md"], + ["ftp", "ftp://x.y/z"], + ["tel", "tel:123"], + ["about", "about:blank"], + ["unknown scheme", "x-custom:foo"], + ["empty", ""], + ["only spaces", " "], + ]; + for (const [name, url] of rejected) { + test("rejects " + name, () => { + expect(safeHref(url)).toBeNull(); + }); + } + + test("accepts http, https and mailto as external", () => { + expect(safeHref("http://a.b/c?d=1#e")).toEqual({ kind: "external", href: "http://a.b/c?d=1#e" }); + expect(safeHref("https://a.b")).toEqual({ kind: "external", href: "https://a.b" }); + expect(safeHref("HTTPS://A.B/x")?.kind).toBe("external"); + expect(safeHref("mailto:a@b.co")).toEqual({ kind: "external", href: "mailto:a@b.co" }); + }); + + test("accepts a fragment", () => { + expect(safeHref("#x")).toEqual({ kind: "fragment", href: "#x" }); + expect(safeHref("#heading-1")?.kind).toBe("fragment"); + }); + + test("accepts relative paths as repo", () => { + expect(safeHref("a/b.md")).toEqual({ kind: "repo", href: "a/b.md" }); + expect(safeHref("./a.md")?.kind).toBe("repo"); + expect(safeHref("../a.md#sec")?.kind).toBe("repo"); + expect(safeHref("/docs/a.md")?.kind).toBe("repo"); + expect(safeHref("a.md?x=1")?.kind).toBe("repo"); + }); + + test("a colon after the first slash is not a scheme", () => { + expect(safeHref("a/b:c.md")?.kind).toBe("repo"); + }); + + test("never throws on junk", () => { + for (const s of ["%", "%zz", "&#xZZ;", "�", "\0", "a".repeat(100000), ":", "::::"]) { + expect(() => safeHref(s)).not.toThrow(); + } + }); +}); + +describe("AC-12 resolveRepoPath", () => { + const r = (base: string, rel: string) => resolveRepoPath(base, rel); + + test("plain relative paths join the base folder", () => { + expect(r("docs", "a.md")).toEqual({ path: "docs/a.md", query: "", hash: "" }); + expect(r("", "a.md")).toEqual({ path: "a.md", query: "", hash: "" }); + expect(r("docs", "./a")?.path).toBe("docs/a"); + expect(r("a/b", "c/../d.md")?.path).toBe("a/b/d.md"); + expect(r("a", "b//c/./d")?.path).toBe("a/b/c/d"); + }); + + test("dot-dot climbs and may reach the root but not beyond", () => { + expect(r("docs", "../x")?.path).toBe("x"); + expect(r("a/b", "../../etc/passwd")?.path).toBe("etc/passwd"); + expect(r("docs", "../../etc/passwd")).toBeNull(); + expect(r("", "../x")).toBeNull(); + expect(r("a", "../../x")).toBeNull(); + }); + + test("a leading slash means the repo root", () => { + expect(r("docs", "/a")?.path).toBe("a"); + expect(r("a/b", "/c/d.md")?.path).toBe("c/d.md"); + expect(r("docs", "/../a")).toBeNull(); + }); + + test("percent encoding is decoded exactly once", () => { + expect(r("docs", "%2e%2e/x")?.path).toBe("x"); + expect(r("", "%2e%2e/x")).toBeNull(); + expect(r("docs", "a%20b.md")?.path).toBe("docs/a b.md"); + expect(r("docs", "%252e%252e/x")?.path).toBe("docs/%2e%2e/x"); + expect(r("docs", "%252e")?.path).toBe("docs/%2e"); + }); + + test("query and fragment are split off", () => { + expect(r("", "docs/b.md?x#y")).toEqual({ path: "docs/b.md", query: "x", hash: "y" }); + expect(r("", "b.md#sec")).toEqual({ path: "b.md", query: "", hash: "sec" }); + expect(r("", "b.md?q=1")).toEqual({ path: "b.md", query: "q=1", hash: "" }); + expect(r("docs", "../x?y")).toEqual({ path: "x", query: "y", hash: "" }); + }); + + test("a dot-dot hidden before the query does not escape", () => { + expect(r("", "..?x")).toBeNull(); + expect(r("", "../?x#y")).toBeNull(); + }); + + test("backslashes are rejected", () => { + expect(r("", "a/..\\..\\b")).toBeNull(); + expect(r("docs", "a\\b")).toBeNull(); + expect(r("docs", "%5Cb")).toBeNull(); + }); + + test("NUL and control characters are rejected", () => { + expect(r("", "a\0b")).toBeNull(); + expect(r("", "a%00b")).toBeNull(); + expect(r("", "a\nb")).toBeNull(); + }); + + test("drive letters and schemes are rejected", () => { + expect(r("", "C:\\x")).toBeNull(); + expect(r("", "C:/x")).toBeNull(); + expect(r("", "http://x.y/z")).toBeNull(); + }); + + test("empty and invalid input returns null or a path without throwing", () => { + expect(r("", "")).toBeNull(); + expect(() => r("", "%zz")).not.toThrow(); + expect(() => r("", "%E0%A4%A")).not.toThrow(); + expect(() => r("a", "x".repeat(100000))).not.toThrow(); + }); +}); + +describe("slugify", () => { + test("GitHub style", () => { + expect(slugify("Hello, World!")).toBe("hello-world"); + expect(slugify(" Some Title ")).toBe("some-title"); + expect(slugify("A_b-c d")).toBe("a_b-c-d"); + expect(slugify("Café au lait")).toBe("café-au-lait"); + expect(slugify("API (v2) `code`")).toBe("api-v2-code"); + }); + + test("duplicates get -1, -2", () => { + const seen = new Map(); + expect(slugify("Hello", seen)).toBe("hello"); + expect(slugify("Hello", seen)).toBe("hello-1"); + expect(slugify("hello", seen)).toBe("hello-2"); + expect(slugify("Other", seen)).toBe("other"); + }); + + test("an empty title yields a stable non-empty-or-empty string without throwing", () => { + expect(() => slugify("!!!")).not.toThrow(); + expect(typeof slugify("")).toBe("string"); + }); +}); + +describe("AC-14 autolinks in the AST", () => { + const links = (src: string): Extract[] => { + const out: Extract[] = []; + const walkI = (list: Inline[]) => { + for (const n of list) { + if (n.type === "link") out.push(n); + if (n.type === "link" || n.type === "em" || n.type === "strong") walkI(n.children); + } + }; + const walkB = (list: Block[]) => { + for (const b of list) { + if (b.type === "paragraph" || b.type === "heading") walkI(b.children); + else if (b.type === "blockquote") walkB(b.children); + else if (b.type === "list") for (const it of b.items) walkB(it.children); + } + }; + walkB(parseMarkdown(src)); + return out; + }; + const label = (l: { children: Inline[] }) => l.children.map((c) => (c.type === "text" ? c.value : "")).join(""); + + test("angle-bracket URL autolink", () => { + const l = links("see now"); + expect(l.length).toBe(1); + expect(l[0]!.href).toBe("https://x.y/z"); + expect(label(l[0]!)).toBe("https://x.y/z"); + }); + + test("angle-bracket email autolink becomes mailto", () => { + const l = links(""); + expect(l.length).toBe(1); + expect(l[0]!.href).toBe("mailto:a@b.co"); + expect(label(l[0]!)).toBe("a@b.co"); + }); + + test("bare URL autolink", () => { + const l = links("go to https://x.y/z today"); + expect(l.length).toBe(1); + expect(l[0]!.href).toBe("https://x.y/z"); + expect(label(l[0]!)).toBe("https://x.y/z"); + }); + + test("bare http URL excludes trailing punctuation and unbalanced paren", () => { + expect(links("Visit http://x.y/a.")[0]?.href).toBe("http://x.y/a"); + expect(links("(https://x.y/a)")[0]?.href).toBe("https://x.y/a"); + expect(links("https://x.y/a, and")[0]?.href).toBe("https://x.y/a"); + expect(links("https://x.y/a_(b)")[0]?.href).toBe("https://x.y/a_(b)"); + }); + + test("a bare URL is linked inside emphasis and list items", () => { + expect(links("*https://x.y*").length).toBe(1); + expect(links("- https://x.y").length).toBe(1); + }); + + test("not linked inside code spans or an explicit link label", () => { + expect(links("`https://x.y`").length).toBe(0); + const l = links("[https://x.y](https://q.r)"); + expect(l.length).toBe(1); + expect(l[0]!.href).toBe("https://q.r"); + }); + + test("a scheme-only or non-http bare string is not linked", () => { + expect(links("https:// nothing").length).toBe(0); + expect(links("javascript:alert(1) and ftp://x.y").length).toBe(0); + }); + + test("explicit and reference links keep the raw href in the AST", () => { + expect(links("[a](b/c.md)")[0]?.href).toBe("b/c.md"); + expect(links("[a][r]\n\n[r]: https://x.y")[0]?.href).toBe("https://x.y"); + }); +}); From 7b121270635034f64c12d7765ca09f77e8a7f6a4 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:43:22 +0100 Subject: [PATCH 5/9] feat(markdown): GFM tables, task lists and strikethrough --- src/client/markdown.ts | 110 ++++- src/client/mdrender.ts | 39 +- src/client/styles.css | 41 ++ test/client.mdgfm.test.ts | 171 ++++++++ test/client.mdstyles.test.ts | 58 +++ test/fixtures/md-gfm.json | 807 +++++++++++++++++++++++++++++++++++ test/markdown.gfm.test.ts | 49 +++ 7 files changed, 1260 insertions(+), 15 deletions(-) create mode 100644 test/client.mdgfm.test.ts create mode 100644 test/client.mdstyles.test.ts create mode 100644 test/fixtures/md-gfm.json create mode 100644 test/markdown.gfm.test.ts diff --git a/src/client/markdown.ts b/src/client/markdown.ts index ace99fb..ee9f5f7 100644 --- a/src/client/markdown.ts +++ b/src/client/markdown.ts @@ -7,7 +7,7 @@ export interface Text { value: string; } export interface Emphasis { - type: "em" | "strong"; + type: "em" | "strong" | "del"; children: Inline[]; } export interface CodeSpan { @@ -54,6 +54,8 @@ export interface BlockQuote { } export interface ListItem { type: "item"; + /** GFM task list state: true for `[x]`, false for `[ ]`, absent for a plain item. */ + checked?: boolean; children: Block[]; } export interface List { @@ -64,7 +66,14 @@ export interface List { tight: boolean; items: ListItem[]; } -export type Block = Heading | Paragraph | ThematicBreak | CodeBlock | BlockQuote | List; +/** GFM table: `head` and every row have exactly `align.length` cells. */ +export interface Table { + type: "table"; + align: ("left" | "center" | "right" | null)[]; + head: Inline[][]; + rows: Inline[][][]; +} +export type Block = Heading | Paragraph | ThematicBreak | CodeBlock | BlockQuote | List | Table; /** True for `.md` / `.markdown` paths, case-insensitive. */ export function isMarkdown(path: string): boolean { @@ -78,6 +87,7 @@ const MAX_LINE = 10_000; // inline characters per line before the text is left l const MAX_DEFS = 10_000; // link reference definitions const MAX_SCAN = 1500; // characters scanned for a link destination/title const MAX_LABEL = 999; +const MAX_COLS = 200; // table columns; wider tables stay a paragraph const isWs = (c: string | undefined) => c === " " || c === "\t"; @@ -333,7 +343,7 @@ function parseDef(line: string): { label: string; href: string; title: string | // Block pass interface Pending { - node: Heading | Paragraph; + set: (children: Inline[]) => void; text: string; } interface Ctx { @@ -341,6 +351,46 @@ interface Ctx { pending: Pending[]; } +/** Splits a table row on unescaped `|`; `\|` becomes `|`. Stops after MAX_COLS + 1 cells. */ +function splitRow(line: string): string[] { + let s = line.trim(); + if (s[0] === "|") s = s.slice(1); + const cells: string[] = []; + let cur = ""; + let i = 0; + while (i < s.length) { + const c = s[i]!; + if (c === "\\" && i + 1 < s.length) { + cur += s[i + 1] === "|" ? "|" : c + s[i + 1]; + i += 2; + continue; + } + if (c === "|") { + cells.push(cur.trim()); + cur = ""; + if (cells.length > MAX_COLS) return cells; + } else cur += c; + i++; + } + if (cur.trim() || !s.endsWith("|") || s.endsWith("\\|")) cells.push(cur.trim()); + return cells; +} + +/** Column alignments of a GFM delimiter row, or null if the line is not one. */ +function delimRow(line: string): Table["align"] | null { + if (indent(line) > 3 || !/^[-:| \t]+$/.test(line) || !line.includes("-")) return null; + const cells = splitRow(line); + if (!cells.length || cells.length > MAX_COLS) return null; + const align: Table["align"] = []; + for (const c of cells) { + if (!/^:?-+:?$/.test(c)) return null; + const l = c[0] === ":"; + const r = c[c.length - 1] === ":"; + align.push(l && r ? "center" : l ? "left" : r ? "right" : null); + } + return align; +} + interface FenceTrack { f: Fence | null; } @@ -365,7 +415,7 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ blocks.push(b); }; const addText = (b: Heading | Paragraph, text: string) => { - ctx.pending.push({ node: b, text }); + ctx.pending.push({ set: (c) => (b.children = c), text }); blocks.push(b); }; const flush = () => { @@ -406,6 +456,32 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ i++; continue; } + const al = delimRow(line); + if (al) { + const head = splitRow(para[para.length - 1]!); + if (head.length === al.length) { + para.pop(); + if (para.length) flush(); + else if (paraBlank && blocks.length) gap = true; + const tbl: Table = { type: "table", align: al, head: [], rows: [] }; + const cell = (txt: string, into: Inline[][]) => { + const k = into.length; + into.push([]); + ctx.pending.push({ set: (c) => (into[k] = c), text: txt }); + }; + for (const h of head) cell(h, tbl.head); + i++; + while (i < n && !isBlank(lines[i]!) && !startsBlock(lines[i]!, canNest)) { + const cells = splitRow(lines[i]!); + const row: Inline[][] = []; + for (let k = 0; k < al.length; k++) cell(cells[k] ?? "", row); + tbl.rows.push(row); + i++; + } + blocks.push(tbl); + continue; + } + } if (!startsBlock(line, canNest)) { para.push(line.trimStart()); i++; @@ -481,6 +557,8 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ const cur = listMarker(lines[i]!); if (!cur || cur.key !== m.key || isHr(lines[i]!)) break; const itemLines = [lines[i]!.slice(cur.offset)]; + const task = /^\[([ xX])\][ \t]/.exec(itemLines[0]!); + if (task) itemLines[0] = itemLines[0]!.slice(4); const t: FenceTrack = { f: null }; track(t, itemLines[0]!); i++; @@ -505,7 +583,7 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ } const r = parseBlocks(itemLines, depth + 1, ctx); if (r.gap) loose = true; - items.push({ type: "item", children: r.blocks }); + items.push(task ? { type: "item", checked: task[1] !== " ", children: r.blocks } : { type: "item", children: r.blocks }); const nxt = i < n ? listMarker(lines[i]!) : null; if (nxt && nxt.key === m.key && !isHr(lines[i]!)) { if (blanks > 0) loose = true; @@ -766,8 +844,8 @@ function parseInline(s: string, defs: Defs): Inline[] { const d = depthOf(kids); if (d + 1 > MAX_DEPTH) found = null; else { - const use = closer.n >= 2 && found.n >= 2 ? 2 : 1; - const en = nodeOf({ type: use === 2 ? "strong" : "em", children: toInline(kids) }, d + 1); + const use = closer.ch === "~" || (closer.n >= 2 && found.n >= 2) ? 2 : 1; + const en = nodeOf({ type: closer.ch === "~" ? "del" : use === 2 ? "strong" : "em", children: toInline(kids) }, d + 1); found.next = en; en.prev = found; en.next = closer; @@ -871,9 +949,15 @@ function parseInline(s: string, defs: Defs): Inline[] { break; } case 42: - case 95: { + case 95: + case 126: { + // `~~` (exactly two) is a strikethrough delimiter with the same flanking rules as `*`. let e = i; while (s.charCodeAt(e) === c) e++; + if (c === 126 && e - i !== 2) { + i = e; + break; + } flush(i); const prev = i > 0 ? s[i - 1]! : "\n"; const next = e < len ? s[e]! : "\n"; @@ -884,10 +968,10 @@ function parseInline(s: string, defs: Defs): Inline[] { const left = !wsN && (!puN || wsP || puP); const right = !wsP && (!puP || wsN || puN); const x = mk(2); - x.ch = c === 42 ? "*" : "_"; + x.ch = c === 42 ? "*" : c === 95 ? "_" : "~"; x.n = x.orig = e - i; - x.open = c === 42 ? left : left && (!right || puP); - x.close = c === 42 ? right : right && (!left || puN); + x.open = c !== 95 ? left : left && (!right || puP); + x.close = c !== 95 ? right : right && (!left || puN); append(x); x.dprev = dtail; if (dtail) dtail.dnext = x; @@ -1036,8 +1120,8 @@ export function parseMarkdown(src: string): Block[] { const lines = src.split(/\r\n|\r|\n/).map(expandTabs); const ctx: Ctx = { defs: new Map(), pending: [] }; const { blocks } = parseBlocks(lines, 0, ctx); - for (const { node, text } of ctx.pending) { - node.children = !text ? [] : tooLong(text) ? [{ type: "text", value: text }] : parseInline(text, ctx.defs); + for (const { set, text } of ctx.pending) { + set(!text ? [] : tooLong(text) ? [{ type: "text", value: text }] : parseInline(text, ctx.defs)); } return blocks; } diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts index 3696d93..0734968 100644 --- a/src/client/mdrender.ts +++ b/src/client/mdrender.ts @@ -20,7 +20,7 @@ function plainText(nodes: Inline[]): string { let out = ""; for (const n of nodes) { if (n.type === "text" || n.type === "codespan") out += n.value; - else if (n.type === "em" || n.type === "strong" || n.type === "link") out += plainText(n.children); + else if (n.type === "em" || n.type === "strong" || n.type === "del" || n.type === "link") out += plainText(n.children); else if (n.type === "image") out += n.alt; } return out; @@ -59,7 +59,8 @@ function inline(parent: HTMLElement, nodes: Inline[], b: Budget) { parent.append(n.value); break; case "em": - case "strong": { + case "strong": + case "del": { const el = document.createElement(n.type); parent.append(el); inline(el, n.children, b); @@ -122,6 +123,31 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) { blocks(el, blk.children, b); break; } + case "table": { + const table = document.createElement("table"); + parent.append(table); + const thead = document.createElement("thead"); + const tbody = document.createElement("tbody"); + table.append(thead, tbody); + const row = (host: HTMLElement, cells: Inline[][], tag: "th" | "td") => { + const tr = document.createElement("tr"); + host.append(tr); + for (let k = 0; k < cells.length; k++) { + if (!b.take(2)) return; + const c = document.createElement(tag); + const a = blk.align[k]; + if (a) c.className = "md-align-" + a; + tr.append(c); + inline(c, cells[k]!, b); + } + }; + row(thead, blk.head, "th"); + for (const r of blk.rows) { + if (b.over || !b.take()) break; + row(tbody, r, "td"); + } + break; + } case "list": { const el = document.createElement(blk.ordered ? "ol" : "ul"); if (blk.ordered && blk.start !== 1 && blk.start !== null) el.setAttribute("start", String(blk.start)); @@ -130,6 +156,15 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) { if (!b.take()) return; const li = document.createElement("li"); el.append(li); + if (item.checked !== undefined) { + li.className = "md-task"; + const box = document.createElement("input"); + box.setAttribute("type", "checkbox"); + box.setAttribute("disabled", ""); + if (item.checked) box.setAttribute("checked", ""); + li.append(box); + b.take(); + } blocks(li, item.children, b, blk.tight); } break; diff --git a/src/client/styles.css b/src/client/styles.css index b44bb8d..3a911ce 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -921,6 +921,47 @@ svg { background: none; padding: 0; } +.md-doc table { + border-collapse: collapse; + margin: 0 0 1em; + display: block; + max-width: 100%; + overflow-x: auto; +} +.md-doc th, +.md-doc td { + padding: 0.35em 0.8em; + border: 1px solid var(--border-strong); +} +.md-doc th { + color: var(--fg-strong); + background: var(--bg-elev); + font-weight: 600; +} +.md-doc tbody tr:nth-child(even) { + background: var(--bg-hover); +} +.md-align-left { + text-align: left; +} +.md-align-center { + text-align: center; +} +.md-align-right { + text-align: right; +} +.md-doc del { + color: var(--fg-muted); + text-decoration: line-through; +} +.md-doc li.md-task { + list-style: none; + margin-left: -1.4em; +} +.md-doc li.md-task > input { + margin: 0 0.5em 0 0; + vertical-align: middle; +} .md-truncated { margin: 1em 0; padding: 0.5em 0.8em; diff --git a/test/client.mdgfm.test.ts b/test/client.mdgfm.test.ts new file mode 100644 index 0000000..3804201 --- /dev/null +++ b/test/client.mdgfm.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; + +let M: typeof import("../src/client/markdown.ts"); +let R: typeof import("../src/client/mdrender.ts"); +beforeAll(async () => { + installDom(); + try { + M = await import("../src/client/markdown.ts"); + R = await import("../src/client/mdrender.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const render = (src: string) => R.renderMarkdown(M.parseMarkdown(src)) as unknown as FakeElement; +const q = (el: FakeElement, sel: string) => el.querySelectorAll(sel); + +const TABLE = "| Name | Qty | Note |\n|:-----|:---:|-----:|\n| a | 1 | x \\| y |\n| b | 2 | z |"; + +describe("AC-15 table rendering", () => { + test("produces a real table with thead and tbody", () => { + const el = render(TABLE); + expect(q(el, "table").length).toBe(1); + expect(q(el, "thead").length).toBe(1); + expect(q(el, "tbody").length).toBe(1); + expect(q(q(el, "thead")[0]!, "th").length).toBe(3); + expect(q(q(el, "tbody")[0]!, "tr").length).toBe(2); + expect(q(q(el, "tbody")[0]!, "td").length).toBe(6); + expect(q(q(el, "tbody")[0]!, "th").length).toBe(0); + }); + + test("cell text is rendered as text with the escaped pipe unescaped", () => { + const tds = q(render(TABLE), "td"); + expect(tds[0]!.textContent).toBe("a"); + expect(tds[2]!.textContent).toBe("x | y"); + }); + + test("alignment is carried by md-align-* classes on th and td", () => { + const el = render(TABLE); + const ths = q(el, "th"); + expect(ths[0]!.classList.contains("md-align-left")).toBe(true); + expect(ths[1]!.classList.contains("md-align-center")).toBe(true); + expect(ths[2]!.classList.contains("md-align-right")).toBe(true); + const tds = q(el, "td"); + expect(tds[1]!.classList.contains("md-align-center")).toBe(true); + expect(tds[5]!.classList.contains("md-align-right")).toBe(true); + }); + + test("an unaligned column has no md-align class", () => { + const th = q(render("| a |\n| - |\n| 1 |"), "th")[0]!; + expect(th.className).not.toContain("md-align"); + }); + + test("no element in the table carries a style or align attribute", () => { + const el = render(TABLE); + for (const e of [...q(el, "table"), ...q(el, "thead"), ...q(el, "tbody"), ...q(el, "tr"), ...q(el, "th"), ...q(el, "td")]) { + expect(e.attrs.has("style")).toBe(false); + expect(e.attrs.has("align")).toBe(false); + expect(Object.keys(e.style).length).toBe(0); + } + }); + + test("inline syntax inside cells is rendered", () => { + const el = render("| h |\n| - |\n| **b** `c` [l](https://x.y) |"); + const td = q(el, "td")[0]!; + expect(q(td, "strong").length).toBe(1); + expect(q(td, "code").length).toBe(1); + expect(q(td, "a").length).toBe(1); + }); + + test("HTML in a cell stays text", () => { + const el = render("| h |\n| - |\n| |"); + expect(q(el, "script").length).toBe(0); + expect(q(el, "td")[0]!.textContent).toBe(""); + }); + + test("a table inside a block quote still renders", () => { + const bq = q(render("> | a |\n> | - |\n> | 1 |"), "blockquote")[0]!; + expect(q(bq, "table").length).toBe(1); + }); + + test("more than 200 columns degrade to a paragraph, not a table", () => { + const row = (n: number, c: string) => "|" + Array.from({ length: n }, () => c).join("|") + "|"; + const el = render(`${row(201, " a ")}\n${row(201, "---")}\n${row(201, " b ")}`); + expect(q(el, "table").length).toBe(0); + expect(q(el, "p").length).toBe(1); + }); + + test("a table counts against the node budget", () => { + const cells = 300; + const head = "|" + Array.from({ length: 10 }, () => " h ").join("|") + "|"; + const sep = "|" + Array.from({ length: 10 }, () => "-").join("|") + "|"; + const body = Array.from({ length: cells }, () => "|" + Array.from({ length: 10 }, () => " c ").join("|") + "|").join("\n"); + const el = R.renderMarkdown(M.parseMarkdown(`${head}\n${sep}\n${body}`)) as unknown as FakeElement; + expect(q(el, "td").length).toBe(cells * 10); + }); +}); + +describe("AC-16 task lists", () => { + test("renders disabled checkboxes, checked state matching the source", () => { + const el = render("- [x] a\n- [ ] b"); + const boxes = q(el, "input"); + expect(boxes.length).toBe(2); + for (const b of boxes) { + expect(b.getAttribute("type")).toBe("checkbox"); + expect(b.attrs.has("disabled")).toBe(true); + } + expect(boxes[0]!.attrs.has("checked")).toBe(true); + expect(boxes[1]!.attrs.has("checked")).toBe(false); + }); + + test("checkboxes have no handlers and no on* attributes", () => { + for (const b of q(render("- [x] a\n- [ ] b"), "input")) { + expect(b.listenerCount("click")).toBe(0); + expect(b.listenerCount("change")).toBe(0); + expect(b.listenerCount("input")).toBe(0); + for (const k of b.attrs.keys()) expect(k.startsWith("on")).toBe(false); + } + }); + + test("the marker text is not rendered next to the checkbox", () => { + const li = q(render("- [x] done"), "li")[0]!; + expect(li.textContent).toBe("done"); + }); + + test("plain items in the same list get no checkbox", () => { + const el = render("- [x] a\n- plain"); + expect(q(el, "input").length).toBe(1); + expect(q(el, "li")[1]!.textContent).toBe("plain"); + }); + + test("a non-task item that merely looks like one has no checkbox", () => { + expect(q(render("- [x]a\n- [] b"), "input").length).toBe(0); + }); + + test("a loose task item keeps the checkbox inside its li", () => { + const el = render("- [ ] a\n\n- [x] b"); + expect(q(el, "li").length).toBe(2); + expect(q(q(el, "li")[0]!, "input").length).toBe(1); + expect(q(q(el, "li")[1]!, "input").length).toBe(1); + }); + + test("ordered task lists render checkboxes too", () => { + expect(q(render("1. [x] a\n2. [ ] b"), "input").length).toBe(2); + }); +}); + +describe("AC-17 strikethrough", () => { + test("renders a del element", () => { + const el = render("a ~~x~~ b"); + expect(q(el, "del").length).toBe(1); + expect(q(el, "del")[0]!.textContent).toBe("x"); + }); + + test("nests inside emphasis and holds emphasis", () => { + const el = render("**~~a~~** ~~*b*~~"); + expect(q(q(el, "strong")[0]!, "del").length).toBe(1); + expect(q(q(el, "del")[1]!, "em").length).toBe(1); + }); + + test("single tilde or unclosed markers render as literal text", () => { + const el = render("~x~ and ~~y"); + expect(q(el, "del").length).toBe(0); + expect(el.textContent).toBe("~x~ and ~~y"); + }); + + test("del works inside a table cell and a heading", () => { + expect(q(render("| h |\n| - |\n| ~~x~~ |"), "del").length).toBe(1); + expect(q(render("# ~~x~~"), "del").length).toBe(1); + }); +}); diff --git a/test/client.mdstyles.test.ts b/test/client.mdstyles.test.ts new file mode 100644 index 0000000..5f5db06 --- /dev/null +++ b/test/client.mdstyles.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +// AC-17: every colour in the new GFM rules must come from an existing theme token. +const css = readFileSync(new URL("../src/client/styles.css", import.meta.url), "utf8"); + +/** Returns [selector, body] for every rule whose selector list mentions `needle`. */ +function rulesFor(needle: RegExp): [string, string][] { + const out: [string, string][] = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(css))) { + const sel = m[1]!.replace(/\/\*[\s\S]*?\*\//g, "").trim(); + if (needle.test(sel)) out.push([sel, m[2]!]); + } + return out; +} + +const tokens = new Set([...css.matchAll(/^\s*(--[\w-]+)\s*:/gm)].map((m) => m[1]!)); +const GFM = /\.md-doc\s+(table|thead|tbody|tr|th|td|del|input)\b|\.md-align-|\.md-task/; + +describe("AC-15/16/17 theme-token styles", () => { + test("styles exist for table, th, td and del under .md-doc", () => { + for (const el of ["table", "th", "td", "del"]) { + expect(rulesFor(new RegExp(`\\.md-doc\\s+${el}\\b`)).length).toBeGreaterThan(0); + } + }); + + test("alignment classes are styled", () => { + for (const a of ["center", "right"]) expect(rulesFor(new RegExp(`\\.md-align-${a}\\b`)).length).toBeGreaterThan(0); + const body = rulesFor(/\.md-align-center\b/).map((r) => r[1]).join(""); + expect(body).toContain("text-align"); + }); + + test("tables have hairline borders from the border tokens", () => { + const body = rulesFor(/\.md-doc\s+(table|th|td)\b/).map((r) => r[1]).join("\n"); + expect(body).toMatch(/border[^;]*var\(--border(-strong)?\)/); + }); + + test("the added rules contain no hex, rgb or hsl colour literals", () => { + const rules = rulesFor(GFM); + expect(rules.length).toBeGreaterThan(0); + for (const [sel, body] of rules) { + expect(body, sel).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); + expect(body, sel).not.toMatch(/\b(rgba?|hsla?)\(/); + } + }); + + test("every var() used by the added rules is a defined theme token", () => { + for (const [sel, body] of rulesFor(GFM)) { + for (const m of body.matchAll(/var\((--[\w-]+)/g)) expect(tokens.has(m[1]!), `${sel} uses ${m[1]}`).toBe(true); + } + }); + + test("no inline style is needed: rules never rely on the style attribute", () => { + expect(css).not.toMatch(/\.md-doc[^{]*\[style/); + }); +}); diff --git a/test/fixtures/md-gfm.json b/test/fixtures/md-gfm.json new file mode 100644 index 0000000..4c1211e --- /dev/null +++ b/test/fixtures/md-gfm.json @@ -0,0 +1,807 @@ +[ + { + "name": "basic table", + "src": "| a | b |\n| --- | --- |\n| 1 | 2 |", + "ast": [ + { + "type": "table", + "align": [ + null, + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ], + [ + { + "type": "text", + "value": "b" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "2" + } + ] + ] + ] + } + ] + }, + { + "name": "table without outer pipes", + "src": "a|b\n-|-\n1|2", + "ast": [ + { + "type": "table", + "align": [ + null, + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ], + [ + { + "type": "text", + "value": "b" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "2" + } + ] + ] + ] + } + ] + }, + { + "name": "alignment row sets left, center, right and none", + "src": "| a | b | c | d |\n|:--|:-:|--:|---|\n| 1 | 2 | 3 | 4 |", + "ast": [ + { + "type": "table", + "align": [ + "left", + "center", + "right", + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ], + [ + { + "type": "text", + "value": "b" + } + ], + [ + { + "type": "text", + "value": "c" + } + ], + [ + { + "type": "text", + "value": "d" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "2" + } + ], + [ + { + "type": "text", + "value": "3" + } + ], + [ + { + "type": "text", + "value": "4" + } + ] + ] + ] + } + ] + }, + { + "name": "escaped pipe stays in the cell", + "src": "| a |\n| - |\n| x \\| y |", + "ast": [ + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "x | y" + } + ] + ] + ] + } + ] + }, + { + "name": "short rows are padded and long rows truncated to the header width", + "src": "| a | b |\n| - | - |\n| 1 |\n| 1 | 2 | 3 |", + "ast": [ + { + "type": "table", + "align": [ + null, + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ], + [ + { + "type": "text", + "value": "b" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ], + [] + ], + [ + [ + { + "type": "text", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "2" + } + ] + ] + ] + } + ] + }, + { + "name": "inline syntax inside cells", + "src": "| h |\n| - |\n| **b** `c` |", + "ast": [ + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "h" + } + ] + ], + "rows": [ + [ + [ + { + "type": "strong", + "children": [ + { + "type": "text", + "value": "b" + } + ] + }, + { + "type": "text", + "value": " " + }, + { + "type": "codespan", + "value": "c" + } + ] + ] + ] + } + ] + }, + { + "name": "header only table has no body rows", + "src": "| a |\n| - |", + "ast": [ + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ] + ], + "rows": [] + } + ] + }, + { + "name": "table ends at a blank line", + "src": "| a |\n| - |\n| 1 |\n\ntext", + "ast": [ + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ] + ] + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "text" + } + ] + } + ] + }, + { + "name": "table interrupts a paragraph", + "src": "intro\n| a |\n| - |\n| 1 |", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "intro" + } + ] + }, + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ] + ] + ] + } + ] + }, + { + "name": "header and delimiter column counts must match", + "src": "| a | b |\n| - |\n| 1 | 2 |", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "| a | b |\n| - |\n| 1 | 2 |" + } + ] + } + ] + }, + { + "name": "a delimiter row with no pipe and a dash line is a setext heading, not a table", + "src": "a\n---", + "ast": [ + { + "type": "heading", + "level": 2, + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "name": "table inside a block quote", + "src": "> | a |\n> | - |\n> | 1 |", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "table", + "align": [ + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ] + ], + "rows": [ + [ + [ + { + "type": "text", + "value": "1" + } + ] + ] + ] + } + ] + } + ] + }, + { + "name": "checked task", + "src": "- [x] a", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ], + "checked": true + } + ] + } + ] + }, + { + "name": "unchecked task", + "src": "- [ ] b", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ], + "checked": false + } + ] + } + ] + }, + { + "name": "uppercase X is checked", + "src": "- [X] a", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ], + "checked": true + } + ] + } + ] + }, + { + "name": "mixed task and plain items", + "src": "- [x] a\n- [ ] b\n- c", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ], + "checked": true + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ], + "checked": false + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "c" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "brackets without a following space are not a task", + "src": "- [x]a\n- [] b\n- [xx] c", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[x]a" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[] b" + } + ] + } + ] + }, + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[xx] c" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ordered task list", + "src": "1. [x] a", + "ast": [ + { + "type": "list", + "ordered": true, + "start": 1, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ], + "checked": true + } + ] + } + ] + }, + { + "name": "task marker only counts at the start of the item", + "src": "- a [x] b", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a [x] b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "strikethrough", + "src": "~~x~~", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "del", + "children": [ + { + "type": "text", + "value": "x" + } + ] + } + ] + } + ] + }, + { + "name": "strikethrough with surrounding text and nested emphasis", + "src": "a ~~b *c*~~ d", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a " + }, + { + "type": "del", + "children": [ + { + "type": "text", + "value": "b " + }, + { + "type": "em", + "children": [ + { + "type": "text", + "value": "c" + } + ] + } + ] + }, + { + "type": "text", + "value": " d" + } + ] + } + ] + }, + { + "name": "single tilde is literal", + "src": "~x~ ~~ y ~~", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "~x~ ~~ y ~~" + } + ] + } + ] + }, + { + "name": "unclosed strikethrough is literal", + "src": "~~x", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "~~x" + } + ] + } + ] + }, + { + "name": "strikethrough is literal inside a code span", + "src": "`~~x~~`", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "codespan", + "value": "~~x~~" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/test/markdown.gfm.test.ts b/test/markdown.gfm.test.ts new file mode 100644 index 0000000..2729072 --- /dev/null +++ b/test/markdown.gfm.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import gfm from "./fixtures/md-gfm.json"; + +// Loaded dynamically so a missing module fails by assertion rather than by a load error. +let M: typeof import("../src/client/markdown.ts"); +try { + M = await import("../src/client/markdown.ts"); +} catch {} + +describe("AC-15/16/17 GFM tables, task lists and strikethrough (fixtures/md-gfm.json)", () => { + for (const c of gfm as { name: string; src: string; ast: unknown }[]) { + test(c.name, () => { + expect(M.parseMarkdown(c.src)).toEqual(c.ast as never); + }); + } +}); + +describe("AC-15 table column cap", () => { + const row = (n: number, cell: string) => "|" + Array.from({ length: n }, () => cell).join("|") + "|"; + + test("200 columns is still a table", () => { + const ast = M.parseMarkdown(`${row(200, " a ")}\n${row(200, "---")}\n${row(200, " b ")}`); + expect(ast.length).toBe(1); + expect(ast[0]!.type).toBe("table"); + expect((ast[0] as { align: unknown[] }).align.length).toBe(200); + }); + + test("201 columns degrade to a paragraph and never throw", () => { + const ast = M.parseMarkdown(`${row(201, " a ")}\n${row(201, "---")}\n${row(201, " b ")}`); + expect(ast.some((b) => b.type === "table")).toBe(false); + expect(ast[0]!.type).toBe("paragraph"); + }); + + test("a huge column count stays linear and does not throw", () => { + const n = 50_000; + const t0 = performance.now(); + const ast = M.parseMarkdown(`${row(n, " a ")}\n${row(n, "-")}\n${row(n, " b ")}`); + expect(ast.some((b) => b.type === "table")).toBe(false); + expect(performance.now() - t0).toBeLessThan(1000); + }); +}); + +describe("GFM syntax never reaches the AST as raw strings", () => { + test("a task item carries a boolean, not marker text", () => { + const [list] = M.parseMarkdown("- [x] done") as { items: { checked?: boolean; children: unknown[] }[] }[]; + expect(list!.items[0]!.checked).toBe(true); + expect(JSON.stringify(list!.items[0]!.children)).not.toContain("[x]"); + }); +}); From c8766c378b244c69933895f848c997f68ad57006 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 10:49:11 +0100 Subject: [PATCH 6/9] feat(markdown): highlighted fenced code and visible hidden characters --- src/client/mdrender.ts | 93 ++++++++++++++--- src/client/search.ts | 159 ++++++++++++++++++++++------- src/client/util.ts | 12 +++ test/client.hidden.test.ts | 57 +++++++++++ test/client.mdcode.test.ts | 200 +++++++++++++++++++++++++++++++++++++ 5 files changed, 471 insertions(+), 50 deletions(-) create mode 100644 test/client.hidden.test.ts create mode 100644 test/client.mdcode.test.ts diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts index 0734968..d84ad08 100644 --- a/src/client/mdrender.ts +++ b/src/client/mdrender.ts @@ -1,7 +1,59 @@ -import { resolveRepoPath, safeHref, slugify, type Block, type Inline } from "./markdown.ts"; +import { languageFor, lexLine, type Span } from "./highlight.ts"; +import { showHidden } from "./util.ts"; +import { + resolveRepoPath, + safeHref, + slugify, + type Block, + type Inline, +} from "./markdown.ts"; /** Rendering stops once this many DOM nodes exist. */ const MAX_NODES = 200_000; +/** Fences beyond these limits render as plain text. */ +const MAX_FENCE_LINES = 2000; +const MAX_FENCE_CHARS = 200 * 1024; + +/** Builds the
 for a block; `lang` is only a lookup key, never emitted. */
+function codeBlock(value: string, lang: string | null, b: Budget): HTMLElement {
+  const pre = document.createElement("pre");
+  pre.setAttribute("dir", "auto");
+  const code = document.createElement("code");
+  pre.append(code);
+  let id = "text";
+  if (lang && /^[a-z0-9+#-]{1,20}$/.test(lang)) id = languageFor("x." + lang);
+  const lines = value.split("\n");
+  if (
+    id === "text" ||
+    lines.length > MAX_FENCE_LINES ||
+    value.length > MAX_FENCE_CHARS
+  ) {
+    code.textContent = lines.map(showHidden).join("\n");
+    b.take(2);
+    return pre;
+  }
+  let state = 0;
+  const spans: Span[] = [];
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]!;
+    if (i) code.append("\n");
+    spans.length = 0;
+    state = line.length > 10_000 ? 0 : lexLine(id, line, state, spans);
+    let at = 0;
+    for (const [s, e, t] of spans) {
+      if (s < at || e <= s) continue;
+      if (s > at) code.append(showHidden(line.slice(at, s)));
+      const sp = document.createElement("span");
+      sp.className = "tok-" + t;
+      sp.textContent = showHidden(line.slice(s, e));
+      code.append(sp);
+      at = e;
+    }
+    if (at < line.length) code.append(showHidden(line.slice(at)));
+    if (!b.take(spans.length + 1)) break;
+  }
+  return pre;
+}
 
 class Budget {
   readonly seen = new Map();
@@ -20,13 +72,23 @@ function plainText(nodes: Inline[]): string {
   let out = "";
   for (const n of nodes) {
     if (n.type === "text" || n.type === "codespan") out += n.value;
-    else if (n.type === "em" || n.type === "strong" || n.type === "del" || n.type === "link") out += plainText(n.children);
+    else if (
+      n.type === "em" ||
+      n.type === "strong" ||
+      n.type === "del" ||
+      n.type === "link"
+    )
+      out += plainText(n.children);
     else if (n.type === "image") out += n.alt;
   }
   return out;
 }
 
-function link(parent: HTMLElement, n: Extract, b: Budget) {
+function link(
+  parent: HTMLElement,
+  n: Extract,
+  b: Budget,
+) {
   const safe = safeHref(n.href);
   let a: HTMLElement | null = null;
   if (safe) {
@@ -46,7 +108,7 @@ function link(parent: HTMLElement, n: Extract, b: Budg
       } else a = null;
     }
   }
-  if (a && n.title) a.setAttribute("title", n.title);
+  if (a && n.title) a.setAttribute("title", showHidden(n.title));
   if (a) parent.append(a);
   inline(a ?? parent, n.children, b);
 }
@@ -68,7 +130,7 @@ function inline(parent: HTMLElement, nodes: Inline[], b: Budget) {
       }
       case "codespan": {
         const el = document.createElement("code");
-        el.textContent = n.value;
+        el.textContent = showHidden(n.value);
         parent.append(el);
         break;
       }
@@ -109,12 +171,7 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) {
         parent.append(document.createElement("hr"));
         break;
       case "code": {
-        const pre = document.createElement("pre");
-        const code = document.createElement("code");
-        code.textContent = blk.value;
-        pre.append(code);
-        parent.append(pre);
-        b.take(2);
+        parent.append(codeBlock(blk.value, blk.lang, b));
         break;
       }
       case "blockquote": {
@@ -129,7 +186,11 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) {
         const thead = document.createElement("thead");
         const tbody = document.createElement("tbody");
         table.append(thead, tbody);
-        const row = (host: HTMLElement, cells: Inline[][], tag: "th" | "td") => {
+        const row = (
+          host: HTMLElement,
+          cells: Inline[][],
+          tag: "th" | "td",
+        ) => {
           const tr = document.createElement("tr");
           host.append(tr);
           for (let k = 0; k < cells.length; k++) {
@@ -150,7 +211,8 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) {
       }
       case "list": {
         const el = document.createElement(blk.ordered ? "ol" : "ul");
-        if (blk.ordered && blk.start !== 1 && blk.start !== null) el.setAttribute("start", String(blk.start));
+        if (blk.ordered && blk.start !== 1 && blk.start !== null)
+          el.setAttribute("start", String(blk.start));
         parent.append(el);
         for (const item of blk.items) {
           if (!b.take()) return;
@@ -174,7 +236,10 @@ function blocks(parent: HTMLElement, list: Block[], b: Budget, tight = false) {
 }
 
 /** Turns the AST into DOM using createElement/textContent only; never innerHTML. */
-export function renderMarkdown(list: Block[], opts: { base?: string } = {}): HTMLElement {
+export function renderMarkdown(
+  list: Block[],
+  opts: { base?: string } = {},
+): HTMLElement {
   const root = document.createElement("div");
   root.className = "md-doc";
   const b = new Budget(opts.base);
diff --git a/src/client/search.ts b/src/client/search.ts
index 1ffd9e4..e44aa2d 100644
--- a/src/client/search.ts
+++ b/src/client/search.ts
@@ -1,5 +1,5 @@
 import { api, type SearchEvent, type SearchMatch } from "./api.ts";
-import { esc, h } from "./util.ts";
+import { esc, h, showHidden } from "./util.ts";
 
 export type { SearchEvent };
 
@@ -18,15 +18,8 @@ interface Group {
 type Opt = "case" | "word" | "regex" | "ignored";
 
 /** Shows control characters and bidi overrides as visible escapes, then HTML-escapes. */
-// eslint-disable-next-line no-control-regex
-const HIDDEN = /[\x00-\x1f\x7f-\x9f‪-‮⁦-⁩]/g;
 function safe(s: string): string {
-  return esc(
-    s.replace(HIDDEN, (c) => {
-      const n = c.charCodeAt(0);
-      return n < 256 ? `\\x${n.toString(16).padStart(2, "0")}` : `\\u${n.toString(16).padStart(4, "0")}`;
-    }),
-  );
+  return esc(showHidden(s));
 }
 
 /** An include glob for everything under `dir`: Bun.Glob metacharacters, commas (the list separator) and whitespace (trimmed from list entries) backslash-escaped, so the scope is literal. */
@@ -37,12 +30,14 @@ export function folderGlob(dir: string): string {
 /** Longest include list the server accepts (src/server/search.ts MAX_GLOB_CHARS). */
 const MAX_INCLUDE = 1024;
 
-const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
+const plural = (n: number, one: string, many: string) =>
+  `${n} ${n === 1 ? one : many}`;
 
 /** Project-wide content search panel: query, option toggles, results grouped by file. */
 export class SearchView {
   readonly el: HTMLElement;
-  onOpen: (path: string, line: number, col: number, len: number) => void = () => {};
+  onOpen: (path: string, line: number, col: number, len: number) => void =
+    () => {};
   /** Makes the view visible; searchIn calls it once the scope is set, before searching. */
   onReveal: () => void = () => {};
   private input: HTMLInputElement;
@@ -51,7 +46,12 @@ export class SearchView {
   private results: HTMLElement;
   private progress: HTMLElement;
   private toggles = new Map();
-  private on: Record = { case: false, word: false, regex: false, ignored: false };
+  private on: Record = {
+    case: false,
+    word: false,
+    regex: false,
+    ignored: false,
+  };
   private debounce: number;
   private timer = 0;
   private progressTimer = 0;
@@ -72,16 +72,54 @@ export class SearchView {
 
   constructor(opts: SearchViewOptions = {}) {
     this.debounce = opts.debounce ?? 250;
-    this.input = h("input", { class: "sv-query", spellcheck: "false", placeholder: "Search", "aria-label": "Search in files" });
-    this.include = h("input", { class: "sv-include", spellcheck: "false", placeholder: "files to include", "aria-label": "Files to include" });
-    this.exclude = h("input", { class: "sv-exclude", spellcheck: "false", placeholder: "files to exclude", "aria-label": "Files to exclude" });
-    this.results = h("div", { class: "sv-results", role: "listbox", tabindex: "0" });
+    this.input = h("input", {
+      class: "sv-query",
+      spellcheck: "false",
+      placeholder: "Search",
+      "aria-label": "Search in files",
+    });
+    this.include = h("input", {
+      class: "sv-include",
+      spellcheck: "false",
+      placeholder: "files to include",
+      "aria-label": "Files to include",
+    });
+    this.exclude = h("input", {
+      class: "sv-exclude",
+      spellcheck: "false",
+      placeholder: "files to exclude",
+      "aria-label": "Files to exclude",
+    });
+    this.results = h("div", {
+      class: "sv-results",
+      role: "listbox",
+      tabindex: "0",
+    });
     this.progress = h("div", { class: "sv-progress", hidden: true });
-    const refresh = h("button", { class: "sv-refresh", type: "button" }, "Refresh");
+    const refresh = h(
+      "button",
+      { class: "sv-refresh", type: "button" },
+      "Refresh",
+    );
     refresh.addEventListener("click", () => void this.run());
-    this.staleBar = h("div", { class: "sv-stale", hidden: true }, h("span", null, "Results may be out of date"), refresh);
+    this.staleBar = h(
+      "div",
+      { class: "sv-stale", hidden: true },
+      h("span", null, "Results may be out of date"),
+      refresh,
+    );
     const tog = (opt: Opt, label: string, title: string, cls = "") => {
-      const b = h("button", { class: `sv-toggle ${cls}`.trim(), "data-opt": opt, "aria-pressed": "false", title, type: "button" }, label);
+      const b = h(
+        "button",
+        {
+          class: `sv-toggle ${cls}`.trim(),
+          "data-opt": opt,
+          "aria-pressed": "false",
+          title,
+          type: "button",
+        },
+        label,
+      );
       b.addEventListener("click", () => {
         this.on[opt] = !this.on[opt];
         b.setAttribute("aria-pressed", String(this.on[opt]));
@@ -96,10 +134,22 @@ export class SearchView {
       h(
         "div",
         { class: "sv-top" },
-        h("div", { class: "sv-field" }, this.input, tog("case", "Aa", "Match case"), tog("word", "ab", "Whole word"), tog("regex", ".*", "Regular expression")),
+        h(
+          "div",
+          { class: "sv-field" },
+          this.input,
+          tog("case", "Aa", "Match case"),
+          tog("word", "ab", "Whole word"),
+          tog("regex", ".*", "Regular expression"),
+        ),
         this.include,
         this.exclude,
-        tog("ignored", "Include ignored files", "Also search files excluded by .gitignore", "sv-wide"),
+        tog(
+          "ignored",
+          "Include ignored files",
+          "Also search files excluded by .gitignore",
+          "sv-wide",
+        ),
       ),
       this.staleBar,
       this.progress,
@@ -143,7 +193,8 @@ export class SearchView {
       const g = head ? this.groups[Number(head.dataset.f)] : null;
       if (g) {
         g.collapsed = !g.collapsed;
-        if (g.collapsed && this.flat[this.sel]?.g === Number(head!.dataset.f)) this.sel = this.nearestVisible(this.sel);
+        if (g.collapsed && this.flat[this.sel]?.g === Number(head!.dataset.f))
+          this.sel = this.nearestVisible(this.sel);
         this.render();
       }
     });
@@ -161,9 +212,16 @@ export class SearchView {
     this.flat.forEach((_, i) => this.visible(i) && vis.push(i));
     if (!vis.length) return;
     const at = vis.indexOf(this.sel);
-    this.sel = at < 0 ? (dir === 1 ? vis[0]! : vis[vis.length - 1]!) : vis[Math.max(0, Math.min(vis.length - 1, at + dir))]!;
+    this.sel =
+      at < 0
+        ? dir === 1
+          ? vis[0]!
+          : vis[vis.length - 1]!
+        : vis[Math.max(0, Math.min(vis.length - 1, at + dir))]!;
     this.render();
-    this.results.querySelector(".sv-row.sel")?.scrollIntoView?.({ block: "nearest" });
+    this.results
+      .querySelector(".sv-row.sel")
+      ?.scrollIntoView?.({ block: "nearest" });
   }
 
   private visible(i: number): boolean {
@@ -185,9 +243,12 @@ export class SearchView {
   step(dir: 1 | -1): boolean {
     const n = this.flat.length;
     if (!n) return false;
-    this.sel = this.sel < 0 ? (dir === 1 ? 0 : n - 1) : (this.sel + dir + n) % n;
+    this.sel =
+      this.sel < 0 ? (dir === 1 ? 0 : n - 1) : (this.sel + dir + n) % n;
     this.render();
-    this.results.querySelector(".sv-row.sel")?.scrollIntoView?.({ block: "nearest" });
+    this.results
+      .querySelector(".sv-row.sel")
+      ?.scrollIntoView?.({ block: "nearest" });
     const grp = this.groups[this.flat[this.sel]!.g]!;
     if (grp.collapsed) {
       grp.collapsed = false;
@@ -201,7 +262,8 @@ export class SearchView {
   /** A file changed on disk: when it is in the results (or `many` changed), offer a Refresh. */
   markStale(paths: string[], many = false): void {
     if (!this.started || this.error) return;
-    if (many || this.groups.some((g) => paths.includes(g.path))) return this.showStale();
+    if (many || this.groups.some((g) => paths.includes(g.path)))
+      return this.showStale();
     // Still streaming: the path may arrive later in this stream, so remember it until the end.
     if (this.running) for (const p of paths) this.pending.add(p);
   }
@@ -320,13 +382,28 @@ export class SearchView {
       for await (const e of api.search(params, ac.signal)) {
         if (g !== this.gen) break;
         if (e.t === "done") this.done = e;
-        else this.groups.push({ path: e.path, matches: e.matches, more: e.more ?? 0, collapsed: false });
+        else
+          this.groups.push({
+            path: e.path,
+            matches: e.matches,
+            more: e.more ?? 0,
+            collapsed: false,
+          });
         if (e.t === "done" || this.groups.length < 50) this.render();
-        else if (!this.renderTimer) this.renderTimer = window.setTimeout(() => ((this.renderTimer = 0), this.render()), 100);
+        else if (!this.renderTimer)
+          this.renderTimer = window.setTimeout(
+            () => ((this.renderTimer = 0), this.render()),
+            100,
+          );
       }
     } catch (e) {
       if (g === this.gen) {
-        this.error = e instanceof TypeError ? "Search failed" : e instanceof Error ? e.message : "Search failed";
+        this.error =
+          e instanceof TypeError
+            ? "Search failed"
+            : e instanceof Error
+              ? e.message
+              : "Search failed";
         if (!(e instanceof TypeError)) this.input.classList.add("invalid");
       }
     } finally {
@@ -336,7 +413,8 @@ export class SearchView {
         clearTimeout(this.renderTimer);
         this.renderTimer = 0;
         this.progress.hidden = true;
-        if (!this.error && this.groups.some((g) => this.pending.has(g.path))) this.showStale();
+        if (!this.error && this.groups.some((g) => this.pending.has(g.path)))
+          this.showStale();
         this.pending.clear();
         this.render();
       }
@@ -354,7 +432,10 @@ export class SearchView {
       return;
     }
     const d = this.done;
-    const skipped = d && d.skipped ? `
${plural(d.skipped, "binary/large file", "binary/large files")} skipped
` : ""; + const skipped = + d && d.skipped + ? `
${plural(d.skipped, "binary/large file", "binary/large files")} skipped
` + : ""; if (!this.groups.length) { this.results.innerHTML = this.running ? `
Searching…
` @@ -367,7 +448,10 @@ export class SearchView { let html = ""; this.groups.forEach((g, gi) => { const slash = g.path.lastIndexOf("/"); - const dir = slash > 0 ? `${safe(g.path.slice(0, slash))}` : ""; + const dir = + slash > 0 + ? `${safe(g.path.slice(0, slash))}` + : ""; const count = g.matches.length + g.more; total += count; html += `
${safe(g.path.slice(slash + 1))}${dir}${count}
`; @@ -382,9 +466,12 @@ export class SearchView { const n = d ? d.matches : total; const files = d ? d.files : this.groups.length; let head = `
${plural(n, "result", "results")} in ${plural(files, "file", "files")}
`; - if (d?.truncated === "matches") head += `
Showing first 2,000 results
`; - if (d?.truncated === "time") head += `
Search timed out - showing partial results
`; - if (d?.error || (!d && !this.running)) head += `
Search ended early - showing partial results
`; + if (d?.truncated === "matches") + head += `
Showing first 2,000 results
`; + if (d?.truncated === "time") + head += `
Search timed out - showing partial results
`; + if (d?.error || (!d && !this.running)) + head += `
Search ended early - showing partial results
`; this.results.innerHTML = head + skipped + html; } } diff --git a/src/client/util.ts b/src/client/util.ts index ebae12e..015d950 100644 --- a/src/client/util.ts +++ b/src/client/util.ts @@ -158,3 +158,15 @@ export function dismissable( window.removeEventListener("wheel", onAway, true); }; } + +/** Control characters and bidi overrides that must never be shown raw (Trojan Source). */ +// eslint-disable-next-line no-control-regex +export const HIDDEN = /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g; + +/** Replaces every HIDDEN character with a visible `\xNN` / `\uNNNN` escape. */ +export function showHidden(s: string): string { + return s.replace(HIDDEN, (c) => { + const n = c.charCodeAt(0); + return n < 256 ? `\\x${n.toString(16).padStart(2, "0")}` : `\\u${n.toString(16).padStart(4, "0")}`; + }); +} diff --git a/test/client.hidden.test.ts b/test/client.hidden.test.ts new file mode 100644 index 0000000..c06e7d4 --- /dev/null +++ b/test/client.hidden.test.ts @@ -0,0 +1,57 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { installDom, uninstallDom } from "./helpers/fakedom.ts"; + +let u: typeof import("../src/client/util.ts"); +beforeAll(async () => { + installDom(); + u = await import("../src/client/util.ts?hidden"); +}); +afterAll(() => uninstallDom()); + +describe("AC-19 shared HIDDEN rule in util.ts", () => { + test("HIDDEN is a global RegExp matching C0/C1 controls and bidi overrides", () => { + expect(u.HIDDEN).toBeInstanceOf(RegExp); + expect(u.HIDDEN.global).toBe(true); + for (const c of [ + "\x00", + "\x01", + "\x1f", + "\x7f", + "\x85", + "\x9f", + "‪", + "‮", + "⁦", + "⁩", + ]) { + expect(("a" + c + "b").replace(u.HIDDEN, "#")).toBe("a#b"); + } + }); + + test("HIDDEN leaves printable text alone", () => { + expect("héllo wörld ✓ 日本 /path".replace(u.HIDDEN, "#")).toBe( + "héllo wörld ✓ 日本 /path", + ); + expect(" ~\xa0\xa1".replace(u.HIDDEN, "#")).toBe(" ~\xa0\xa1"); + }); + + test("showHidden writes escapes: \\xNN below 256, \\uNNNN above", () => { + expect(u.showHidden("a\x01b")).toBe("a\\x01b"); + expect(u.showHidden("a\x7fb\x85")).toBe("a\\x7fb\\x85"); + expect(u.showHidden("a‮b")).toBe("a\\u202eb"); + expect(u.showHidden("⁦x⁩")).toBe("\\u2066x\\u2069"); + expect(u.showHidden("plain text")).toBe("plain text"); + }); + + test("search.ts uses the shared rule instead of its own copy", () => { + const src = readFileSync( + new URL("../src/client/search.ts", import.meta.url), + "utf8", + ); + expect(src).not.toMatch(/const HIDDEN\s*=/); + expect(src).toMatch( + /import[^;]*\bHIDDEN\b[^;]*from "\.\/util\.ts"|import[^;]*\bshowHidden\b[^;]*from "\.\/util\.ts"/, + ); + }); +}); diff --git a/test/client.mdcode.test.ts b/test/client.mdcode.test.ts new file mode 100644 index 0000000..5da6a77 --- /dev/null +++ b/test/client.mdcode.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + installDom, + uninstallDom, + type FakeElement, +} from "./helpers/fakedom.ts"; + +let M: typeof import("../src/client/markdown.ts"); +let R: typeof import("../src/client/mdrender.ts"); +beforeAll(async () => { + installDom(); + try { + M = await import("../src/client/markdown.ts"); + R = await import("../src/client/mdrender.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +const render = (src: string) => + R.renderMarkdown(M.parseMarkdown(src)) as unknown as FakeElement; +const q = (el: FakeElement, sel: string) => + el.querySelectorAll(sel); +const fence = (lang: string, body: string) => + "```" + lang + "\n" + body + "\n```"; +const allEls = (el: FakeElement): FakeElement[] => { + const out: FakeElement[] = [el]; + const walk = (e: FakeElement) => { + for (const c of e.children) + if ("tag" in c) (out.push(c as FakeElement), walk(c as FakeElement)); + }; + walk(el); + return out; +}; +/** The code element inside the first pre (fakedom has no descendant selectors). */ +const blockCode = (el: FakeElement) => q(q(el, "pre")[0]!, "code")[0]!; +const spans = (el: FakeElement) => q(el, "span"); +const tokText = (el: FakeElement, tok: string) => + q(el, "span." + tok).map((s) => s.textContent); + +describe("AC-18 highlighted fences", () => { + test("a known language yields tok-* spans and the code text is unchanged", () => { + const body = "const x = 1; // note"; + const el = render(fence("js", body)); + const code = blockCode(el); + expect(code.textContent).toBe(body); + expect(spans(code).length).toBeGreaterThan(0); + expect(tokText(code, "tok-k")).toContain("const"); + expect(tokText(code, "tok-c").join("")).toContain("// note"); + }); + + test("every span carries only a tok- class and no other attribute", () => { + const el = render( + fence( + "ts", + "export function f(a: number): string { return `x${a}` + 'y'; }", + ), + ); + const ss = spans(el); + expect(ss.length).toBeGreaterThan(2); + for (const s of ss) { + expect(s.className).toMatch(/^tok-[a-z]$/); + expect([...s.attrs.keys()]).toEqual(["class"]); + } + }); + + test("lexer state is threaded line to line (block comment spanning lines)", () => { + const el = render(fence("js", "/* open\nstill comment\nend */ let y")); + const comments = tokText(el, "tok-c"); + expect(comments.join("|")).toContain("still comment"); + expect(comments.join("|")).toContain("end */"); + expect(blockCode(el).textContent).toBe( + "/* open\nstill comment\nend */ let y", + ); + expect(tokText(el, "tok-k")).toContain("let"); + }); + + test("state does not leak between separate fences", () => { + const el = render( + fence("js", "/* never closed") + "\n\n" + fence("js", "let z = 2"), + ); + const codes = q(el, "pre").map((p) => q(p, "code")[0]!); + expect(codes.length).toBe(2); + expect(tokText(codes[1]!, "tok-k")).toContain("let"); + expect(tokText(codes[1]!, "tok-c")).toEqual([]); + }); + + test("language keys map through the lexer's known languages (ts, py)", () => { + expect( + tokText(render(fence("ts", "const a: number = 1")), "tok-k"), + ).toContain("const"); + expect( + tokText(render(fence("py", "def f():\n return 1")), "tok-k"), + ).toContain("def"); + }); + + test("the info string is never emitted into an attribute", () => { + const el = render(fence("js", "let a = 1")); + for (const e of allEls(el)) + for (const v of e.attrs.values()) expect(v).not.toMatch(/js|lang/i); + }); + + test("an unknown language renders plain text with no spans", () => { + const el = render(fence("nosuchlang", "let a = 1; // x")); + const code = blockCode(el); + expect(code.textContent).toBe("let a = 1; // x"); + expect(spans(code).length).toBe(0); + }); + + test("an unsafe info string gives plain text and nothing in attributes", () => { + const el = render('```">", "", "