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 cf79941..cb74302 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; } @@ -93,6 +97,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); @@ -115,11 +127,62 @@ .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; } - .cli-line { color: var(--ink-soft); font-size: 0.78rem; } - .cli-line .dollar { color: var(--ink-faint); } + .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, .instr-note .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); } + .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; } + .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; } @@ -173,6 +236,7 @@