Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@
"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",
"test:e2e": "node scripts/fetch-grammars.mjs --extended && CODEINDEX_E2E=1 vitest run tests/e2e-real-repos.test.ts",
"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"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 84 additions & 0 deletions scripts/build-browser.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,7 @@
</a>
<nav class="primary" aria-label="Primary">
<div class="nav-links">
<a href="playground/">Playground</a>
<a href="#benchmarks">Benchmarks</a>
<a href="#quality">Quality</a>
<a href="#features">Features</a>
Expand Down
Loading
Loading