From fe758ad7495dfe890a82500315671281bd71cfbd Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 25 Aug 2026 20:14:01 -0500 Subject: [PATCH 1/3] Split the landing-page demo into Read/Write and Library/CLI views The "Try it" demo showed write operations as bare instruction JSON with the mdpatch command as a footnote, but showed reads as a readTarget() call, so "Append to a section" and "Read a section" spoke different vocabularies. Two toggles now pick the example set (Write / Read) and the front door (Library / CLI), and every example renders in both forms: patch(note, {...}) or readTarget(...) with the return value, versus the mdpatch one-liner with the file-after or stdout. The `within` example, which has no flag form, renders as a runnable `mdpatch apply` heredoc. Adds "Read a frontmatter value" and "Find matching addresses" so the read set matches the write set. Every precomputed result was checked against the engine; a `within` append needs a leading newline to continue the list, which the demo now shows. Co-Authored-By: Claude Fable 5 --- site/index.html | 226 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 171 insertions(+), 55 deletions(-) diff --git a/site/index.html b/site/index.html index cf79941..c4375a2 100644 --- a/site/index.html +++ b/site/index.html @@ -93,6 +93,14 @@ text-transform: uppercase; letter-spacing: 0.08em; } .demo-head .live { color: var(--accent-ink); font-weight: 700; } + .demo-head .toggles { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; } + .toggle { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; } + .toggle button { + font: 700 0.72rem var(--sans); letter-spacing: 0.06em; text-transform: uppercase; + color: var(--ink-soft); background: var(--bg-inset); border: 0; padding: 4px 10px; cursor: pointer; + } + .toggle button + button { border-left: 1px solid var(--line); } + .toggle button[aria-pressed="true"] { background: var(--accent); color: #fff; } .ops { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); } .ops button { font: 600 0.85rem var(--sans); color: var(--ink-soft); background: var(--bg-inset); @@ -116,8 +124,9 @@ .docline.add { background: var(--add-bg); color: var(--add-ink); } .docline.add::before { content: "+ "; font-weight: 700; } .instr { background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; } - .cli-line { color: var(--ink-soft); font-size: 0.78rem; } - .cli-line .dollar { color: var(--ink-faint); } + .instr .dollar { color: var(--ink-faint); user-select: none; } + .instr .fn { color: var(--accent-ink); } + .instr-note { color: var(--ink-faint); font-size: 0.78rem; margin: 0; } .key { color: var(--accent-ink); } section { padding: 48px 0; border-top: 1px solid var(--line); } @@ -194,13 +203,25 @@

Structure-aware edits to Markdown documents.

-
● Try it pick an operation, watch it land
+
+ ● Try it +
+
+ + +
+
+ + +
+
+
-

The instruction

+

The call

-

+

notes.md — after

@@ -327,13 +348,19 @@

3 · Patch

"- Adam", "- Kim", ]; - // Each op: label, instruction (JSON) or instrText (library call), optional CLI - // line, and the resulting output as [lineText, isAdded] pairs (precomputed - // against the engine's documented semantics). + // Each example is shown two ways — as a library call and as an mdpatch + // command — and lives in either the write set or the read set. Results are + // precomputed against the engine's documented semantics. function lines(edit) { return BASE.map((l) => [l, false]).flatMap(edit); } - const OPS = [ + function plain(rows) { + return rows.map((l) => [l, false]); + } + function json(obj) { + return JSON.stringify(obj, null, 2); + } + const WRITE_OPS = [ { label: "Append to a section", instr: { @@ -383,79 +410,168 @@

3 · Patch

