diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 1255329..5b32fad 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -1,14 +1,25 @@ name: Site -# One-page GitHub Pages site (site/index.html). Self-contained: zero external -# requests at runtime, so the deploy step is a plain static-asset publish — -# no build step, no dependency install. Triggers only on changes to the site -# itself (or this workflow), plus manual dispatch. +# GitHub Pages site. Still a plain static-asset publish — no build step and no +# dependency install — because every generated artifact it serves is committed: +# site/index.html is hand-written, and site/playground/engine.browser.mjs is +# built by scripts/build-browser.mjs and held byte-reproducible by +# `pnpm run check:build`. +# +# The one thing not committed is the tree-sitter wasm the playground loads. It +# already lives in scripts/grammars/ (22 MB), so it is COPIED into the artifact +# here rather than duplicated in git. +# +# Note on the site's "zero external requests" property: that holds for +# site/index.html, which remains fully self-contained. site/playground/ is +# deliberately different — it fetches repositories from jsDelivr at runtime, +# which is the entire point of a playground. on: push: branches: [main] paths: - "site/**" + - "scripts/grammars/**" - ".github/workflows/site.yml" workflow_dispatch: @@ -32,6 +43,15 @@ jobs: - uses: actions/configure-pages@v5 + # The AST tier the playground demonstrates is these files. Without them it + # still works, but silently degrades to the regex tier — so a missing copy + # would quietly weaken the one thing the page exists to show. + - name: Stage the tree-sitter grammars for the playground + run: | + cp -R scripts/grammars site/playground/grammars + test -f site/playground/grammars/web-tree-sitter.wasm + test -f site/playground/engine.browser.mjs + - uses: actions/upload-pages-artifact@v4 with: path: site diff --git a/.gitignore b/.gitignore index 71d525b..78cc453 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ scripts/embed-asset/.venv/ # never in git: `node scripts/fetch-grammars.mjs --extended` regenerates it, and # `codeindex grammars pull` is how a consumer gets it. scripts/grammars-extended/ + +# The playground serves the committed grammars from its own directory. They are +# COPIED there at deploy time (.github/workflows/site.yml) rather than committed +# twice — 22 MB of wasm already lives in scripts/grammars/. Populate it locally +# with: cp -R scripts/grammars site/playground/grammars +site/playground/grammars/ diff --git a/package.json b/package.json index a9ab77e..23f6f5c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "node": ">=18" }, "scripts": { - "build": "tsup && node scripts/postbuild.mjs", + "build": "tsup && node scripts/postbuild.mjs && node scripts/build-browser.mjs", "grammars": "node scripts/fetch-grammars.mjs", "test": "node scripts/fetch-grammars.mjs --extended && vitest run", "test:watch": "node scripts/fetch-grammars.mjs --extended && vitest", @@ -38,7 +38,7 @@ "quality": "node scripts/fetch-grammars.mjs --extended && CODEINDEX_QUALITY_WRITE=1 vitest run tests/quality.test.ts", "quality:report": "node scripts/fetch-grammars.mjs --extended && CODEINDEX_QUALITY_REPORT=1 vitest run tests/quality.test.ts", "typecheck": "tsc --noEmit", - "check:build": "tsup && node scripts/postbuild.mjs && git diff --exit-code -- scripts/engine.mjs scripts/engine.d.mts scripts/cli.mjs scripts/grammars", + "check:build": "tsup && node scripts/postbuild.mjs && node scripts/build-browser.mjs && git diff --exit-code -- scripts/engine.mjs scripts/engine.d.mts scripts/cli.mjs scripts/grammars site/playground/engine.browser.mjs", "demo": "node scripts/cli.mjs graph --repo tests/fixtures/mini-repo", "release": "semantic-release", "grammars:extended": "node scripts/fetch-grammars.mjs --extended" @@ -66,6 +66,7 @@ "@tree-sitter-grammars/tree-sitter-lua": "0.4.1", "@tree-sitter-grammars/tree-sitter-zig": "1.1.2", "@types/node": "^20.14.0", + "esbuild": "0.27.7", "semantic-release": "^25.0.8", "tree-sitter-bash": "0.25.1", "tree-sitter-c": "^0.24.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205cbd5..bb1484a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@types/node': specifier: ^20.14.0 version: 20.19.43 + esbuild: + specifier: 0.27.7 + version: 0.27.7 semantic-release: specifier: ^25.0.8 version: 25.0.8(typescript@5.9.3) diff --git a/scripts/build-browser.mjs b/scripts/build-browser.mjs new file mode 100644 index 0000000..25792e9 --- /dev/null +++ b/scripts/build-browser.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Builds site/playground/engine.browser.mjs — the SAME engine as +// scripts/engine.mjs, resolved against browser shims instead of node builtins. +// Committed like the other two artifacts and held byte-reproducible by +// `pnpm run check:build`, so GitHub Pages stays a plain static publish with no +// build step and no dependency install. +// +// WHY NOT A THIRD tsup TARGET. This build's whole job is to intercept the +// resolution of node builtins, and tsup registers its own node-protocol plugin +// ahead of any user plugin. esbuild takes the FIRST onResolve result, so tsup's +// plugin wins and resolves `node:fs` to an external bare "fs". The build still +// succeeds; the output just carries `import { readFileSync } from "fs"` +// statements that no browser can load — and that Node satisfies from the real +// disk, so it fails silently rather than loudly. Owning the plugin list here +// removes that ordering hazard entirely. +// +// Determinism: esbuild is pinned in package.json and its output is a pure +// function of (input, options), so two builds of an unchanged tree are +// byte-identical — the same property check:build already enforces for the Node +// artifacts. + +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const shim = (name) => join(root, "src", "browser", `${name}.ts`); + +// Node builtins the engine imports. Matched under both spellings because the +// source is not uniform about the `node:` prefix and third-party code inside +// the bundle (web-tree-sitter) uses the bare form. +const BUILTINS = "fs|path|crypto|os|url|child_process|worker_threads|readline|zlib"; + +const browserShims = { + name: "browser-shims", + setup(build) { + // `namespace: "file"` is load-bearing, not decoration. A plugin-resolved + // path with no namespace gets a DIFFERENT module identity from the same + // file reached by ordinary relative resolution — so src/browser/fs.ts would + // be instantiated twice: once for `node:fs` (what walk.ts reads) and once + // for `./fs.js` (what the playground's mount API writes). Two instances + // means two VFS maps, and every mount would land in a filesystem the engine + // cannot see. + build.onResolve({ filter: new RegExp(`^(node:)?(${BUILTINS})$`) }, (args) => ({ + path: shim(args.path.replace(/^node:/, "")), + namespace: "file", + })); + + // web-tree-sitter probes for a Node environment before falling back to its + // fetch/XHR loaders. That branch is dead here — ENVIRONMENT_IS_NODE is + // false in a browser, and ensureGrammars hands the runtime an explicit + // wasmBinary so it never reaches for a loader at all — but esbuild still + // has to resolve the specifiers statically. Empty modules satisfy it + // without pulling any of Node's machinery into the bundle. + build.onResolve({ filter: /^(node:)?(fs[/]promises|module)$/ }, () => ({ path: "node-only", namespace: "browser-empty" })); + build.onLoad({ filter: /.*/, namespace: "browser-empty" }, () => ({ + contents: "export const createRequire = () => { throw new Error('node-only API in the browser build'); };\nexport default {};\n", + loader: "js", + })); + }, +}; + +const outfile = join(root, "site", "playground", "engine.browser.mjs"); + +await build({ + entryPoints: [join(root, "src", "browser", "entry.ts")], + outfile, + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + // Minified because this artifact is served over the wire to a browser rather + // than vendored into a repo, where the Node bundle's readability matters. + minify: true, + sourcemap: false, + legalComments: "none", + plugins: [browserShims], + // Buffer and process are free identifiers in the engine source (walk.ts's + // decoder, loader.ts's env reads). Injection substitutes both without editing + // a line of that source. + inject: [shim("globals")], +}); + +console.log(`build-browser: ${outfile}`); diff --git a/site/index.html b/site/index.html index 9a10e36..6b02ab8 100644 --- a/site/index.html +++ b/site/index.html @@ -932,6 +932,7 @@