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/README.md b/README.md index b0e1d5c..c09b71b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,23 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ | ⌘-click in terminal | Open `path:line` or URL | | Left ⌥ | Meta in the terminal (right ⌥ still types special characters) | +## Markdown + +`.md` and `.markdown` files open as a rendered document; the **View source** button (or the command +palette entry **Toggle Markdown Preview**) switches to the highlighted source and back. Opening a file at a +line, Find and Go to line always use the source. The preview updates live when the file changes. + +- **Supported:** CommonMark core plus GFM tables, task lists, strikethrough, bare-URL autolinks and + highlighted fenced code. The parser and renderer are written in this repository, with no dependencies. +- **Untrusted content:** the preview is built with `createElement`/`textContent` only. There is no raw HTML + (it is shown as text), links use an allowlist (`http`, `https`, `mailto`, `#fragment`, relative paths), and + relative links open inside the viewer. +- **Images:** relative images of a type the server serves (png, jpg, gif, webp, avif, bmp, ico, svg) load + through the authenticated raw endpoint; at most 100 images per render. Remote images (`http(s)`, `//host`, + `data:`) are never loaded: a placeholder shows the alt text and host, with a link to open it in your browser. +- **Limits:** files over 1 MiB open in source with a "Too large to preview · Render anyway" note; nesting, + table width, line length and about 200,000 rendered nodes are capped and degrade to literal text. + ## Security The server binds to localhost and serves a shell. It was hardened through an adversarial review @@ -109,6 +126,8 @@ The server binds to localhost and serves a shell. It was hardened through an adv - A throwaway profile, locked to the window's own server port. - External links open in your default browser. - Terminal clipboard writes always show a notice. +- **Markdown:** repository files are untrusted. The preview never uses `innerHTML`, shows no raw HTML, + filters link schemes and loads no remote images (the CSP is unchanged). - **Builds:** the Linux browser is pinned by SHA-256 in `packaging/browser-pins.json`. ## Verify a download diff --git a/SECURITY.md b/SECURITY.md index 8bac4d7..a8e67f1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,6 +9,15 @@ Please **do not open a public issue**. Report privately through GitHub's [private vulnerability reporting](https://github.com/BackendStack21/codeviewer/security/advisories/new). Include the version, OS, and the steps to reproduce. Expect an acknowledgement within a few days. +## Markdown preview + +The content of every viewed file, Markdown included, is untrusted. The Markdown renderer builds its DOM +with `createElement` and `textContent` only (never `innerHTML`), shows raw HTML as text, allows only +`http`, `https`, `mailto`, `#fragment` and relative link targets, prefixes heading ids with `md-`, and loads +no remote images: relative images are fetched through the authenticated raw endpoint as blob URLs (revoked on +re-render and close, image extensions only, at most 100 per render) and the Content-Security-Policy is not +widened. Report any way around these as a vulnerability. + ## Verifying a download Every release is built by GitHub Actions from a tagged commit and ships with `SHA256SUMS`, an diff --git a/package.json b/package.json index a137913..5e4e1e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeviewer", - "version": "1.4.0", + "version": "1.5.0", "description": "A fast, read-only code viewer with a real built-in terminal and live git indicators. Zero dependencies.", "license": "MIT", "author": "21no.de", diff --git a/src/client/app.ts b/src/client/app.ts index 7253ed1..971c1c6 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -3,6 +3,7 @@ import { api, type Info } from "./api.ts"; import { Editor, type StatusInfo } from "./editor.ts"; import { FileTree } from "./tree.ts"; import { Palette, type Command } from "./palette.ts"; +import { markdownToggleCommand } from "./mdcommands.ts"; import { TerminalPanel } from "./terminals.ts"; import type { Theme } from "./term/renderer.ts"; import { icons } from "./icons.ts"; @@ -221,6 +222,20 @@ class App { if (t) this.trackRecent(t.path); }; this.editor.onStatus = (s) => this.renderStatus(s); + this.editor.onOpenLink = async (p, hash) => { + const type = await api.stat(p); + if (type === "file") { + await this.openFile(p, { pin: true }); + if (hash) this.editor.revealFragment(p, hash); + 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); @@ -323,6 +338,7 @@ class App { c("search.next", "Next Search Result", () => this.search.step(1), "F4"), c("search.prev", "Previous Search Result", () => this.search.step(-1), "⇧F4"), c("view.explorer", "Show Explorer", () => this.setSideView("explorer")), + markdownToggleCommand({ isMarkdownTab: () => this.editor.isMarkdownTab(), toggleMarkdown: () => this.editor.toggleMarkdown() }), c("file.find", "Find in File", () => this.editor.openFind(), `${MOD}F`), c("file.line", "Go to Line…", () => this.palette.open(":"), IS_MAC ? "⌃G" : "Ctrl+G"), c("file.reveal", "Reveal Active File in Explorer", () => this.editor.active && void this.tree.reveal(this.editor.active.path)), diff --git a/src/client/editor.ts b/src/client/editor.ts index a0cbb08..e979336 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -2,6 +2,10 @@ 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 { decidePreview, tooLargeNote } from "./mdgate.ts"; +import { planView, toggleLabel, toggleMode, viewMode } from "./viewstate.ts"; import { basename, debounce, esc, formatBytes, h, MOD } from "./util.ts"; import { GitState, relativeTime } from "./git.ts"; @@ -14,6 +18,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; @@ -28,6 +34,8 @@ export interface Tab { /** Git status changed while the tab was in the background. */ gitStale: boolean; asSource: boolean; + /** The user asked for the preview of a file over the size cap (Render anyway or the toggle). */ + forcePreview?: boolean; changedOnDisk: boolean; deleted: boolean; } @@ -63,6 +71,12 @@ 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, hash?: string) => Promise = async (p, hash) => { + await this.open(p, { pin: true }); + if (hash) this.revealFragment(p, hash); + return true; + }; /** Called whenever the set of tabs or the active tab changes. */ onChange: () => void = () => {}; git: GitState | null = null; @@ -85,6 +99,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 +113,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); @@ -137,9 +153,26 @@ export class Editor { tab.truncated = f.truncated; tab.encoding = f.encoding; tab.deleted = false; + const mode = { asSource: tab.asSource, forcePreview: !!tab.forcePreview }; + const showSource = viewMode(tab.path, f.size, f.truncated, mode) === "source"; + const tooLarge = f.kind === "text" && isMarkdown(tab.path) && !tab.asSource && decidePreview(f.size, f.truncated, mode.forcePreview).tooLarge; + const plan = planView({ kind: f.kind, path: tab.path, asSource: showSource, 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 (tab.code && reload) tab.code.setText(f.text); + if (plan.view === "md") { + if (plan.reuse) tab.md!.setText(f.text); + else { + tab.md = new MarkdownView(f.text, this.fontSize, { path: tab.path, onOpen: (p, hash) => this.onOpenLink(p, hash) }); + tab.el.replaceChildren(tab.md.el); + tab.el.prepend(this.viewToggle(tab, toggleLabel("preview"), icons.code)); + } + } else if (plan.reuse) tab.code!.setText(f.text); else { tab.el.replaceChildren(); tab.code = new CodeView(f.text, tab.lang, this.fontSize); @@ -152,6 +185,18 @@ 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, toggleLabel("source"), icons.image)); + if (tooLarge) { + tab.el.prepend( + tooLargeNote(() => { + tab.forcePreview = true; + tab.code = undefined; + void this.load(tab); + }), + ); + } + } } await this.refreshGit(tab, !reload); } else if (f.kind === "image") { @@ -178,6 +223,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(); @@ -189,18 +236,41 @@ export class Editor { "button", { class: "view-toggle", - onclick: () => { - tab.asSource = !tab.asSource; - tab.code = undefined; - tab.image = undefined; - void this.load(tab); - }, + onclick: () => this.flip(tab), }, h("span", { html: icon }), label, ); } + private flip(tab: Tab) { + if (isMarkdown(tab.path)) { + const next = toggleMode(tab.path, tab.size, tab.truncated, { asSource: tab.asSource, forcePreview: !!tab.forcePreview }); + tab.asSource = next.asSource; + tab.forcePreview = next.forcePreview; // an explicit request overrides the size gate + } else tab.asSource = !tab.asSource; // SVG: image or source + tab.code = undefined; + tab.image = undefined; + tab.md?.dispose(); + tab.md = undefined; + void this.load(tab); + } + + /** The active tab is a text file that can switch between preview and source. */ + isMarkdownTab(): boolean { + const t = this.active; + return !!t && t.kind === "text" && isMarkdown(t.path); + } + + toggleMarkdown() { + if (this.active && this.isMarkdownTab()) this.flip(this.active); + } + + /** Scrolls an open Markdown preview to a heading fragment. */ + revealFragment(path: string, hash: string) { + this.tabs.find((t) => t.path === path)?.md?.scrollToFragment(hash); + } + activate(tab: Tab) { if (this.active === tab) return; this.active = tab; @@ -236,7 +306,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 +376,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 +393,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 +412,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..1caf4eb --- /dev/null +++ b/src/client/markdown.ts @@ -0,0 +1,1222 @@ +// 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 interface Emphasis { + type: "em" | "strong" | "del"; + 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; + children: Inline[]; +} +export interface Paragraph { + type: "paragraph"; + children: Inline[]; +} +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"; + /** GFM task list state: true for `[x]`, false for `[ ]`, absent for a plain item. */ + checked?: boolean; + 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[]; +} +/** 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 { + const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + 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 MAX_COLS = 200; // table columns; wider tables stay a paragraph + +const isWs = (c: string | undefined) => c === " " || c === "\t"; + +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 (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; + 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 { level: n as Heading["level"], text }; +} + +/** 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 { + set: (children: Inline[]) => void; + text: string; +} +interface Ctx { + defs: Defs; + 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; +} +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({ set: (c) => (b.children = c), text }); + blocks.push(b); + }; + const flush = () => { + 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()); + }; + + let i = 0; + while (i < n) { + const line = lines[i]!; + if (isBlank(line)) { + flush(); + blank = true; + i++; + continue; + } + 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; + } + 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++; + continue; + } + flush(); + } + 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 task = /^\[([ xX])\](?:[ \t]|$)/.exec(itemLines[0]!); + if (task) itemLines[0] = itemLines[0]!.slice(3).replace(/^[ \t]/, ""); + 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(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; + } 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; +} + +/** `` 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; + 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.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; + 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: + 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"; + 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 ? "*" : c === 95 ? "_" : "~"; + x.n = x.orig = e - i; + 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; + 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 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) { + 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 { set, text } of ctx.pending) { + set(!text ? [] : tooLong(text) ? [{ type: "text", value: text }] : parseInline(text, ctx.defs)); + } + 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/mdcommands.ts b/src/client/mdcommands.ts new file mode 100644 index 0000000..09f2bc2 --- /dev/null +++ b/src/client/mdcommands.ts @@ -0,0 +1,17 @@ +import type { Command } from "./palette.ts"; + +export interface MarkdownHost { + isMarkdownTab(): boolean; + toggleMarkdown(): void; +} + +/** The "Toggle Markdown Preview" palette command; a no-op unless the active tab is Markdown. */ +export function markdownToggleCommand(host: MarkdownHost): Command { + return { + id: "view.markdown", + label: "Toggle Markdown Preview", + run() { + if (host.isMarkdownTab()) host.toggleMarkdown(); + }, + }; +} diff --git a/src/client/mdgate.ts b/src/client/mdgate.ts new file mode 100644 index 0000000..85a3848 --- /dev/null +++ b/src/client/mdgate.ts @@ -0,0 +1,25 @@ +import { h } from "./util.ts"; + +/** Text files larger than this open in source with a "Render anyway" note. */ +export const MAX_PREVIEW_BYTES = 1024 * 1024; + +export interface PreviewDecision { + /** Render the Markdown preview. */ + preview: boolean; + /** Show the "Too large to preview" note instead. */ + tooLarge: boolean; + /** Show the truncation pill above the preview. */ + pill: boolean; +} + +export function decidePreview(size: number, truncated: boolean, forced = false): PreviewDecision { + if (size > MAX_PREVIEW_BYTES && !forced) return { preview: false, tooLarge: true, pill: false }; + return { preview: true, tooLarge: false, pill: truncated }; +} + +/** The "Too large to preview · Render anyway" note; clicking the button calls `onRender`. */ +export function tooLargeNote(onRender: () => void): HTMLElement { + const btn = h("button", { class: "md-render-anyway", type: "button" }, "Render anyway"); + btn.addEventListener("click", () => onRender()); + return h("div", { class: "md-notice" }, "Too large to preview · ", btn); +} diff --git a/src/client/mdimage.ts b/src/client/mdimage.ts new file mode 100644 index 0000000..efe7407 --- /dev/null +++ b/src/client/mdimage.ts @@ -0,0 +1,40 @@ +import { resolveRepoPath } from "./markdown.ts"; + +/** At most this many images are loaded per render; the rest show their alt text. */ +export const MAX_IMAGES = 100; + +/** What an image source in Markdown refers to. */ +export type ImageTarget = + | { kind: "repo"; path: string } + | { kind: "remote"; href: string; host: string } + | { kind: "none" }; + +/** Image types the server serves (src/server/files.ts IMAGE_MIME). */ +const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "svg"]); +const NONE: ImageTarget = { kind: "none" }; + +/** Classifies an image source: a repo image (extension gated), a remote placeholder, or nothing. */ +export function classifyImage(src: string, base: string): ImageTarget { + const s = src.trim(); + if (!s || s.length > 4000) return NONE; + if (s.startsWith("//")) { + const host = /^\/\/([^/?#\\]+)/.exec(s)?.[1]; + return host ? { kind: "remote", href: "", host } : NONE; + } + if (/^data:/i.test(s)) return { kind: "remote", href: "", host: "" }; + if (/^[a-z][a-z0-9+.-]*:/i.test(s)) { + if (!/^https?:/i.test(s)) return NONE; + try { + const u = new URL(s); + return u.hostname ? { kind: "remote", href: s, host: u.hostname } : NONE; + } catch { + return NONE; + } + } + const r = resolveRepoPath(base, s); + if (!r) return NONE; + const name = r.path.slice(r.path.lastIndexOf("/") + 1); + const dot = name.lastIndexOf("."); + if (dot <= 0 || !IMAGE_EXT.has(name.slice(dot + 1).toLowerCase())) return NONE; + return { kind: "repo", path: r.path }; +} diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts new file mode 100644 index 0000000..e0e6fae --- /dev/null +++ b/src/client/mdrender.ts @@ -0,0 +1,289 @@ +import { languageFor, lexLine, type Span } from "./highlight.ts"; +import { showHidden } from "./util.ts"; +import { classifyImage, MAX_IMAGES } from "./mdimage.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;
+}
+
+/** Called for each repo image the renderer emits; the caller loads it and sets `src`. `wrap` holds the img. */
+export type ImageLoader = (img: HTMLElement, path: string, alt: string, wrap: HTMLElement) => void;
+
+class Budget {
+  readonly seen = new Map();
+  images = 0;
+  constructor(readonly base = "", readonly onImage?: ImageLoader) {}
+  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 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 === "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", showHidden(n.title));
+  if (a) parent.append(a);
+  inline(a ?? parent, n.children, b);
+}
+
+function image(parent: HTMLElement, n: Extract, b: Budget) {
+  const t = classifyImage(n.src, b.base);
+  if (t.kind === "repo" && b.onImage && b.images < MAX_IMAGES) {
+    b.images++;
+    const wrap = document.createElement("span");
+    wrap.className = "md-img";
+    const img = document.createElement("img");
+    img.setAttribute("alt", n.alt);
+    wrap.append(img);
+    parent.append(wrap);
+    b.take(2);
+    b.onImage(img, t.path, n.alt, wrap);
+  } else if (t.kind === "remote") {
+    const ph = document.createElement("span");
+    ph.className = "md-img-remote";
+    ph.append((n.alt || "image") + (t.host ? ` · ${t.host} ` : " "));
+    const safe = t.href ? safeHref(t.href) : null;
+    if (safe && safe.kind === "external") {
+      const a = document.createElement("a");
+      a.setAttribute("href", safe.href);
+      a.setAttribute("rel", "noopener noreferrer nofollow");
+      a.setAttribute("target", "_blank");
+      a.textContent = "Open";
+      ph.append(a);
+    }
+    parent.append(ph);
+    b.take(2);
+  } else parent.append(n.alt);
+}
+
+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":
+      case "del": {
+        const el = document.createElement(n.type);
+        parent.append(el);
+        inline(el, n.children, b);
+        break;
+      }
+      case "codespan": {
+        const el = document.createElement("code");
+        el.textContent = showHidden(n.value);
+        parent.append(el);
+        break;
+      }
+      case "break":
+        parent.append(document.createElement("br"));
+        break;
+      case "link":
+        link(parent, n, b);
+        break;
+      case "image":
+        image(parent, n, b);
+        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}`);
+        el.setAttribute("id", "md-" + slugify(plainText(blk.children), b.seen));
+        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": {
+        parent.append(codeBlock(blk.value, blk.lang, b));
+        break;
+      }
+      case "blockquote": {
+        const el = document.createElement("blockquote");
+        parent.append(el);
+        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));
+        parent.append(el);
+        for (const item of blk.items) {
+          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;
+      }
+    }
+  }
+}
+
+/** Turns the AST into DOM using createElement/textContent only; never innerHTML. */
+export function renderMarkdown(
+  list: Block[],
+  opts: { base?: string; onImage?: ImageLoader } = {},
+): HTMLElement {
+  const root = document.createElement("div");
+  root.className = "md-doc";
+  const b = new Budget(opts.base, opts.onImage);
+  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/mdview.ts b/src/client/mdview.ts
new file mode 100644
index 0000000..37a7166
--- /dev/null
+++ b/src/client/mdview.ts
@@ -0,0 +1,146 @@
+import { api } from "./api.ts";
+import { parseMarkdown, slugify } 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;
+  /** Fragment (without #) to scroll to once the document is built. */
+  hash?: 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;
+  private noticeEl: HTMLElement | null = null;
+  /** Bumped on every rebuild and dispose; late image loads from an older epoch are dropped. */
+  private epoch = 0;
+  private blobs: string[] = [];
+
+  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);
+    if (opts.hash) this.scrollToFragment(opts.hash);
+  }
+
+  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");
+        else this.clearNotice();
+      };
+      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();
+      this.clearNotice();
+      this.scrollToFragment(href.slice(1));
+    }
+  }
+
+  /** Scrolls to the heading a fragment names (slugified like GitHub); false when there is none. */
+  scrollToFragment(frag: string): boolean {
+    let f = frag;
+    try {
+      f = decodeURIComponent(f);
+    } catch {
+      // use as written
+    }
+    if (!f) return false;
+    const id = "md-" + slugify(f);
+    for (const hd of Array.from(this.el.querySelectorAll("h1,h2,h3,h4,h5,h6"))) {
+      if (hd.getAttribute("id") === id) {
+        hd.scrollIntoView({ block: "start" });
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private notice(msg: string) {
+    const n = document.createElement("div");
+    n.className = "md-notice";
+    n.textContent = msg;
+    this.noticeEl = n;
+    if (this.root) this.el.replaceChildren(n, this.root);
+  }
+
+  private clearNotice() {
+    if (!this.noticeEl) return;
+    this.noticeEl = null;
+    if (this.root) this.el.replaceChildren(this.root);
+  }
+
+  private revokeAll() {
+    this.epoch++;
+    for (const u of this.blobs) URL.revokeObjectURL(u);
+    this.blobs = [];
+  }
+
+  private loadImage(img: HTMLElement, path: string, alt: string, wrap: HTMLElement, epoch: number) {
+    api.rawBlobUrl(path).then(
+      (url) => {
+        if (epoch !== this.epoch) return void URL.revokeObjectURL(url);
+        this.blobs.push(url);
+        img.setAttribute("src", url);
+      },
+      () => {
+        if (epoch === this.epoch) wrap.replaceChildren(alt);
+      },
+    );
+  }
+
+  private build(text: string) {
+    this.revokeAll();
+    const epoch = this.epoch;
+    this.text = text;
+    this.renders++;
+    this.noticeEl = null;
+    const dir = this.opts.path ? this.opts.path.slice(0, Math.max(0, this.opts.path.lastIndexOf("/"))) : "";
+    this.root = renderMarkdown(parseMarkdown(text), {
+      base: dir,
+      onImage: (img, path, alt, wrap) => this.loadImage(img, path, alt, wrap, epoch),
+    });
+    this.el.replaceChildren(this.root);
+  }
+
+  /** 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.revokeAll();
+    this.text = "";
+    this.root = null;
+    this.noticeEl = null;
+    this.el.replaceChildren();
+  }
+}
diff --git a/src/client/search.ts b/src/client/search.ts
index 1ffd9e4..00ecf3e 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. */
diff --git a/src/client/styles.css b/src/client/styles.css
index 3453c67..827790d 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -854,6 +854,170 @@ 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 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-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-notice {
+  margin: 0.5em 1em;
+  padding: 0.5em 0.8em;
+  color: var(--fg-muted);
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius);
+}
+.md-notice button {
+  font: inherit;
+  color: var(--accent);
+  background: none;
+  border: 0;
+  cursor: pointer;
+  text-decoration: underline;
+}
+.md-doc img {
+  max-width: 100%;
+}
+.md-img-remote {
+  display: inline-block;
+  padding: 0 0.5em;
+  color: var(--fg-muted);
+  border: 1px dashed var(--border-strong);
+  border-radius: var(--radius);
+}
+.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;
+  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/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/src/client/viewstate.ts b/src/client/viewstate.ts
new file mode 100644
index 0000000..9f0ef20
--- /dev/null
+++ b/src/client/viewstate.ts
@@ -0,0 +1,54 @@
+import { isMarkdown } from "./markdown.ts";
+import { decidePreview } from "./mdgate.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 };
+}
+
+/** Per-tab Markdown mode: the user's choice and whether they asked to preview a file over the size cap. */
+export interface ModeState {
+  asSource: boolean;
+  forcePreview: boolean;
+}
+
+/** What a load of a text file shows: the preview only for Markdown, not chosen as source and not gated by size. */
+export function viewMode(path: string, size: number, truncated: boolean, s: ModeState): "source" | "preview" {
+  if (!isMarkdown(path) || s.asSource) return "source";
+  return decidePreview(size, truncated, s.forcePreview).preview ? "preview" : "source";
+}
+
+/** The state after the toggle is used: from a shown source to the (forced) preview, and back. */
+export function toggleMode(path: string, size: number, truncated: boolean, s: ModeState): ModeState {
+  return viewMode(path, size, truncated, s) === "source"
+    ? { asSource: false, forcePreview: true }
+    : { asSource: true, forcePreview: s.forcePreview };
+}
+
+export const toggleLabel = (mode: "source" | "preview") => (mode === "source" ? "Preview" : "View source");
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.mdcarried.test.ts b/test/client.mdcarried.test.ts
new file mode 100644
index 0000000..a69fec6
--- /dev/null
+++ b/test/client.mdcarried.test.ts
@@ -0,0 +1,179 @@
+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 q = (el: unknown, sel: string) => (el as FakeElement).querySelectorAll(sel);
+const click = (el: FakeElement) => {
+  const ev = new FakeEvent("click", { button: 0 });
+  el.dispatchEvent(ev);
+  return ev;
+};
+const tick = () => new Promise((r) => setTimeout(r, 0));
+const text = (v: { el: unknown }) => (v.el as FakeElement).textContent;
+
+describe("carried (a): [x](other.md#section) opens the file and scrolls to the heading", () => {
+  test("the click hands the fragment to onOpen", () => {
+    const calls: [string, string][] = [];
+    const v = new V.MarkdownView("[x](other.md#section)", 13, { path: "docs/a.md", onOpen: (p, h) => void calls.push([p, h]) });
+    click(q(v.el, "a")[0]!);
+    expect(calls).toEqual([["docs/other.md", "section"]]);
+  });
+
+  test("scrollToFragment scrolls to the matching heading and reports it", () => {
+    const v = new V.MarkdownView("# Top\n\n## Some Section\n\ntext", 13, { path: "o.md" });
+    expect(v.scrollToFragment("some-section")).toBe(true);
+    expect(q(v.el, "h2")[0]!.scrolled).toBe(1);
+    expect(q(v.el, "h1")[0]!.scrolled).toBe(0);
+  });
+
+  test("scrollToFragment slugifies the fragment (case, percent-encoding) and misses cleanly", () => {
+    const v = new V.MarkdownView("# C++ & Go!\n\n## Other", 13, { path: "o.md" });
+    expect(v.scrollToFragment("C++%20&%20Go!")).toBe(true);
+    expect(v.scrollToFragment("OTHER")).toBe(true);
+    expect(v.scrollToFragment("nope")).toBe(false);
+    expect(v.scrollToFragment("")).toBe(false);
+  });
+
+  test("a hash option scrolls once the document is built", () => {
+    const v = new V.MarkdownView("# A\n\n## Target Heading\n", 13, { path: "o.md", hash: "target-heading" });
+    expect(q(v.el, "h2")[0]!.scrolled).toBe(1);
+  });
+
+  test("an unknown hash option is harmless", () => {
+    const v = new V.MarkdownView("# A", 13, { path: "o.md", hash: "zzz" });
+    expect(q(v.el, "h1")[0]!.scrolled).toBe(0);
+  });
+});
+
+describe("carried (b): in-page fragment clicks use slugify", () => {
+  const SRC = "# C++ & Go!\n\n[a](#C++%20&%20Go!) [b](<#C++ & Go!>) [c](#c-go)\n";
+
+  test("the heading id is md-c-go", () => {
+    expect(q(R.renderMarkdown(M.parseMarkdown(SRC), {}), "h1")[0]!.getAttribute("id")).toBe("md-c-go");
+  });
+
+  test("clicking #C++ & Go! (encoded or angle-bracketed) or #c-go finds that heading", () => {
+    const v = new V.MarkdownView(SRC, 13, { path: "a.md" });
+    const h = q(v.el, "h1")[0]!;
+    const links = q(v.el, "a");
+    expect(links.length).toBe(3);
+    for (let i = 0; i < 3; i++) {
+      const ev = click(links[i]!);
+      expect(ev.defaultPrevented).toBe(true);
+      expect(h.scrolled).toBe(i + 1);
+    }
+    expect(dom.location.hash).toBe("#orig");
+  });
+
+  test("duplicate headings still resolve by their suffixed slug", () => {
+    const v = new V.MarkdownView("# Go!\n\n# Go!\n\n[x](#go-1)", 13, { path: "a.md" });
+    click(q(v.el, "a")[0]!);
+    const hs = q(v.el, "h1");
+    expect(hs[1]!.scrolled).toBe(1);
+    expect(hs[0]!.scrolled).toBe(0);
+  });
+});
+
+describe("carried (c): the File not found notice clears", () => {
+  const SRC = "[gone](gone.md) [here](here.md) [jump](#t)\n\n# T\n";
+  const setup = () => {
+    const open = async (p: string) => p !== "gone.md";
+    const v = new V.MarkdownView(SRC, 13, { path: "a.md", onOpen: open });
+    const link = (label: string) => q(v.el, "a").find((a) => a.textContent === label)!;
+    return { v, link };
+  };
+
+  test("shows on a failed open", async () => {
+    const { v, link } = setup();
+    click(link("gone"));
+    await tick();
+    expect(text(v)).toContain("File not found");
+  });
+
+  test("clears on the next successful open", async () => {
+    const { v, link } = setup();
+    click(link("gone"));
+    await tick();
+    click(link("here"));
+    await tick();
+    expect(text(v)).not.toContain("File not found");
+    expect(q(v.el, ".md-notice").length).toBe(0);
+  });
+
+  test("clears on an in-page fragment click", async () => {
+    const { v, link } = setup();
+    click(link("gone"));
+    await tick();
+    click(link("jump"));
+    await tick();
+    expect(q(v.el, ".md-notice").length).toBe(0);
+  });
+
+  test("repeated failures show a single notice", async () => {
+    const { v, link } = setup();
+    click(link("gone"));
+    await tick();
+    click(link("gone"));
+    await tick();
+    expect(q(v.el, ".md-notice").length).toBe(1);
+  });
+
+  test("clears when the document re-renders", async () => {
+    const { v, link } = setup();
+    click(link("gone"));
+    await tick();
+    v.setText(SRC + "more\n");
+    expect(q(v.el, ".md-notice").length).toBe(0);
+  });
+});
+
+describe("carried (d): a task item with only the marker renders a checkbox", () => {
+  const boxes = (src: string) => q(R.renderMarkdown(M.parseMarkdown(src), {}), "input");
+
+  test("- [x] alone is a checked disabled checkbox", () => {
+    const b = boxes("- [x]");
+    expect(b.length).toBe(1);
+    expect(b[0]!.getAttribute("type")).toBe("checkbox");
+    expect(b[0]!.attrs.has("disabled")).toBe(true);
+    expect(b[0]!.attrs.has("checked")).toBe(true);
+  });
+
+  test("- [ ] alone is an unchecked disabled checkbox; X is also checked", () => {
+    const u = boxes("- [ ]");
+    expect(u.length).toBe(1);
+    expect(u[0]!.attrs.has("checked")).toBe(false);
+    expect(u[0]!.attrs.has("disabled")).toBe(true);
+    expect(boxes("- [X]")[0]!.attrs.has("checked")).toBe(true);
+  });
+
+  test("marker-only items among others keep the order and show no marker text", () => {
+    const el = R.renderMarkdown(M.parseMarkdown("- [x]\n- [ ]\n- [x] done\n"), {});
+    const b = q(el, "input");
+    expect(b.map((x) => x.attrs.has("checked"))).toEqual([true, false, true]);
+    expect((el as unknown as FakeElement).textContent).not.toContain("[x]");
+    expect((el as unknown as FakeElement).textContent).not.toContain("[ ]");
+  });
+
+  test("the parser marks the item checked", () => {
+    const blocks = M.parseMarkdown("- [x]");
+    const list = blocks[0] as Extract<(typeof blocks)[number], { type: "list" }>;
+    expect(list.items[0]!.checked).toBe(true);
+  });
+
+  test("the marker followed by text without a space is still plain text", () => {
+    expect(boxes("- [x]a").length).toBe(0);
+  });
+});
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('```"> |");
+    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.mdimages.test.ts b/test/client.mdimages.test.ts
new file mode 100644
index 0000000..cfaacb6
--- /dev/null
+++ b/test/client.mdimages.test.ts
@@ -0,0 +1,285 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts";
+
+let I: typeof import("../src/client/mdimage.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");
+beforeAll(async () => {
+  installDom();
+  try {
+    I = await import("../src/client/mdimage.ts");
+    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 q = (el: unknown, sel: string) => (el as FakeElement).querySelectorAll(sel);
+const flush = () => new Promise((r) => setTimeout(r, 15));
+
+let created: string[] = [];
+let revoked: string[] = [];
+let calls: { url: string }[] = [];
+let restoreFetch = () => {};
+let origCreate: typeof URL.createObjectURL;
+let origRevoke: typeof URL.revokeObjectURL;
+let counter = 0;
+let respond: (url: string) => Response = () => new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } });
+
+beforeEach(() => {
+  created = [];
+  revoked = [];
+  counter = 0;
+  respond = () => new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } });
+  origCreate = URL.createObjectURL;
+  origRevoke = URL.revokeObjectURL;
+  URL.createObjectURL = (() => {
+    const u = `blob:test/${++counter}`;
+    created.push(u);
+    return u;
+  }) as typeof URL.createObjectURL;
+  URL.revokeObjectURL = ((u: string) => void revoked.push(u)) as typeof URL.revokeObjectURL;
+  const m = mockFetch((url) => respond(url));
+  calls = m.calls;
+  restoreFetch = m.restore;
+});
+afterEach(() => {
+  restoreFetch();
+  URL.createObjectURL = origCreate;
+  URL.revokeObjectURL = origRevoke;
+});
+
+const view = (src: string, path = "docs/a.md") => new V.MarkdownView(src, 13, { path });
+
+describe("AC-20 classifyImage (pure)", () => {
+  test("raster and SVG extensions resolve to a repo path, case-insensitively", () => {
+    for (const ext of ["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "svg", "PNG", "Svg"]) {
+      expect(I.classifyImage(`img/x.${ext}`, "docs")).toEqual({ kind: "repo", path: `docs/img/x.${ext}` });
+    }
+  });
+
+  test("the path is resolved against the document folder; query and fragment are dropped; percent-decoded once", () => {
+    expect(I.classifyImage("../x.png", "docs/sub")).toEqual({ kind: "repo", path: "docs/x.png" });
+    expect(I.classifyImage("x.png?v=1#h", "")).toEqual({ kind: "repo", path: "x.png" });
+    expect(I.classifyImage("my%20pic.png", "d")).toEqual({ kind: "repo", path: "d/my pic.png" });
+    expect(I.classifyImage("/abs.png", "docs")).toEqual({ kind: "repo", path: "abs.png" });
+  });
+
+  test("non-image extensions and dotfiles are never fetched", () => {
+    for (const src of [".env", "a.txt", "a.md", "noext", "x.png.txt", "a.ts", ".png", "dir/", "a.png.", "secret.json", "id_rsa"]) {
+      expect(I.classifyImage(src, "docs")).toEqual({ kind: "none" });
+    }
+  });
+
+  test("escaping the root, API paths and odd inputs are none", () => {
+    for (const src of ["../../x.png", "../../../etc/passwd.png", "a\\b.png", "x\u0000.png", "", "javascript:x.png", "file:///a.png", "blob:http://x/y.png"]) {
+      expect(I.classifyImage(src, "docs")).toEqual({ kind: "none" });
+    }
+  });
+
+  test("http(s) is a remote placeholder with a link; // and data: are placeholders without one", () => {
+    expect(I.classifyImage("https://a.example/b.png", "")).toEqual({ kind: "remote", href: "https://a.example/b.png", host: "a.example" });
+    expect(I.classifyImage("http://img.test/x/y.gif", "")).toEqual({ kind: "remote", href: "http://img.test/x/y.gif", host: "img.test" });
+    expect(I.classifyImage("//cdn.test/b.png", "")).toEqual({ kind: "remote", href: "", host: "cdn.test" });
+    expect(I.classifyImage("data:image/png;base64,AAAA", "")).toEqual({ kind: "remote", href: "", host: "" });
+  });
+
+  test("the image cap is 100", () => {
+    expect(I.MAX_IMAGES).toBe(100);
+  });
+});
+
+describe("AC-20 relative images load through api.rawBlobUrl", () => {
+  test("the img src is the blob URL, never the source text, and alt is kept", async () => {
+    const v = view("![the alt](img/x.png)");
+    await flush();
+    expect(calls.map((c) => c.url)).toEqual(["/api/raw?path=docs%2Fimg%2Fx.png"]);
+    const imgs = q(v.el, "img");
+    expect(imgs.length).toBe(1);
+    expect(imgs[0]!.getAttribute("src")).toBe(created[0]!);
+    expect(imgs[0]!.getAttribute("src")).not.toContain("x.png");
+    expect(imgs[0]!.getAttribute("alt")).toBe("the alt");
+  });
+
+  test("an SVG loads as an img blob, never inlined", async () => {
+    const v = view("![](logo.svg)");
+    await flush();
+    expect(q(v.el, "img").length).toBe(1);
+    expect(q(v.el, "svg").length).toBe(0);
+  });
+
+  test("images inside links and lists load too", async () => {
+    const v = view("- [![a](a.png)](b.md)\n- ![b](b.png)");
+    await flush();
+    expect(q(v.el, "img").length).toBe(2);
+    expect(calls.length).toBe(2);
+  });
+
+  test("![](.env) and other non-image files are never fetched and show the alt text", async () => {
+    const v = view("![env](.env) ![txt](a.txt) ![md](a.md)");
+    await flush();
+    expect(calls.length).toBe(0);
+    expect(q(v.el, "img").length).toBe(0);
+    const t = (v.el as FakeElement).textContent;
+    expect(t).toContain("env");
+    expect(t).toContain("txt");
+  });
+
+  test("a path that escapes the root is never fetched", async () => {
+    const v = view("![x](../../x.png)");
+    await flush();
+    expect(calls.length).toBe(0);
+    expect(q(v.el, "img").length).toBe(0);
+  });
+
+  test("a failed fetch leaves the alt text and no src, and does not throw", async () => {
+    respond = () => new Response(JSON.stringify({ error: "nope" }), { status: 404 });
+    const v = view("![gone](missing.png)");
+    await flush();
+    expect(q(v.el, "img").filter((i) => i.getAttribute("src") !== null).length).toBe(0);
+    expect((v.el as FakeElement).textContent).toContain("gone");
+    expect(created.length).toBe(0);
+  });
+
+  test("renderMarkdown alone (no loader) makes no request and sets no src", async () => {
+    const el = R.renderMarkdown(M.parseMarkdown("![a](x.png)"), { base: "" });
+    await flush();
+    expect(calls.length).toBe(0);
+    expect(q(el, "img").filter((i) => i.getAttribute("src") !== null).length).toBe(0);
+  });
+
+  test("at most 100 images are loaded per render", async () => {
+    const src = Array.from({ length: 150 }, (_, i) => `![alt${i}](i${i}.png)`).join("\n\n");
+    const v = view(src);
+    await flush();
+    expect(calls.length).toBe(100);
+    expect(q(v.el, "img").length).toBe(100);
+    expect(created.length).toBe(100);
+    expect((v.el as FakeElement).textContent).toContain("alt120");
+  });
+});
+
+describe("AC-20 blob URLs are revoked", () => {
+  test("re-render revokes the previous blob URLs", async () => {
+    const v = view("![a](a.png)");
+    await flush();
+    expect(created.length).toBe(1);
+    expect(revoked).toEqual([]);
+    expect(v.setText("![b](b.png)")).toBe(true);
+    await flush();
+    expect(revoked).toEqual([created[0]!]);
+    expect(created.length).toBe(2);
+    expect(q(v.el, "img")[0]!.getAttribute("src")).toBe(created[1]!);
+  });
+
+  test("identical text neither reloads nor revokes", async () => {
+    const v = view("![a](a.png)");
+    await flush();
+    expect(v.setText("![a](a.png)")).toBe(false);
+    await flush();
+    expect(revoked).toEqual([]);
+    expect(calls.length).toBe(1);
+  });
+
+  test("dispose revokes everything created", async () => {
+    const v = view("![a](a.png) ![b](b.png)");
+    await flush();
+    expect(created.length).toBe(2);
+    v.dispose();
+    expect([...revoked].sort()).toEqual([...created].sort());
+  });
+
+  test("a blob that resolves after the render was replaced is revoked, not shown", async () => {
+    const v = view("![a](a.png)");
+    v.setText("plain text");
+    await flush();
+    expect(created.length).toBe(revoked.length);
+    expect([...revoked].sort()).toEqual([...created].sort());
+    expect(q(v.el, "img").length).toBe(0);
+  });
+
+  test("a blob that resolves after dispose is revoked", async () => {
+    const v = view("![a](a.png)");
+    v.dispose();
+    await flush();
+    expect(created.length).toBe(1);
+    expect([...revoked].sort()).toEqual([...created].sort());
+  });
+
+  test("created equals revoked after a full lifecycle", async () => {
+    const v = view("![a](a.png) ![b](b.png)");
+    await flush();
+    v.setText("![c](c.png)");
+    await flush();
+    v.setText("![d](d.png) ![e](e.png) ![f](f.png)");
+    await flush();
+    v.dispose();
+    await flush();
+    expect(created.length).toBe(6);
+    expect([...revoked].sort()).toEqual([...created].sort());
+  });
+});
+
+describe("AC-21 remote images never load", () => {
+  test("https image: placeholder with alt and host, a safe open link, no img and no src anywhere", async () => {
+    const v = view("![logo](https://a.example/b.png)");
+    await flush();
+    expect(calls.length).toBe(0);
+    expect(q(v.el, "img").length).toBe(0);
+    const ph = q(v.el, ".md-img-remote");
+    expect(ph.length).toBe(1);
+    expect(ph[0]!.textContent).toContain("logo");
+    expect(ph[0]!.textContent).toContain("a.example");
+    const a = q(ph[0]!, "a");
+    expect(a.length).toBe(1);
+    expect(a[0]!.getAttribute("href")).toBe("https://a.example/b.png");
+    expect(a[0]!.getAttribute("target")).toBe("_blank");
+    expect(a[0]!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
+    const walk = (e: FakeElement) => {
+      expect(e.getAttribute("src")).toBeNull();
+      for (const c of e.children) if ("tag" in c) walk(c);
+    };
+    walk(v.el as FakeElement);
+  });
+
+  test("protocol-relative: placeholder with alt and host, no link, no img", async () => {
+    const v = view("![cdn pic](//cdn.test/b.png)");
+    await flush();
+    expect(q(v.el, "img").length).toBe(0);
+    expect(calls.length).toBe(0);
+    const ph = q(v.el, ".md-img-remote");
+    expect(ph.length).toBe(1);
+    expect(ph[0]!.textContent).toContain("cdn pic");
+    expect(ph[0]!.textContent).toContain("cdn.test");
+    expect(q(ph[0]!, "a").length).toBe(0);
+  });
+
+  test("data: image: placeholder with alt, payload not echoed, no link, no img", async () => {
+    const v = view("![inline](data:image/png;base64,QUFBQUFB)");
+    await flush();
+    expect(q(v.el, "img").length).toBe(0);
+    const ph = q(v.el, ".md-img-remote");
+    expect(ph.length).toBe(1);
+    expect(ph[0]!.textContent).toContain("inline");
+    expect((v.el as FakeElement).textContent).not.toContain("QUFBQUFB");
+    expect(q(ph[0]!, "a").length).toBe(0);
+  });
+
+  test("javascript: and file: image sources render only the alt text", async () => {
+    const v = view("![j](javascript:alert(1)) ![f](file:///etc/passwd)");
+    await flush();
+    expect(q(v.el, "img").length).toBe(0);
+    expect(q(v.el, "a").length).toBe(0);
+    expect(calls.length).toBe(0);
+  });
+
+  test("the response CSP is unchanged: img-src stays 'self' data: blob:", async () => {
+    const src = await Bun.file(new URL("../src/server/http.ts", import.meta.url)).text();
+    expect(src).toContain(
+      "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self'; ",
+    );
+    expect(src).toContain("object-src 'none'; frame-ancestors 'none'; base-uri 'none'");
+  });
+});
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/client.mdpalette.test.ts b/test/client.mdpalette.test.ts
new file mode 100644
index 0000000..5792a02
--- /dev/null
+++ b/test/client.mdpalette.test.ts
@@ -0,0 +1,57 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { FakeEvent, installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts";
+
+let C: typeof import("../src/client/mdcommands.ts");
+let P: typeof import("../src/client/palette.ts");
+beforeAll(async () => {
+  installDom();
+  try {
+    C = await import("../src/client/mdcommands.ts");
+    P = await import("../src/client/palette.ts");
+  } catch {}
+});
+afterAll(() => uninstallDom());
+
+const host = (md: boolean) => {
+  const h = { toggles: 0, isMarkdownTab: () => md, toggleMarkdown: () => void h.toggles++ };
+  return h;
+};
+
+describe("AC-23 Toggle Markdown Preview command", () => {
+  test("has the documented label and a stable id", () => {
+    const c = C.markdownToggleCommand(host(true));
+    expect(c.label).toBe("Toggle Markdown Preview");
+    expect(c.id.length).toBeGreaterThan(0);
+  });
+
+  test("on a Markdown tab it toggles once per run", () => {
+    const h = host(true);
+    const c = C.markdownToggleCommand(h);
+    c.run();
+    c.run();
+    expect(h.toggles).toBe(2);
+  });
+
+  test("on any other tab it does nothing", () => {
+    const h = host(false);
+    C.markdownToggleCommand(h).run();
+    expect(h.toggles).toBe(0);
+  });
+
+  test("it is found and run through the real palette", () => {
+    const m = mockFetch(() => new Response("a.ts"));
+    try {
+      const h = host(true);
+      const p = new P.Palette();
+      p.commands = [C.markdownToggleCommand(h)];
+      const input = p.el.querySelector(".palette-input")!;
+      p.open(">");
+      input.value = ">markdown prev";
+      input.dispatchEvent(new FakeEvent("input"));
+      input.dispatchEvent(new FakeEvent("keydown", { key: "Enter" }));
+      expect(h.toggles).toBe(1);
+    } finally {
+      m.restore();
+    }
+  });
+});
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/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/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/client.viewstate.large.test.ts b/test/client.viewstate.large.test.ts new file mode 100644 index 0000000..e27c51d --- /dev/null +++ b/test/client.viewstate.large.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +// Pure mode decisions for Markdown tabs, including the 1 MiB size gate (review SL06 major). +// viewMode(path, size, truncated, state) -> "source" | "preview" what a load shows +// toggleMode(path, size, truncated, state) -> next state what the toggle button / palette does +// toggleLabel(mode) -> "Preview" | "View source" +// state is { asSource: boolean; forcePreview: boolean }. +const V: Record = (await import("../src/client/viewstate.ts").catch(() => ({}))) as Record; + +type State = { asSource: boolean; forcePreview: boolean }; +type Mode = "source" | "preview"; +const need = (name: string): T => { + expect(typeof V[name]).toBe("function"); + return V[name] as T; +}; +const viewMode = (p: string, size: number, tr: boolean, s: State) => need<(p: string, size: number, tr: boolean, s: State) => Mode>("viewMode")(p, size, tr, s); +const toggle = (p: string, size: number, tr: boolean, s: State) => need<(p: string, size: number, tr: boolean, s: State) => State>("toggleMode")(p, size, tr, s); +const label = (m: Mode) => need<(m: Mode) => string>("toggleLabel")(m); + +const BIG = 1024 * 1024 + 1; +const fresh: State = { asSource: false, forcePreview: false }; + +describe("a Markdown file over 1 MiB", () => { + test("opens in source and the toggle is labelled Preview", () => { + const m = viewMode("big.md", BIG, false, fresh); + expect(m).toBe("source"); + expect(label(m)).toBe("Preview"); + }); + + test("clicking Preview renders the preview and the label becomes View source", () => { + const s = toggle("big.md", BIG, false, fresh); + const m = viewMode("big.md", BIG, false, s); + expect(m).toBe("preview"); + expect(label(m)).toBe("View source"); + }); + + test("clicking again returns to source", () => { + const s1 = toggle("big.md", BIG, false, fresh); + const s2 = toggle("big.md", BIG, false, s1); + const m = viewMode("big.md", BIG, false, s2); + expect(m).toBe("source"); + expect(label(m)).toBe("Preview"); + }); + + test("a third click previews again", () => { + let s = fresh; + for (let i = 0; i < 3; i++) s = toggle("big.md", BIG, false, s); + expect(viewMode("big.md", BIG, false, s)).toBe("preview"); + }); + + test("a live reload keeps the chosen mode (state is untouched by a load)", () => { + const previewing = toggle("big.md", BIG, false, fresh); + expect(viewMode("big.md", BIG + 500, false, previewing)).toBe("preview"); + const back = toggle("big.md", BIG, false, previewing); + expect(viewMode("big.md", BIG + 500, false, back)).toBe("source"); + }); + + test("a truncated large file behaves the same", () => { + expect(viewMode("big.md", 8 * 1024 * 1024, true, fresh)).toBe("source"); + expect(viewMode("big.md", 8 * 1024 * 1024, true, toggle("big.md", 8 * 1024 * 1024, true, fresh))).toBe("preview"); + }); +}); + +describe("small Markdown files and other files are unchanged", () => { + test("a small file previews by default, toggles to source and back", () => { + expect(viewMode("a.md", 100, false, fresh)).toBe("preview"); + const s1 = toggle("a.md", 100, false, fresh); + expect(viewMode("a.md", 100, false, s1)).toBe("source"); + expect(label("source")).toBe("Preview"); + expect(label("preview")).toBe("View source"); + expect(viewMode("a.md", 100, false, toggle("a.md", 100, false, s1))).toBe("preview"); + }); + + test("exactly 1 MiB still previews", () => { + expect(viewMode("a.md", 1024 * 1024, false, fresh)).toBe("preview"); + }); + + test("a file that grows past the cap while previewed (not forced) drops to source; a forced one stays", () => { + expect(viewMode("a.md", BIG, false, fresh)).toBe("source"); + expect(viewMode("a.md", BIG, false, { asSource: false, forcePreview: true })).toBe("preview"); + }); + + test("a file that shrinks under the cap previews again after having been forced or not", () => { + expect(viewMode("a.md", 10, false, { asSource: false, forcePreview: true })).toBe("preview"); + expect(viewMode("a.md", 10, false, { asSource: true, forcePreview: true })).toBe("source"); + }); + + test("non-Markdown text is always source, whatever the size", () => { + expect(viewMode("a.ts", 10, false, fresh)).toBe("source"); + expect(viewMode("a.md.txt", BIG, false, fresh)).toBe("source"); + }); + + test("toggleMode does not mutate its input", () => { + const s: State = { asSource: false, forcePreview: false }; + toggle("big.md", BIG, false, s); + expect(s).toEqual({ asSource: false, forcePreview: false }); + }); +}); 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 }); + }); +}); diff --git a/test/docs.markdown.test.ts b/test/docs.markdown.test.ts new file mode 100644 index 0000000..227fccd --- /dev/null +++ b/test/docs.markdown.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +const read = (p: string) => Bun.file(new URL(`../${p}`, import.meta.url)).text(); + +describe("SL06 docs", () => { + test("README has a Markdown section with the toggle, caps and image policy", async () => { + const t = await read("README.md"); + expect(t).toMatch(/^#{2,3} .*Markdown/m); + const section = t.slice(t.search(/^#{2,3} .*Markdown/m)); + expect(section).toContain("Toggle Markdown Preview"); + expect(section).toMatch(/1 ?MiB/); + expect(section).toMatch(/Render anyway/); + expect(section).toMatch(/100 images/); + expect(section).toMatch(/remote images/i); + expect(section).toMatch(/raw HTML/i); + }); + + test("SECURITY.md documents the Markdown trust model", async () => { + const t = await read("SECURITY.md"); + expect(t).toMatch(/Markdown/); + expect(t).toMatch(/untrusted/i); + expect(t).toMatch(/innerHTML/); + expect(t).toMatch(/remote images/i); + expect(t).toMatch(/blob/i); + }); +}); + +describe("SL06 wiring pins (editor.ts and app.ts cannot be loaded under the fake DOM)", () => { + test("the editor applies the size gate and passes the link fragment through", async () => { + const t = await read("src/client/editor.ts"); + expect(t).toContain("decidePreview("); + expect(t).toContain("tooLargeNote("); + expect(t).toContain("scrollToFragment("); + expect(t).toMatch(/onOpenLink\s*:\s*\(path: string, hash/); + }); + + test("the app registers the Toggle Markdown Preview command", async () => { + const t = await read("src/client/app.ts"); + expect(t).toContain("markdownToggleCommand("); + }); +}); 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/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-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/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/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.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.fuzz.test.ts b/test/markdown.fuzz.test.ts new file mode 100644 index 0000000..68e5c43 --- /dev/null +++ b/test/markdown.fuzz.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, mockFetch, uninstallDom, 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"); +beforeAll(async () => { + installDom(); + 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()); + +/** Deterministic PRNG so a failure reproduces from its seed. */ +function mulberry32(a: number) { + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const TOKENS = [ + "# ", "## ", "###### ", "*", "**", "_", "__", "`", "``", "```", "```js\n", "~~", "[", "]", "(", ")", "![", "](", "<", ">", + "", "", "