target: ["Weekly Sync", "Attendees"], within: -1, operation: "append", - content: "\\n- Priya", + content: "\n- Priya", }, + // No flag form for `within`: the full instruction goes through `apply`. cli: null, result: lines(([l, a]) => l === "- Kim" ? [[l, a], ["- Priya", true]] : [[l, a]] ), }, + ]; + const READ_OPS = [ { label: "Read a section", - instrText: 'readTarget(note, {\n targetType: "heading",\n target: ["Weekly Sync", "Notes"]\n})', - cli: 'mdpatch query heading "Weekly Sync::Notes" notes.md', - resultTitle: "stdout — nothing written", - result: [["Kim walked through the Q3 timeline.", false]], + lib: { + fn: "readTarget", + arg: { targetType: "heading", target: ["Weekly Sync", "Notes"] }, + result: plain(json({ kind: "heading", content: "\nKim walked through the Q3 timeline.\n" }).split("\n")), + }, + cli: { + cmd: 'mdpatch query heading "Weekly Sync::Notes" notes.md', + result: plain(["Kim walked through the Q3 timeline."]), + }, + }, + { + label: "Read a frontmatter value", + lib: { + fn: "readTarget", + arg: { targetType: "frontmatter", target: "status" }, + result: plain(json({ kind: "frontmatter", value: "draft" }).split("\n")), + }, + cli: { + cmd: "mdpatch query frontmatter status notes.md", + result: plain(['"draft"']), + }, }, { label: "Map the document", - instrText: "projectMap(buildModel(note))", - cli: "mdpatch print-map notes.md", - resultTitle: "the addressable map", - result: [ - ["{", false], - [' "version": "2b0a04",', false], - [' "frontmatterFields": [', false], - [' "status"', false], - [" ],", false], - [' "headings": {', false], - [' "Weekly Sync": {', false], - [' "Notes": {},', false], - [' "Attendees": {}', false], - [" }", false], - [" },", false], - [' "blocks": []', false], - ["}", false], - ], + lib: { + text: "projectMap(buildModel(note))", + result: null, // filled below + }, + cli: { + cmd: "mdpatch print-map notes.md", + result: null, + }, + }, + { + label: "Find matching addresses", + lib: { + text: 'headingTreePaths(projectMap(buildModel(note)).headings)\n .filter((path) => /Notes/.test(path.join("::")))', + result: plain(['[["Weekly Sync", "Notes"]]']), + }, + cli: { + cmd: 'mdpatch print-map notes.md "Notes"', + result: plain(["heading\tWeekly Sync::Notes"]), + }, }, ]; + const MAP = plain( + json({ + version: "2b0a04", + frontmatterFields: ["status"], + headings: { "Weekly Sync": { Notes: {}, Attendees: {} } }, + blocks: [], + }).split("\n") + ); + READ_OPS[2].lib.result = MAP; + READ_OPS[2].cli.result = MAP; + const state = { mode: "write", door: "lib", index: 0 }; const opsEl = document.getElementById("ops"); const instrEl = document.getElementById("instr"); - const cliEl = document.getElementById("cli"); + const instrTitleEl = document.getElementById("instr-title"); + const instrNoteEl = document.getElementById("instr-note"); const resultEl = document.getElementById("result"); const resultTitleEl = document.getElementById("result-title"); - function renderInstr(obj) { - const json = JSON.stringify(obj, null, 2); - return json.replace(/"(\w+)":/g, '"$1":'); - } function esc(s) { return s.replace(/&/g, "&").replace(/ - b.setAttribute("aria-pressed", String(i === j)) - ); - instrEl.innerHTML = op.instr ? renderInstr(op.instr) : esc(op.instrText); - resultTitleEl.textContent = op.resultTitle || "notes.md — after"; - cliEl.innerHTML = op.cli - ? '$ ' + esc(op.cli) - : "Full-model instructions run through mdpatch apply."; - resultEl.innerHTML = op.result + function hiJson(obj) { + return esc(json(obj)).replace(/"(\w+)":/g, '"$1":'); + } + function call(fn, arg) { + return '' + fn + "(note, " + hiJson(arg) + ")"; + } + function shell(cmd) { + return '$ ' + esc(cmd); + } + function applyHeredoc(instr) { + return shell("mdpatch apply notes.md - <<'EOF'") + "\n" + hiJson(instr) + "\nEOF"; + } + + function render() { + const ops = state.mode === "write" ? WRITE_OPS : READ_OPS; + const op = ops[state.index]; + const lib = state.door === "lib"; + let instr, note = "", result, resultTitle; + if (state.mode === "write") { + if (lib) { + instr = call("patch", op.instr); + resultTitle = "result.document"; + } else if (op.cli) { + instr = shell(op.cli); + resultTitle = "notes.md — after"; + } else { + instr = applyHeredoc(op.instr); + note = "No flag for within on mdpatch patch — the full instruction goes through apply."; + resultTitle = "notes.md — after"; + } + result = op.result; + } else if (lib) { + instr = op.lib.text ? esc(op.lib.text) : call(op.lib.fn, op.lib.arg); + result = op.lib.result; + resultTitle = "return value — nothing written"; + } else { + instr = shell(op.cli.cmd); + result = op.cli.result; + resultTitle = "stdout — nothing written"; + } + instrTitleEl.textContent = lib ? "The library call" : "The command"; + instrEl.innerHTML = instr; + instrNoteEl.textContent = note; + resultTitleEl.textContent = resultTitle; + resultEl.innerHTML = result .map(([l, add]) => '' + (esc(l) || " ") + "" ) .join(""); } - OPS.forEach((op, i) => { - const b = document.createElement("button"); - b.textContent = op.label; - b.addEventListener("click", () => select(i)); - opsEl.appendChild(b); - }); - select(0); + function renderOps() { + const ops = state.mode === "write" ? WRITE_OPS : READ_OPS; + opsEl.innerHTML = ""; + ops.forEach((op, i) => { + const b = document.createElement("button"); + b.textContent = op.label; + b.setAttribute("aria-pressed", String(i === state.index)); + b.addEventListener("click", () => { + state.index = i; + renderOps(); + render(); + }); + opsEl.appendChild(b); + }); + } + function wireToggle(id, key) { + const buttons = [...document.getElementById(id).querySelectorAll("button")]; + buttons.forEach((b) => + b.addEventListener("click", () => { + const value = b.dataset[key]; + if (state[key] === value) return; + state[key] = value; + if (key === "mode") state.index = 0; + buttons.forEach((x) => x.setAttribute("aria-pressed", String(x === b))); + renderOps(); + render(); + }) + ); + } + wireToggle("mode", "mode"); + wireToggle("door", "door"); + renderOps(); + render(); const fills = document.querySelectorAll(".bar-fill"); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; From dc9f5a419e696318d204e5301e3f2f593d51bdd1 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 26 Aug 2026 06:13:32 -0500 Subject: [PATCH 2/3] Add an interactive playground that runs the engine in the browser The landing page could only show canned examples: every result on it was a precomputed string, so a visitor had to take our word for what the engine does to a document that is not ours. The engine now runs on the page itself. site/playground.ts holds the logic and site/playground.main.ts is the bundle entry; `npm run build:site` bundles them with esbuild, aliasing Node's `crypto` to a pure-JS SHA-256 (site/crypto-shim.ts) so a document's version token is byte-identical to the one `mdpatch print-map` prints. The bundle is generated rather than committed, and the Pages workflow builds it during "Assemble site". The playground pane pairs an editable document with an editable instruction, renders the live document map as clickable addresses, and line-diffs the result so an edit shows what it did. Errors are the engine's own, so an unresolvable address or a stale ifMatch reads exactly as it would in a consumer. Co-Authored-By: Claude Opus 5 --- .github/workflows/docs.yml | 5 + .gitignore | 1 + package-lock.json | 485 +++++++++++++++++++++++++++++++++++++ package.json | 2 + site/bundle.test.ts | 163 +++++++++++++ site/crypto-shim.test.ts | 78 ++++++ site/crypto-shim.ts | 178 ++++++++++++++ site/index.html | 104 ++++++++ site/playground.main.ts | 8 + site/playground.test.ts | 286 ++++++++++++++++++++++ site/playground.ts | 391 ++++++++++++++++++++++++++++++ tsconfig.jest.json | 3 + 12 files changed, 1704 insertions(+) create mode 100644 site/bundle.test.ts create mode 100644 site/crypto-shim.test.ts create mode 100644 site/crypto-shim.ts create mode 100644 site/playground.main.ts create mode 100644 site/playground.test.ts create mode 100644 site/playground.ts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f24ef30..ed82476 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,11 +46,16 @@ jobs: - name: Build API docs # typedoc outputs to './docs/' (see typedoc.json) run: npm run docs + - name: Build the browser bundle + # The landing-page playground runs the real engine client-side; + # the bundle is generated here rather than committed. + run: npm run build:site - name: Assemble site # Landing page at the root, typedoc API docs under /api/ run: | mkdir -p _site/api cp -R site/. _site/ + rm -f _site/*.ts cp -R docs/. _site/api/ - name: Upload artifact uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index ab89b97..34b301b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/* node_modules/* docs/* demo.gif +site/playground.bundle.js diff --git a/package-lock.json b/package-lock.json index 74de601..510326b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@types/jest": "^29.5.12", "@types/node": "^22.4.0", + "esbuild": "^0.28.2", "http-server": "^14.1.1", "jest": "^29.7.0", "markdown-toc": "^1.2.0", @@ -630,6 +631,448 @@ "node": ">=12" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2092,6 +2535,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", diff --git a/package.json b/package.json index ffb7b08..eff7c3d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@types/jest": "^29.5.12", "@types/node": "^22.4.0", + "esbuild": "^0.28.2", "http-server": "^14.1.1", "jest": "^29.7.0", "markdown-toc": "^1.2.0", @@ -34,6 +35,7 @@ }, "scripts": { "build": "tsc", + "build:site": "esbuild site/playground.main.ts --bundle --format=esm --minify --platform=browser --alias:crypto=./site/crypto-shim.ts --outfile=site/playground.bundle.js", "prepack": "rm -rf dist && npm run build", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "docs": "typedoc", diff --git a/site/bundle.test.ts b/site/bundle.test.ts new file mode 100644 index 0000000..08a40bf --- /dev/null +++ b/site/bundle.test.ts @@ -0,0 +1,163 @@ +import { buildSync } from "esbuild"; +import { createHash } from "crypto"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { pathToFileURL } from "url"; + +import type { InstructionInput } from "../src/index.js"; + +/** + * The playground's whole premise is that the browser bundle is the same engine + * the CLI runs — including the `version` token, which is the one thing the + * bundle does *not* get from the library (Node's `crypto` is aliased away at + * bundle time). So bundle the engine with the same alias `npm run build:site` + * uses and check it against Node's own hash and against the unbundled engine. + * + * The shipped artifact's entry is `playground.main.ts`, which mounts against a + * DOM and so can't be imported here; it gets a build-only check at the end. + */ +const OUT_DIR = mkdtempSync(join(tmpdir(), "mdpatch-bundle-")); +const OUT_FILE = join(OUT_DIR, "mdpatch.mjs"); + +const DOCUMENT = [ + "---", + "status: draft", + "---", + "", + "# Weekly Sync", + "", + "## Notes", + "", + "Kim walked through the Q3 timeline.", + "", +].join("\n"); + +type Engine = typeof import("../src/index.js"); + +let bundled: Engine; + +beforeAll(async () => { + buildSync({ + entryPoints: ["src/index.ts"], + bundle: true, + format: "esm", + minify: true, + platform: "browser", + alias: { crypto: "./site/crypto-shim.ts" }, + outfile: OUT_FILE, + }); + // The bundle is browser-targeted, but nothing it touches (TextEncoder, + // DataView) is browser-only, so Node can load it and answer for it here. + bundled = (await import(pathToFileURL(OUT_FILE).href)) as Engine; +}); + +describe("the browser bundle", () => { + it("carries no dependency on Node's crypto", () => { + const outText = buildSync({ + entryPoints: ["src/index.ts"], + bundle: true, + format: "esm", + platform: "browser", + alias: { crypto: "./site/crypto-shim.ts" }, + write: false, + }).outputFiles[0]!.text; + expect(outText).not.toMatch(/require\(["']crypto["']\)/); + expect(outText).not.toMatch(/from\s*["']crypto["']/); + }); + + it.each([ + ["a note with frontmatter", DOCUMENT], + ["an empty document", ""], + ["non-ascii content", "# Ünïcode — 🩹\n\nbody\n"], + ["CRLF line endings", "# Title\r\n\r\nbody\r\n"], + ])("computes the same version token as Node for %s", (_label, document) => { + const expected = createHash("sha256") + .update(document, "utf8") + .digest("hex") + .slice(0, 6); + expect(bundled.buildModel(document).version).toBe(expected); + }); + + it("patches a document the same way the library does", async () => { + const library = await import("../src/index.js"); + const instruction: InstructionInput = { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "Decided: we ship on Thursday.", + }; + expect(bundled.patch(DOCUMENT, instruction).document).toBe( + library.patch(DOCUMENT, instruction).document + ); + }); + + it("projects the same document map, version token included", async () => { + const library = await import("../src/index.js"); + expect(bundled.projectMap(bundled.buildModel(DOCUMENT))).toEqual( + library.projectMap(library.buildModel(DOCUMENT)) + ); + }); + + it("enforces ifMatch against a token Node produced", () => { + const stale = createHash("sha256") + .update("something else", "utf8") + .digest("hex") + .slice(0, 6); + expect(() => + bundled.patch(DOCUMENT, { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "x", + ifMatch: stale, + }) + ).toThrow(bundled.PreconditionFailedError); + }); + + it("accepts an ifMatch token computed outside the bundle", () => { + const live = createHash("sha256") + .update(DOCUMENT, "utf8") + .digest("hex") + .slice(0, 6); + expect(() => + bundled.patch(DOCUMENT, { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "x", + ifMatch: live, + }) + ).not.toThrow(); + }); + + it("builds the artifact the page actually loads", () => { + // `npm run build:site` in one call: if the entry, the alias, or a DOM-only + // import ever breaks, this fails here rather than on the deployed page. + const built = buildSync({ + entryPoints: ["site/playground.main.ts"], + bundle: true, + format: "esm", + minify: true, + platform: "browser", + alias: { crypto: "./site/crypto-shim.ts" }, + write: false, + }); + expect(built.errors).toEqual([]); + expect(built.outputFiles[0]!.text.length).toBeGreaterThan(0); + }); + + it("throws the engine's own error types, so the playground can name them", () => { + expect(() => + bundled.patch(DOCUMENT, { + targetType: "heading", + target: ["Nope"], + operation: "append", + content: "x", + }) + ).toThrow(bundled.TargetNotFoundError); + expect(() => + bundled.patch(DOCUMENT, { targetType: "heading" } as never) + ).toThrow(bundled.InvalidInstructionError); + }); +}); diff --git a/site/crypto-shim.test.ts b/site/crypto-shim.test.ts new file mode 100644 index 0000000..865ed86 --- /dev/null +++ b/site/crypto-shim.test.ts @@ -0,0 +1,78 @@ +import { createHash as nodeCreateHash } from "crypto"; + +import { createHash } from "./crypto-shim.js"; + +/** + * The shim only earns its place if it agrees with Node byte for byte: the + * playground's `version` token has to be the token `mdpatch print-map` prints, + * or an `ifMatch` demonstrated in the browser is a lie. Every case below is + * asserted against Node's own `createHash` rather than a checked-in constant, + * so the oracle is the thing we are standing in for. + */ +const agreesWithNode = (chunks: string[]): void => { + const node = nodeCreateHash("sha256"); + const shim = createHash("sha256"); + for (const chunk of chunks) { + node.update(chunk, "utf8"); + shim.update(chunk, "utf8"); + } + expect(shim.digest("hex")).toBe(node.digest("hex")); +}; + +describe("crypto shim", () => { + it.each([ + ["empty input", [""]], + ["ascii", ["hello world"]], + ["a realistic document", ["---\nstatus: draft\n---\n\n# Weekly Sync\n"]], + ["non-ascii", ["héllo wörld — ünïcode"]], + ["astral plane", ["🩹 patch 𝔘𝔫𝔦𝔠𝔬𝔡𝔢 🎯"]], + ["combining marks", ["égalité"]], + ["lone surrogate replacement", ["ok\ud800end"]], + ["multiple chunks", ["one ", "two ", "three"]], + ["chunks split mid-codepoint boundary", ["a".repeat(70), "b".repeat(3)]], + ])("matches Node's sha256 for %s", (_label, chunks) => { + agreesWithNode(chunks); + }); + + // The padding block is where a hand-written SHA-256 goes wrong: a message + // whose length lands on or just under a 64-byte boundary needs an extra + // block, and off-by-one there is invisible for every other length. + it.each([0, 1, 55, 56, 57, 63, 64, 65, 119, 120, 127, 128, 129, 1000])( + "matches Node's sha256 at a message length of %i bytes", + (length) => { + agreesWithNode(["x".repeat(length)]); + } + ); + + it("is chunk-boundary agnostic", () => { + const whole = createHash("sha256").update("abcdefghij", "utf8"); + const split = createHash("sha256") + .update("abcde", "utf8") + .update("fghij", "utf8"); + expect(split.digest("hex")).toBe(whole.digest("hex")); + }); + + it("produces the 6-character token the engine slices", () => { + const document = "# Title\n\nBody.\n"; + const expected = nodeCreateHash("sha256") + .update(document, "utf8") + .digest("hex") + .slice(0, 6); + expect(createHash("sha256").update(document, "utf8").digest("hex").slice(0, 6)).toBe( + expected + ); + }); + + it("refuses an algorithm it cannot honour", () => { + expect(() => createHash("sha1")).toThrow(/only "sha256"/); + }); + + it("refuses encodings it cannot honour", () => { + expect(() => createHash("sha256").update("x", "latin1" as "utf8")).toThrow( + /only "utf8"/ + ); + expect(() => + createHash("sha256").update("x", "utf8").digest("base64" as "hex") + ).toThrow(/only "hex"/); + }); +}); diff --git a/site/crypto-shim.ts b/site/crypto-shim.ts new file mode 100644 index 0000000..395f724 --- /dev/null +++ b/site/crypto-shim.ts @@ -0,0 +1,178 @@ +/** + * A browser stand-in for the sliver of Node's `crypto` the engine actually + * uses: `createHash("sha256").update(text, "utf8").digest("hex")`, called once + * by `versionOf` in `src/model.ts` to derive a document's `version` token. + * + * The library is deliberately left alone — `esbuild --alias:crypto=` points at + * this file when bundling for the browser, so a document's version token is + * byte-identical whether it was computed by Node or by the playground. That + * matters: the token the playground shows is exactly the one `mdpatch + * print-map` would print for the same document, so an `ifMatch` demonstrated + * here is a real one. + * + * This is a real SHA-256, not a stand-in hash, for the same reason. + */ + +/** SHA-256 round constants: the cube roots of the first 64 primes. */ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** The square roots of the first 8 primes: SHA-256's initial state. */ +const INITIAL_STATE = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, +] as const; + +const rotr = (value: number, bits: number): number => + (value >>> bits) | (value << (32 - bits)); + +const sha256 = (bytes: Uint8Array): Uint8Array => { + // Pad to a multiple of 64 bytes: a 0x80 terminator, zeroes, then the message + // length in bits as a big-endian 64-bit integer. + const bitLength = bytes.length * 8; + const paddedLength = (((bytes.length + 8) >> 6) + 1) << 6; + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + // A JS number holds the low 53 bits exactly, which is far more message than + // any document here; the high word is written from the float remainder. + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000)); + view.setUint32(paddedLength - 4, bitLength >>> 0); + + const state = Int32Array.from(INITIAL_STATE); + const schedule = new Uint32Array(64); + + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let i = 0; i < 16; i += 1) { + schedule[i] = view.getUint32(offset + i * 4); + } + for (let i = 16; i < 64; i += 1) { + const previous = schedule[i - 15]!; + const recent = schedule[i - 2]!; + const s0 = rotr(previous, 7) ^ rotr(previous, 18) ^ (previous >>> 3); + const s1 = rotr(recent, 17) ^ rotr(recent, 19) ^ (recent >>> 10); + schedule[i] = (schedule[i - 16]! + s0 + schedule[i - 7]! + s1) >>> 0; + } + + let a = state[0]!; + let b = state[1]!; + let c = state[2]!; + let d = state[3]!; + let e = state[4]!; + let f = state[5]!; + let g = state[6]!; + let h = state[7]!; + + for (let i = 0; i < 64; i += 1) { + const S1 = rotr(e >>> 0, 6) ^ rotr(e >>> 0, 11) ^ rotr(e >>> 0, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + K[i]! + schedule[i]!) | 0; + const S0 = rotr(a >>> 0, 2) ^ rotr(a >>> 0, 13) ^ rotr(a >>> 0, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) | 0; + + h = g; + g = f; + f = e; + e = (d + temp1) | 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) | 0; + } + + state[0] = (state[0]! + a) | 0; + state[1] = (state[1]! + b) | 0; + state[2] = (state[2]! + c) | 0; + state[3] = (state[3]! + d) | 0; + state[4] = (state[4]! + e) | 0; + state[5] = (state[5]! + f) | 0; + state[6] = (state[6]! + g) | 0; + state[7] = (state[7]! + h) | 0; + } + + const digest = new Uint8Array(32); + const digestView = new DataView(digest.buffer); + for (let i = 0; i < 8; i += 1) { + digestView.setUint32(i * 4, state[i]! >>> 0); + } + return digest; +}; + +const toHex = (bytes: Uint8Array): string => { + let hex = ""; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +}; + +/** The only encodings this shim is asked for; anything else is a caller bug. */ +export type ShimEncoding = "utf8"; + +/** + * The subset of Node's `Hash` the engine uses. `update` accumulates and + * `digest` finalizes, so multi-chunk callers behave the same as Node's. + */ +export interface Hash { + update(data: string, encoding?: ShimEncoding): Hash; + digest(encoding: "hex"): string; +} + +const encoder = new TextEncoder(); + +/** + * Node's `crypto.createHash`, narrowed to `"sha256"`. Any other algorithm + * throws rather than silently hashing with the wrong one — if the library ever + * reaches for a second algorithm, the browser bundle should fail loudly here + * instead of producing tokens that disagree with Node's. + */ +export const createHash = (algorithm: string): Hash => { + if (algorithm !== "sha256") { + throw new Error( + `crypto shim supports only "sha256"; got ${JSON.stringify(algorithm)}` + ); + } + const chunks: Uint8Array[] = []; + const hash: Hash = { + update(data: string, encoding: ShimEncoding = "utf8"): Hash { + if (encoding !== "utf8") { + throw new Error( + `crypto shim supports only "utf8" input; got ${JSON.stringify(encoding)}` + ); + } + chunks.push(encoder.encode(data)); + return hash; + }, + digest(encoding: "hex"): string { + if (encoding !== "hex") { + throw new Error( + `crypto shim supports only "hex" output; got ${JSON.stringify(encoding)}` + ); + } + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const message = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + message.set(chunk, at); + at += chunk.length; + } + return toHex(sha256(message)); + }, + }; + return hash; +}; + +export default { createHash }; diff --git a/site/index.html b/site/index.html index c4375a2..4e8013d 100644 --- a/site/index.html +++ b/site/index.html @@ -22,6 +22,8 @@ --accent-ink: #0f5c35; --add-bg: #e2f4e8; --add-ink: #14602f; + --del-bg: #fbeaea; + --del-ink: #8a2020; --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", sans-serif; } @@ -38,6 +40,8 @@ --accent-ink: #5ed99a; --add-bg: #143423; --add-ink: #7fe0ab; + --del-bg: #3a1c1c; + --del-ink: #f0a3a3; } } * { box-sizing: border-box; } @@ -123,12 +127,59 @@ .docline { display: block; padding: 0 6px; border-radius: 3px; white-space: pre; } .docline.add { background: var(--add-bg); color: var(--add-ink); } .docline.add::before { content: "+ "; font-weight: 700; } + .docline.del { background: var(--del-bg); color: var(--del-ink); text-decoration: line-through; } + .docline.del::before { content: "- "; font-weight: 700; text-decoration: none; } .instr { background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; } .instr .dollar { color: var(--ink-faint); user-select: none; } .instr .fn { color: var(--accent-ink); } .instr-note { color: var(--ink-faint); font-size: 0.78rem; margin: 0; } .key { color: var(--accent-ink); } + .pg { + border: 1px solid var(--line); border-radius: 12px; background: var(--bg-raised); + overflow: hidden; box-shadow: 0 1px 3px rgb(0 0 0 / 0.05); + } + .pg-head { + display: flex; align-items: center; gap: 10px; padding: 10px 14px; flex-wrap: wrap; + border-bottom: 1px solid var(--line); font-size: 0.8rem; color: var(--ink-faint); + text-transform: uppercase; letter-spacing: 0.08em; + } + .pg-head .live { color: var(--accent-ink); font-weight: 700; } + .pg-head .toggles { margin-left: auto; display: flex; gap: 8px; } + .pg-cols { display: grid; grid-template-columns: 1fr 1fr; } + @media (max-width: 860px) { .pg-cols { grid-template-columns: 1fr; } } + .pg-col { min-width: 0; } + .pg-col + .pg-col { border-left: 1px solid var(--line); } + @media (max-width: 860px) { .pg-col + .pg-col { border-left: 0; border-top: 1px solid var(--line); } } + .pg-block { padding: 14px 16px; } + .pg-block + .pg-block { border-top: 1px solid var(--line); } + .pg-block h3 { + margin: 0 0 10px; font-size: 0.72rem; text-transform: uppercase; + letter-spacing: 0.1em; color: var(--ink-faint); font-weight: 700; + display: flex; align-items: baseline; gap: 8px; + } + .pg-block h3 .hint { text-transform: none; letter-spacing: 0; font-weight: 400; font-size: 0.72rem; } + .pg-input { + display: block; width: 100%; font-family: var(--mono); font-size: 0.82rem; line-height: 1.5; + color: var(--ink); background: var(--bg-inset); border: 1px solid var(--line); + border-radius: 8px; padding: 10px 12px; resize: vertical; tab-size: 2; + } + .pg-map { display: flex; flex-wrap: wrap; gap: 6px; } + .pg-map .group { flex-basis: 100%; font-family: var(--mono); font-size: 0.72rem; color: var(--ink-faint); margin-top: 4px; } + .pg-map .group:first-child { margin-top: 0; } + .addr { + font: 500 0.78rem var(--mono); color: var(--ink-soft); background: var(--bg-inset); + border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; cursor: pointer; + } + .addr:hover { border-color: var(--accent); color: var(--accent-ink); } + .addr.token { color: var(--accent-ink); } + .pg-status { font-family: var(--mono); font-size: 0.78rem; margin: 10px 0 0; color: var(--ink-faint); } + .pg-status.err { color: var(--del-ink); } + .pg-status .name { font-weight: 700; } + .pg-warn { font-size: 0.78rem; color: var(--ink-faint); margin: 8px 0 0; } + .pg-note { font-size: 0.82rem; color: var(--ink-faint); margin: 14px 0 0; max-width: 78ch; } + .pg-note code { font-family: var(--mono); font-size: 0.85em; background: var(--bg-inset); padding: 1px 5px; border-radius: 4px; } + section { padding: 48px 0; border-top: 1px solid var(--line); } section h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 8px; text-wrap: balance; } section > .wrap > p.sub { color: var(--ink-soft); margin: 0 0 28px; max-width: 62ch; } @@ -182,6 +233,7 @@
+
+
+

Now run it on your own document.

+

Everything above is a canned example. This is the actual npm package, bundled for the browser and running on this page — paste your own Markdown, write your own instruction, and watch the engine work. Nothing is uploaded; there is no server.

+ +
+
+ ● Live + markdown-patch, running in your browser +
+
+ + +
+
+
+
+
+
+

+ +
+
+

Document map — what print-map would return. Click an address to target it.

+
+
+
+
+
+

+ +

+
+
+

result.document

+

+            
+          
+
+
+
+

The playground bundle isn't built in this checkout — npm run build:site produces it, and the Pages workflow builds it on every deploy.

+

Errors are the engine's own — InvalidInstructionError, TargetNotFoundError, PreconditionFailedError — thrown by the same code an npm install gets you. Click the version token to send it as ifMatch, then edit the document out from under the instruction to watch a stale write fail.

+
+
+

The failure modes you already know, closed off.

@@ -584,5 +682,11 @@

3 · Patch

obs.observe(document.querySelector(".tok-panel")); } + + + diff --git a/site/playground.main.ts b/site/playground.main.ts new file mode 100644 index 0000000..7c4d592 --- /dev/null +++ b/site/playground.main.ts @@ -0,0 +1,8 @@ +/** + * The browser bundle's entry point: everything the playground does lives in + * `playground.ts`, which stays free of side effects so its logic can be tested + * without a DOM. This file is the one line that isn't testable that way. + */ +import { mount } from "./playground.js"; + +mount(); diff --git a/site/playground.test.ts b/site/playground.test.ts new file mode 100644 index 0000000..1ca3164 --- /dev/null +++ b/site/playground.test.ts @@ -0,0 +1,286 @@ +/** + * The playground's logic, tested away from the DOM. `playground.ts` holds no + * top-level side effects — `playground.main.ts` is what calls `mount()` in the + * bundle — so everything below runs in plain Node. + */ +import type { DiffRow } from "./playground.js"; +import * as playground from "./playground.js"; + +const texts = (rows: DiffRow[]): string[] => rows.map(([text]) => text); +const kinds = (rows: DiffRow[]): string[] => rows.map(([, kind]) => kind); + +describe("diffLines", () => { + it("marks nothing when the document is unchanged", () => { + const rows = playground.diffLines("a\nb\nc", "a\nb\nc"); + expect(kinds(rows)).toEqual(["", "", ""]); + }); + + it("marks an inserted line as added, in place", () => { + const rows = playground.diffLines("a\nb", "a\nnew\nb"); + expect(texts(rows)).toEqual(["a", "new", "b"]); + expect(kinds(rows)).toEqual(["", "add", ""]); + }); + + it("marks a removed line as deleted", () => { + const rows = playground.diffLines("a\ngone\nb", "a\nb"); + expect(texts(rows)).toEqual(["a", "gone", "b"]); + expect(kinds(rows)).toEqual(["", "del", ""]); + }); + + it("shows a replaced line as a deletion beside an addition", () => { + const rows = playground.diffLines("# Attendees", "# People"); + expect(rows).toEqual([ + ["# Attendees", "del"], + ["# People", "add"], + ]); + }); + + it("keeps every line of the new document, in order", () => { + const after = "---\nstatus: final\n---\n\n# Title\n\nbody\n"; + const rows = playground.diffLines("# Title\n", after); + expect(texts(rows.filter(([, kind]) => kind !== "del"))).toEqual( + after.split("\n") + ); + }); + + it("does not choke on an empty document on either side", () => { + expect(kinds(playground.diffLines("", "a"))).toEqual(["del", "add"]); + expect(texts(playground.diffLines("a", ""))).toEqual(["a", ""]); + }); + + it("falls back to the new document when the diff would be too large", () => { + const before = "x\n".repeat(700); + const after = "y\n".repeat(700); + const rows = playground.diffLines(before, after); + expect(kinds(rows).every((kind) => kind === "")).toBe(true); + expect(rows).toHaveLength(after.split("\n").length); + }); +}); + +describe("mapChips", () => { + const DOCUMENT = [ + "---", + "status: draft", + "owner: adam", + "---", + "", + "# Weekly Sync", + "", + "## Notes", + "", + "A line. ^note-1", + "", + ].join("\n"); + + it("offers the version token as an ifMatch chip", () => { + const { chips } = playground.mapChips(DOCUMENT); + expect(chips![0]!.group).toBe("version"); + expect(chips![0]!.fields).toEqual({ ifMatch: chips![0]!.label }); + expect(chips![0]!.label).toMatch(/^[0-9a-f]{6}$/); + }); + + it("offers every heading path, joined the way print-map shows them", () => { + const { chips } = playground.mapChips(DOCUMENT); + const headings = chips!.filter((chip) => chip.group === "headings"); + expect(headings.map((chip) => chip.label)).toEqual([ + "Weekly Sync", + "Weekly Sync::Notes", + ]); + expect(headings[1]!.fields).toEqual({ + targetType: "heading", + target: ["Weekly Sync", "Notes"], + }); + }); + + it("offers frontmatter fields and block ids", () => { + const { chips } = playground.mapChips(DOCUMENT); + expect( + chips!.filter((chip) => chip.group === "frontmatter").map((c) => c.fields) + ).toEqual([ + { targetType: "frontmatter", target: "status" }, + { targetType: "frontmatter", target: "owner" }, + ]); + const blocks = chips!.filter((chip) => chip.group === "blocks"); + expect(blocks).toEqual([ + { + group: "blocks", + label: "^note-1", + fields: { targetType: "block", target: "note-1" }, + }, + ]); + }); + + it("still produces a version chip for an empty document", () => { + const { chips, error } = playground.mapChips(""); + expect(error).toBeNull(); + expect(chips!.map((chip) => chip.group)).toEqual(["version"]); + }); + + it("reports a document it cannot model instead of throwing", () => { + const { chips, error } = playground.mapChips("---\n: : :\nnope\n---\n"); + // Either outcome is legitimate — some malformed frontmatter models fine — + // but neither may escape as an exception to the keystroke handler. + expect(chips === null ? typeof error : error).toBeTruthy(); + }); +}); + +describe("foldAddress", () => { + const FALLBACK = { targetType: "heading", target: ["A"] }; + + it("keeps the fields the visitor already wrote", () => { + const folded = playground.foldAddress( + JSON.stringify({ operation: "append", content: "hi", target: ["Old"] }), + { targetType: "heading", target: ["New", "Path"] }, + FALLBACK + ); + expect(JSON.parse(folded)).toEqual({ + operation: "append", + content: "hi", + targetType: "heading", + target: ["New", "Path"], + }); + }); + + it("replaces the whole target when the address changes type", () => { + const folded = playground.foldAddress( + JSON.stringify({ targetType: "heading", target: ["A", "B"] }), + { targetType: "block", target: "note-1" }, + FALLBACK + ); + expect(JSON.parse(folded)).toEqual({ + targetType: "block", + target: "note-1", + }); + }); + + it("adds ifMatch without disturbing the address", () => { + const folded = playground.foldAddress( + JSON.stringify({ targetType: "heading", target: ["A"], operation: "append" }), + { ifMatch: "abc123" }, + FALLBACK + ); + expect(JSON.parse(folded)).toEqual({ + targetType: "heading", + target: ["A"], + operation: "append", + ifMatch: "abc123", + }); + }); + + it("recovers from unparseable text by starting from the fallback", () => { + const folded = playground.foldAddress("{not json", { ifMatch: "abc123" }, FALLBACK); + expect(JSON.parse(folded)).toEqual({ ...FALLBACK, ifMatch: "abc123" }); + }); + + it("refuses to merge into a non-object", () => { + const folded = playground.foldAddress("[1, 2]", { ifMatch: "abc123" }, FALLBACK); + expect(JSON.parse(folded)).toEqual({ ...FALLBACK, ifMatch: "abc123" }); + }); + + it("pretty-prints, since the result goes straight back into the editor", () => { + expect(playground.foldAddress("{}", { ifMatch: "abc123" }, FALLBACK)).toBe( + '{\n "ifMatch": "abc123"\n}' + ); + }); +}); + +describe("runInstruction", () => { + const DOCUMENT = "---\nstatus: draft\n---\n\n# Title\n\nbody\n"; + + it("patches and reports the diff", () => { + const outcome = playground.runInstruction( + DOCUMENT, + JSON.stringify({ + targetType: "heading", + target: ["Title"], + operation: "append", + content: "more", + }), + "patch" + ); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") return; + expect(texts(outcome.rows)).toContain("more"); + expect(outcome.rows.find(([text]) => text === "more")![1]).toBe("add"); + expect(outcome.warnings).toEqual([]); + }); + + it("reads without writing", () => { + const outcome = playground.runInstruction( + DOCUMENT, + JSON.stringify({ targetType: "frontmatter", target: "status" }), + "read" + ); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") return; + expect(outcome.rows.map(([text]) => text).join("\n")).toContain('"draft"'); + expect(outcome.status).toContain('scope "content"'); + }); + + it("names malformed JSON as a syntax error, not an engine error", () => { + const outcome = playground.runInstruction(DOCUMENT, "{oops", "patch"); + expect(outcome).toMatchObject({ kind: "error", name: "SyntaxError" }); + }); + + it("surfaces the engine's own error for an unresolvable address", () => { + const outcome = playground.runInstruction( + DOCUMENT, + JSON.stringify({ + targetType: "heading", + target: ["Nope"], + operation: "append", + content: "x", + }), + "patch" + ); + expect(outcome).toMatchObject({ + kind: "error", + name: "TargetNotFoundError", + }); + }); + + it("surfaces the engine's own error for a malformed instruction", () => { + const outcome = playground.runInstruction( + DOCUMENT, + JSON.stringify({ targetType: "heading" }), + "patch" + ); + expect(outcome).toMatchObject({ + kind: "error", + name: "InvalidInstructionError", + }); + }); + + it("fails a stale ifMatch, which is the whole point of showing the token", () => { + const { chips } = playground.mapChips(DOCUMENT); + const version = chips![0]!.label; + const live = playground.runInstruction( + DOCUMENT, + JSON.stringify({ + targetType: "heading", + target: ["Title"], + operation: "append", + content: "x", + ifMatch: version, + }), + "patch" + ); + expect(live.kind).toBe("ok"); + + const stale = playground.runInstruction( + `${DOCUMENT}edited\n`, + JSON.stringify({ + targetType: "heading", + target: ["Title"], + operation: "append", + content: "x", + ifMatch: version, + }), + "patch" + ); + expect(stale).toMatchObject({ + kind: "error", + name: "PreconditionFailedError", + }); + }); +}); diff --git a/site/playground.ts b/site/playground.ts new file mode 100644 index 0000000..2a450b2 --- /dev/null +++ b/site/playground.ts @@ -0,0 +1,391 @@ +/// + +/** + * The landing page's playground: the real engine, running on whatever the + * visitor types. `npm run build:site` bundles this file — engine included, + * with Node's `crypto` aliased to `./crypto-shim.ts` — into `site/playground.js`, + * which `index.html` loads as a module. + * + * The logic worth being sure about (the diff, folding a clicked address into + * the instruction, projecting the map into chips) is exported and unit-tested + * in `playground.test.ts`; `mount` is the thin DOM wiring over it. + */ + +import { + buildModel, + headingTreePaths, + patch, + projectMap, + readTarget, +} from "../src/index.js"; +import type { InstructionInput, ReadTarget } from "../src/index.js"; + +export type DiffKind = "" | "add" | "del"; +/** One rendered line: its text, and whether the patch added or removed it. */ +export type DiffRow = [text: string, kind: DiffKind]; + +/** The instruction editor holds whatever JSON the visitor typed, so nothing + * downstream of it can assume a shape until the engine validates it. */ +export type JsonObject = Record; + +/** Above this many line-pairs the quadratic table stops being free; a document + * that large is past the point where a line diff tells the visitor anything. */ +const DIFF_CELL_LIMIT = 400_000; + +/** + * A line-level LCS diff, so the result pane shows what the instruction *did* + * rather than only the document it produced. Playground documents are small, + * which is cheaper than carrying a diff dependency into the bundle. + */ +export const diffLines = (before: string, after: string): DiffRow[] => { + const a = before.split("\n"); + const b = after.split("\n"); + if (a.length * b.length > DIFF_CELL_LIMIT) { + return b.map((line): DiffRow => [line, ""]); + } + + // lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:]. + const lcs: Uint32Array[] = []; + for (let i = 0; i <= a.length; i += 1) lcs.push(new Uint32Array(b.length + 1)); + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + lcs[i]![j] = + a[i] === b[j] + ? lcs[i + 1]![j + 1]! + 1 + : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + } + } + + const rows: DiffRow[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + rows.push([a[i]!, ""]); + i += 1; + j += 1; + } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { + rows.push([a[i]!, "del"]); + i += 1; + } else { + rows.push([b[j]!, "add"]); + j += 1; + } + } + while (i < a.length) { + rows.push([a[i]!, "del"]); + i += 1; + } + while (j < b.length) { + rows.push([b[j]!, "add"]); + j += 1; + } + return rows; +}; + +export type ChipGroup = "version" | "headings" | "frontmatter" | "blocks"; + +/** One clickable address from the document map, and what clicking it sets. */ +export interface Chip { + group: ChipGroup; + label: string; + fields: JsonObject; +} + +/** + * Project the document map into the chips the map pane renders. A document the + * engine cannot model isn't an error state for the page — the visitor is + * mid-keystroke — so it comes back as a message rather than a throw. + */ +export const mapChips = ( + document: string +): { chips: Chip[]; error: null } | { chips: null; error: string } => { + let map; + try { + map = projectMap(buildModel(document)); + } catch (error) { + return { chips: null, error: messageOf(error) }; + } + + const chips: Chip[] = [ + { group: "version", label: map.version, fields: { ifMatch: map.version } }, + ]; + for (const path of headingTreePaths(map.headings)) { + chips.push({ + group: "headings", + label: path.join("::"), + fields: { targetType: "heading", target: path }, + }); + } + for (const field of map.frontmatterFields) { + chips.push({ + group: "frontmatter", + label: field, + fields: { targetType: "frontmatter", target: field }, + }); + } + for (const id of map.blocks) { + chips.push({ + group: "blocks", + label: `^${id}`, + fields: { targetType: "block", target: id }, + }); + } + return { chips, error: null }; +}; + +const asObject = (value: unknown): JsonObject | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : null; + +/** + * Fold a clicked address into the instruction already in the editor, keeping + * the visitor's other fields. Text that doesn't parse (or isn't an object) is + * replaced by `fallback` rather than silently discarded to `{}` — a click + * should always leave a runnable instruction behind. + */ +export const foldAddress = ( + instructionText: string, + fields: JsonObject, + fallback: JsonObject +): string => { + let current: JsonObject | null = null; + try { + current = asObject(JSON.parse(instructionText)); + } catch (error) { + current = null; + } + return JSON.stringify({ ...(current ?? fallback), ...fields }, null, 2); +}; + +const messageOf = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const nameOf = (error: unknown): string => + error instanceof Error ? error.name : "Error"; + +export type RunOutcome = + | { kind: "ok"; status: string; rows: DiffRow[]; warnings: string[] } + | { kind: "error"; name: string; message: string }; + +/** Run the engine over the panes' current contents. Every failure mode — + * malformed JSON, an unresolvable address, a stale `ifMatch` — comes back as + * the engine's own error, which is the point of running the real thing. */ +export const runInstruction = ( + document: string, + instructionText: string, + mode: "patch" | "read" +): RunOutcome => { + let instruction: unknown; + try { + instruction = JSON.parse(instructionText); + } catch (error) { + return { + kind: "error", + name: "SyntaxError", + message: `the instruction isn't valid JSON — ${messageOf(error)}`, + }; + } + + try { + if (mode === "read") { + const target = instruction as ReadTarget; + const result = readTarget(document, target); + return { + kind: "ok", + status: `ok — read at scope ${JSON.stringify(target.scope ?? "content")}`, + rows: JSON.stringify(result, null, 2) + .split("\n") + .map((line): DiffRow => [line, ""]), + warnings: [], + }; + } + const result = patch(document, instruction as InstructionInput); + return { + kind: "ok", + status: `ok — ${result.document.length} characters written`, + rows: diffLines(document, result.document), + warnings: result.warnings.map((w) => `${w.code} — ${w.message}`), + }; + } catch (error) { + return { kind: "error", name: nameOf(error), message: messageOf(error) }; + } +}; + +// --- DOM wiring ---------------------------------------------------------- + +const DEMO_DOCUMENT = [ + "---", + "status: draft", + "---", + "", + "# Weekly Sync", + "", + "## Notes", + "", + "Kim walked through the Q3 timeline.", + "", + "## Attendees", + "", + "- Adam", + "- Kim", + "", +].join("\n"); + +const DEMO_INSTRUCTIONS: Record<"patch" | "read", JsonObject> = { + patch: { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "Decided: we ship on Thursday.", + }, + read: { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + scope: "content", + }, +}; + +const GROUP_LABELS: Record = { + version: "version", + headings: "headings", + frontmatter: "frontmatter", + blocks: "blocks", +}; + +const need = (id: string): T => { + const element = window.document.getElementById(id); + if (!element) throw new Error(`playground: #${id} is missing from the page`); + return element as unknown as T; +}; + +/** Wire the playground to the page. `playground.main.ts` is the bundle's entry + * point and calls this; keeping the side effect out of this module is what + * lets the logic above be tested without a DOM. */ +export const mount = (): void => { + const docEl = need("pg-doc"); + const instrEl = need("pg-instr"); + const mapEl = need("pg-map"); + const statusEl = need("pg-status"); + const resultEl = need("pg-result"); + const resultTitleEl = need("pg-result-title"); + const warnEl = need("pg-warn"); + const modeEl = need("pg-mode"); + const unbuiltEl = window.document.getElementById("pg-unbuilt"); + + let mode: "patch" | "read" = "patch"; + + const renderRows = (rows: DiffRow[]): void => { + resultEl.replaceChildren( + ...rows.map(([text, kind]) => { + const line = window.document.createElement("span"); + line.className = kind ? `docline ${kind}` : "docline"; + // A blank line still needs to occupy one, so give it a space to hold. + line.textContent = text === "" ? " " : text; + return line; + }) + ); + }; + + const renderMap = (): void => { + const { chips, error } = mapChips(docEl.value); + mapEl.replaceChildren(); + if (!chips) { + const message = window.document.createElement("span"); + message.className = "group"; + message.textContent = `the document could not be modelled: ${error}`; + mapEl.append(message); + return; + } + let lastGroup: ChipGroup | null = null; + for (const chip of chips) { + if (chip.group !== lastGroup) { + const label = window.document.createElement("span"); + label.className = "group"; + label.textContent = GROUP_LABELS[chip.group]; + mapEl.append(label); + lastGroup = chip.group; + } + const button = window.document.createElement("button"); + button.type = "button"; + button.className = chip.group === "version" ? "addr token" : "addr"; + button.textContent = chip.label; + button.title = + chip.group === "version" + ? "send this version as ifMatch" + : "target this address"; + button.addEventListener("click", () => { + instrEl.value = foldAddress( + instrEl.value, + chip.fields, + DEMO_INSTRUCTIONS[mode] + ); + run(); + }); + mapEl.append(button); + } + }; + + const run = (): void => { + renderMap(); + resultTitleEl.textContent = + mode === "read" ? "readTarget(…) — nothing written" : "result.document"; + const outcome = runInstruction(docEl.value, instrEl.value, mode); + if (outcome.kind === "error") { + statusEl.className = "pg-status err"; + statusEl.replaceChildren(); + const name = window.document.createElement("span"); + name.className = "name"; + name.textContent = outcome.name; + statusEl.append(name, ` ${outcome.message}`); + warnEl.hidden = true; + renderRows([["—", ""]]); + return; + } + statusEl.className = "pg-status"; + statusEl.textContent = outcome.status; + renderRows(outcome.rows); + warnEl.hidden = outcome.warnings.length === 0; + warnEl.textContent = `warnings: ${outcome.warnings.join("; ")}`; + }; + + let pending = 0; + const schedule = (): void => { + window.clearTimeout(pending); + pending = window.setTimeout(run, 120); + }; + docEl.addEventListener("input", schedule); + instrEl.addEventListener("input", schedule); + + const modeButtons = Array.from(modeEl.querySelectorAll("button")); + for (const button of modeButtons) { + button.addEventListener("click", () => { + const next = button.dataset.pgmode === "read" ? "read" : "patch"; + if (next === mode) return; + // Carry the visitor's edits back to the mode they were made in, so + // toggling to compare a read against a patch loses nothing. + try { + const edited = asObject(JSON.parse(instrEl.value)); + if (edited) DEMO_INSTRUCTIONS[mode] = edited; + } catch (error) { + // Unparseable text isn't worth preserving; the demo instruction returns. + } + mode = next; + for (const other of modeButtons) { + other.setAttribute("aria-pressed", String(other === button)); + } + instrEl.value = JSON.stringify(DEMO_INSTRUCTIONS[mode], null, 2); + run(); + }); + } + + docEl.value = DEMO_DOCUMENT; + instrEl.value = JSON.stringify(DEMO_INSTRUCTIONS.patch, null, 2); + docEl.disabled = false; + instrEl.disabled = false; + // The page ships with a "bundle isn't built" note so a plain checkout of + // site/ still explains itself; reaching here means it is built. + unbuiltEl?.remove(); + run(); +}; diff --git a/tsconfig.jest.json b/tsconfig.jest.json index 85ffffb..c861f7a 100644 --- a/tsconfig.jest.json +++ b/tsconfig.jest.json @@ -3,6 +3,9 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "node", + /* Tests are never emitted, and site/crypto-shim.ts (bundled, not compiled + by `tsc`) has its own suite, so the test compiler's root is the repo. */ + "rootDir": ".", "types": ["jest", "node"] } } From 8ddbedd5dcb0133a248db407e85109490c9b12ad Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 26 Aug 2026 06:40:18 -0500 Subject: [PATCH 3/3] Refine the landing-page demo and playground after first review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The playground's read mode shows the targeted content itself (the value JSON-encoded for frontmatter) instead of readTarget's { kind, content } envelope — the same thing `mdpatch query` prints. - The canned demo's "Library" door is now "JSON": the left pane shows only the instruction object, with the function to hand it to noted beneath. The "Try it" badge moves from the canned demo to the live playground. - Long CLI commands wrap inside their pane instead of scrolling. - Document-map heading chips are labelled "A › B" rather than the CLI's "A::B" spelling, and the hint no longer claims they are print-map output. - An Options row under the instruction lists every targetType, operation, and scope the engine accepts; clicking one folds it into the instruction and the current value is highlighted. Co-Authored-By: Claude Fable 5 --- site/index.html | 63 ++++++++++++++++--------- site/playground.test.ts | 51 ++++++++++++++++++-- site/playground.ts | 100 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 31 deletions(-) diff --git a/site/index.html b/site/index.html index 4e8013d..cb74302 100644 --- a/site/index.html +++ b/site/index.html @@ -130,8 +130,10 @@ .docline.del { background: var(--del-bg); color: var(--del-ink); text-decoration: line-through; } .docline.del::before { content: "- "; font-weight: 700; text-decoration: none; } .instr { background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; } + .instr pre { white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; } + .instr-note code { font-family: var(--mono); font-size: 0.92em; } .instr .dollar { color: var(--ink-faint); user-select: none; } - .instr .fn { color: var(--accent-ink); } + .instr .fn, .instr-note .fn { color: var(--accent-ink); } .instr-note { color: var(--ink-faint); font-size: 0.78rem; margin: 0; } .key { color: var(--accent-ink); } @@ -173,6 +175,7 @@ } .addr:hover { border-color: var(--accent); color: var(--accent-ink); } .addr.token { color: var(--accent-ink); } + .addr[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; } .pg-status { font-family: var(--mono); font-size: 0.78rem; margin: 10px 0 0; color: var(--ink-faint); } .pg-status.err { color: var(--del-ink); } .pg-status .name { font-weight: 700; } @@ -256,14 +259,13 @@

Structure-aware edits to Markdown documents.

- ● Try it
-
- +
+
@@ -271,7 +273,7 @@

Structure-aware edits to Markdown documents.

-

The call

+

The instruction

@@ -291,7 +293,7 @@

Now run it on your own document.

- ● Live + ● Try it markdown-patch, running in your browser
@@ -307,7 +309,7 @@

-

Document map — what print-map would return. Click an address to target it.

+

Document map — the addresses projectMap / print-map give you. Click one to target it.

@@ -317,6 +319,10 @@

+
+

Options — every value the engine accepts for these fields. Click one to set it.

+
+

result.document


@@ -446,9 +452,10 @@ 

3 · Patch

"- Adam", "- Kim", ]; - // Each example is shown two ways — as a library call and as an mdpatch - // command — and lives in either the write set or the read set. Results are - // precomputed against the engine's documented semantics. + // Each example is shown two ways — as the JSON instruction the library + // takes and as an mdpatch command — and lives in either the write set or + // the read set. Results are precomputed against the engine's documented + // semantics. function lines(edit) { return BASE.map((l) => [l, false]).flatMap(edit); } @@ -523,7 +530,8 @@

3 · Patch

lib: { fn: "readTarget", arg: { targetType: "heading", target: ["Weekly Sync", "Notes"] }, - result: plain(json({ kind: "heading", content: "\nKim walked through the Q3 timeline.\n" }).split("\n")), + result: plain(["Kim walked through the Q3 timeline."]), + title: "result.content", }, cli: { cmd: 'mdpatch query heading "Weekly Sync::Notes" notes.md', @@ -535,7 +543,8 @@

3 · Patch

lib: { fn: "readTarget", arg: { targetType: "frontmatter", target: "status" }, - result: plain(json({ kind: "frontmatter", value: "draft" }).split("\n")), + result: plain(['"draft"']), + title: "result.value", }, cli: { cmd: "mdpatch query frontmatter status notes.md", @@ -576,7 +585,7 @@

3 · Patch

READ_OPS[2].lib.result = MAP; READ_OPS[2].cli.result = MAP; - const state = { mode: "write", door: "lib", index: 0 }; + const state = { mode: "write", door: "json", index: 0 }; const opsEl = document.getElementById("ops"); const instrEl = document.getElementById("instr"); const instrTitleEl = document.getElementById("instr-title"); @@ -590,8 +599,8 @@

3 · Patch

function hiJson(obj) { return esc(json(obj)).replace(/"(\w+)":/g, '"$1":'); } - function call(fn, arg) { - return '' + fn + "(note, " + hiJson(arg) + ")"; + function handTo(fn, param) { + return "Hand this to " + fn + "(note, " + param + ")"; } function shell(cmd) { return '$ ' + esc(cmd); @@ -603,11 +612,12 @@

3 · Patch

function render() { const ops = state.mode === "write" ? WRITE_OPS : READ_OPS; const op = ops[state.index]; - const lib = state.door === "lib"; + const jsonDoor = state.door === "json"; let instr, note = "", result, resultTitle; if (state.mode === "write") { - if (lib) { - instr = call("patch", op.instr); + if (jsonDoor) { + instr = hiJson(op.instr); + note = handTo("patch", "instruction") + " — result.document is the new text."; resultTitle = "result.document"; } else if (op.cli) { instr = shell(op.cli); @@ -618,18 +628,25 @@

3 · Patch

resultTitle = "notes.md — after"; } result = op.result; - } else if (lib) { - instr = op.lib.text ? esc(op.lib.text) : call(op.lib.fn, op.lib.arg); + } else if (jsonDoor) { + if (op.lib.text) { + instr = esc(op.lib.text); + note = "No instruction object for this one — it's a plain library call."; + resultTitle = "return value — nothing written"; + } else { + instr = hiJson(op.lib.arg); + note = handTo(op.lib.fn, "target") + " — nothing is written."; + resultTitle = op.lib.title; + } result = op.lib.result; - resultTitle = "return value — nothing written"; } else { instr = shell(op.cli.cmd); result = op.cli.result; resultTitle = "stdout — nothing written"; } - instrTitleEl.textContent = lib ? "The library call" : "The command"; + instrTitleEl.textContent = jsonDoor ? "The instruction" : "The command"; instrEl.innerHTML = instr; - instrNoteEl.textContent = note; + instrNoteEl.innerHTML = note; resultTitleEl.textContent = resultTitle; resultEl.innerHTML = result .map(([l, add]) => diff --git a/site/playground.test.ts b/site/playground.test.ts index 1ca3164..9cd6cb7 100644 --- a/site/playground.test.ts +++ b/site/playground.test.ts @@ -79,12 +79,12 @@ describe("mapChips", () => { expect(chips![0]!.label).toMatch(/^[0-9a-f]{6}$/); }); - it("offers every heading path, joined the way print-map shows them", () => { + it("offers every heading path, labelled readably but targeted as an array", () => { const { chips } = playground.mapChips(DOCUMENT); const headings = chips!.filter((chip) => chip.group === "headings"); expect(headings.map((chip) => chip.label)).toEqual([ "Weekly Sync", - "Weekly Sync::Notes", + "Weekly Sync › Notes", ]); expect(headings[1]!.fields).toEqual({ targetType: "heading", @@ -213,10 +213,23 @@ describe("runInstruction", () => { ); expect(outcome.kind).toBe("ok"); if (outcome.kind !== "ok") return; - expect(outcome.rows.map(([text]) => text).join("\n")).toContain('"draft"'); + expect(outcome.rows.map(([text]) => text).join("\n")).toBe('"draft"'); expect(outcome.status).toContain('scope "content"'); }); + it("shows a section's content itself, not the readTarget envelope", () => { + const outcome = playground.runInstruction( + DOCUMENT, + JSON.stringify({ targetType: "heading", target: ["Title"] }), + "read" + ); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") return; + const text = outcome.rows.map(([t]) => t).join("\n"); + expect(text).toContain("body"); + expect(text).not.toContain('"kind"'); + }); + it("names malformed JSON as a syntax error, not an engine error", () => { const outcome = playground.runInstruction(DOCUMENT, "{oops", "patch"); expect(outcome).toMatchObject({ kind: "error", name: "SyntaxError" }); @@ -284,3 +297,35 @@ describe("runInstruction", () => { }); }); }); + +describe("optionChips", () => { + it("lists every operation and scope for patch mode", () => { + const chips = playground.optionChips("patch"); + const values = (field: string) => + chips.filter((c) => c.field === field).map((c) => c.value); + expect(values("targetType")).toEqual(["heading", "block", "frontmatter"]); + expect(values("operation")).toEqual(["replace", "prepend", "append", "delete"]); + expect(values("scope")).toEqual(["content", "marker", "markerAndContent", "parent"]); + }); + + it("drops operation and the parent scope in read mode", () => { + const chips = playground.optionChips("read"); + expect(chips.some((c) => c.field === "operation")).toBe(false); + expect(chips.map((c) => c.value)).not.toContain("parent"); + }); +}); + +describe("selectedOptions", () => { + it("reads the enumerated fields and defaults scope to content", () => { + expect( + playground.selectedOptions( + JSON.stringify({ targetType: "heading", target: ["A"], operation: "append" }) + ) + ).toEqual({ targetType: "heading", operation: "append", scope: "content" }); + }); + + it("selects nothing for text that isn't an object", () => { + expect(playground.selectedOptions("{oops")).toEqual({}); + expect(playground.selectedOptions("[1]")).toEqual({}); + }); +}); diff --git a/site/playground.ts b/site/playground.ts index 2a450b2..a4376c5 100644 --- a/site/playground.ts +++ b/site/playground.ts @@ -113,7 +113,8 @@ export const mapChips = ( for (const path of headingTreePaths(map.headings)) { chips.push({ group: "headings", - label: path.join("::"), + // A readable path; the instruction gets the array form the library takes. + label: path.join(" › "), fields: { targetType: "heading", target: path }, }); } @@ -134,6 +135,58 @@ export const mapChips = ( return { chips, error: null }; }; +export type OptionField = "targetType" | "operation" | "scope"; + +/** One clickable enum value for an instruction field. */ +export interface OptionChip { + field: OptionField; + value: string; +} + +/** + * Every value the engine accepts for the instruction's enumerated fields, so + * a visitor can see what exists without reading the types. Read mode has no + * operation and no `parent` scope (that scope only makes sense for a move). + */ +export const optionChips = (mode: "patch" | "read"): OptionChip[] => { + const of = (field: OptionField, values: readonly string[]): OptionChip[] => + values.map((value) => ({ field, value })); + const targetTypes = of("targetType", ["heading", "block", "frontmatter"]); + if (mode === "read") { + return [ + ...targetTypes, + ...of("scope", ["content", "marker", "markerAndContent"]), + ]; + } + return [ + ...targetTypes, + ...of("operation", ["replace", "prepend", "append", "delete"]), + ...of("scope", ["content", "marker", "markerAndContent", "parent"]), + ]; +}; + +/** The enumerated fields currently set in the instruction text, for + * highlighting the matching chips. Unparseable text selects nothing. */ +export const selectedOptions = ( + instructionText: string +): Partial> => { + let current: JsonObject | null; + try { + current = asObject(JSON.parse(instructionText)); + } catch (error) { + return {}; + } + if (!current) return {}; + const picked: Partial> = {}; + for (const field of ["targetType", "operation", "scope"] as const) { + const value = current[field]; + if (typeof value === "string") picked[field] = value; + } + // `scope` defaults to "content" when omitted, so show that as selected. + if (picked.scope === undefined) picked.scope = "content"; + return picked; +}; + const asObject = (value: unknown): JsonObject | null => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) @@ -192,12 +245,17 @@ export const runInstruction = ( if (mode === "read") { const target = instruction as ReadTarget; const result = readTarget(document, target); + // Show the targeted content itself, as `mdpatch query` prints it — not + // the `{ kind, content }` envelope readTarget wraps it in. Frontmatter + // values are JSON-encoded so a string is distinguishable from a number. + const text = + result.kind === "frontmatter" + ? JSON.stringify(result.value, null, 2) + : result.content; return { kind: "ok", status: `ok — read at scope ${JSON.stringify(target.scope ?? "content")}`, - rows: JSON.stringify(result, null, 2) - .split("\n") - .map((line): DiffRow => [line, ""]), + rows: text.split("\n").map((line): DiffRow => [line, ""]), warnings: [], }; } @@ -267,6 +325,7 @@ export const mount = (): void => { const docEl = need("pg-doc"); const instrEl = need("pg-instr"); const mapEl = need("pg-map"); + const optionsEl = need("pg-options"); const statusEl = need("pg-status"); const resultEl = need("pg-result"); const resultTitleEl = need("pg-result-title"); @@ -327,10 +386,41 @@ export const mount = (): void => { } }; + const renderOptions = (): void => { + optionsEl.replaceChildren(); + const picked = selectedOptions(instrEl.value); + let lastField: OptionField | null = null; + for (const chip of optionChips(mode)) { + if (chip.field !== lastField) { + const label = window.document.createElement("span"); + label.className = "group"; + label.textContent = chip.field; + optionsEl.append(label); + lastField = chip.field; + } + const button = window.document.createElement("button"); + button.type = "button"; + button.className = "addr"; + button.textContent = chip.value; + button.title = `set ${chip.field} to ${JSON.stringify(chip.value)}`; + button.setAttribute("aria-pressed", String(picked[chip.field] === chip.value)); + button.addEventListener("click", () => { + instrEl.value = foldAddress( + instrEl.value, + { [chip.field]: chip.value }, + DEMO_INSTRUCTIONS[mode] + ); + run(); + }); + optionsEl.append(button); + } + }; + const run = (): void => { renderMap(); + renderOptions(); resultTitleEl.textContent = - mode === "read" ? "readTarget(…) — nothing written" : "result.document"; + mode === "read" ? "what readTarget returns — nothing written" : "result.document"; const outcome = runInstruction(docEl.value, instrEl.value, mode); if (outcome.kind === "error") { statusEl.className = "pg-status err";