Development

npm install

# vitest + jsdom
npm test

# + coverage report (text, html in coverage/, json-summary)
npm run test:coverage

# tsup → dist/ (runtime: ESM + CJS + IIFE, vite plugin: ESM + CJS, + .d.ts)
npm run build

# builds the GitHub Pages site into site/ (needs build + coverage first)
npm run site

# the same, on a watch loop with livereload → http://localhost:4179
npm run site.dev

The no-bundle path

Every other test imports from src/, so nothing would notice if the artifacts a CDN actually serves stopped working. tests/no-bundle.test.ts covers the delivery mode that has no build step at all: it loads dist/jq79.global.js as a classic script and dist/jq79.js as a native ES module, serves the fixture components in tests/fixtures/no-bundle/ from a real HTTP server, and mounts them through Component79.fetch() — including a nested component that a setup script's await import("./todo-item.html") fetches at runtime, with no bundler having resolved the specifier. The last test runs the fixture's index.html as written, with only its https://esm.sh/jq79 import pointed at the local build.

It builds dist/ itself when it's missing or older than src/ (~0.5s), since npm test runs before npm run build.

The dev server that serves that path — npx jq79 dev, in dev/dev.ts — is covered by tests/devServer.test.ts, which runs a real server over a real directory and asserts both ends of the seam a browser sits in: the { url, src } an SSE frame carries, and the swap the runtime makes of it. The client script the server injects is fetched from the running server and executed, so a typo in it fails the suite rather than the page.

Load-bearing invariants

Things that look like implementation details but aren't — each one is held up by tests, and each has cost real debugging at least once.

The store never wraps a proxy. $reactive wraps nested objects lazily, as they're read, caching one proxy per raw object per store, and unwraps (toRaw) on the way in and on the way out. This is not an optimisation: it is the thing that makes the store safe. It used to wrap eagerly, rewriting the object it was handed and replacing nested objects with proxies in place — so two stores over the same object each wrapped the other's proxies, and because wrapping walks what it wraps, the layers compounded until the tab froze. Mounting two components with one data object, or re-mounting one, was enough to hang it. Reverting to eager wrapping brings that straight back; tests/reactive.test.ts ("data shared with another store") is what stands in the way.

A nested store is bridged, not wrapped. A store put inside another store keeps its own listeners and its own effects — and the holder's effects are not among them, so a write through it would notify nobody upstairs. The holder subscribes to it instead ($onAny) and re-notifies its changes under the path it sits at (items.0cart.items.0), which is the path an effect reading through cart recorded as a dependency. That bridge is what makes a shared $reactive shared state rather than shared data, so don't remove it in the name of the invariant above: the fix for eager wrapping is not to re-wrap the store, it's to listen to it. It's dropped when the holder is destroyed ($dispose), or a long-lived store would collect a listener per component that ever held it.

Identity is keyed to the object, not to its path. A reordered list has to hand back the same proxy for the same item, because :each diffs by reference (Object.is) — key the proxy cache by path instead and every row re-renders on every reorder. The trade is that an object's dot-path is fixed when it's first wrapped, so after a reorder its notifications carry the old index; effects that read the list itself still wake up (paths overlap), which is what makes it a non-issue in practice.

A hot swap re-attaches at the markers, and destroys before it swaps. hotReplace — the one both the Vite plugin and the dev server drive — has two orderings that look arbitrary and are not. It re-inserts the new output between the component's markers, not into mountRoot, because a nested clone is mounted into a DocumentFragment that is then emptied into the page: its mountRoot is a stale, detached fragment, and re-mounting on it would lift the component out of the document. And it calls destroy() while this.styles still holds the old style blocks — destroy() releases the refcounted stylesheets it acquired, so swapping the parts in first releases a stylesheet nobody holds and leaves the old one styling the page forever.

Setup scripts have two traps that no test can catch for you, both written up in setup-scripts.md: an effect that reads and writes the same scope variable wakes itself on repeat — but only from the second pass, since an effect's dependencies are recorded after its first run, so the component renders fine and misbehaves later (the runtime cuts the loop at 100 rounds with a console error, but the side effects have already run that many times). And $: effects run before the template exists, so anything that touches the DOM needs its first pass done by hand after await $mounted().

The tutorial

/tutorial/ on the site is a jq79 component (tutorial/_app/Tutorial.html) that compiles whatever is in its editor with new Component79(...) and mounts it into a shadow root — no compiler to ship, and an exercise's <style> can't leak into the page around it.

Tutorial.html is only the shell: the state (which exercise, which file, whether a solution is being reviewed), the navigation and the layout. The panes are components under tutorial/_app/components/Toc, Lesson, Editor, Diff, Preview — imported at runtime with await import(...), so the tutorial is itself a multi-component jq79 app. None of them writes to the state it renders: props go down, and what the user did comes back up as a tutorial:* $emit the shell's container element listens for. The two panes that need the highlighter get highlight/languageOf from the shell, and the preview gets a compile function, so hljs and Component79 stay in one file.

Adding an exercise is adding a folder — build-site.mjs walks tutorial/ and emits the whole thing as one JSON manifest:

tutorial/
  01-basics/                     ← a section; its title comes from the folder name
    01-your-first-component/     ← an exercise, in order
      README.md                  ← the prose (its `# heading` is the exercise title)
      app.html                   ← what the editor starts with; the file that gets mounted
      solution/
        app.html                 ← what the "solution" button swaps in

Extra files alongside app.html become editable tabs and its importable modules, so await import("./Greeting.html") inside an exercise resolves to the file in the next tab rather than hitting the network. A solution/ only needs the files it changes.

A specifier that isn't one of those tabs falls through to the runtime, which fetches it — which is what the 02-no-bundle exercises are about, and why tutorial/_app/examples/ exists: it rides along into site/tutorial/examples/, so await import("./examples/Sticker.html") in an exercise is a real request to the host serving the page, for a component no bundler ever saw. Those exercises are the only ones that touch the network, and their tests serve the same files off disk.

Exercises are tested like any other component (tests/tutorial.test.ts): every starting file must mount without throwing, and every solution must actually do what its README claims — so a library change that breaks an exercise fails the build.

The "solution" button doesn't swap the files in: it diffs them against whatever the editor holds and shows that, over the editor, until the user accepts it. So an exercise's solution/ is read twice — once to render the diff, once to apply it — and both go through the same merge over the starting files, which is why applying one also reverts edits made to files the solution doesn't mention.

Highlighting

Snippets in an exercise's prose are highlighted at build time (marked-highlight), and the editor and the diff are highlighted in the browser as you type — by the same library, with the same hljs-* class names, off the one palette the shell page carries (HLJS_CSS in build-site.mjs, shared with the docs pages). highlight.js ships as CommonJS, so the copy the tutorial loads is bundled from scripts/hljs-browser.js into site/assets/hljs.js and handed to the component as render data, the same way Component79 is. That bundle is cached under node_modules/.cache/jq79/ and only rebuilt when the dependency's version changes — the watch loop would otherwise pay for it on every rebuild.

The editor itself is a textarea with transparent text sitting exactly on top of a <pre> holding the same text, highlighted; only the caret and the selection show through. The two layers only line up while they wrap identically, so their font, padding and wrapping are set together in one rule — and the <pre> is the one in flow, so it sizes the pane and there is no scroll position to keep in sync.

Publishing

Releases are automated via GitHub Actions (release.yml): run the Release workflow from the Actions tab (workflow dispatch, choosing the patch/minor/major bump). It tests, builds, bumps the version, publishes to npm with provenance, pushes the commit + tag, and creates the GitHub release with the dist/ files attached. Requires an NPM_TOKEN repository secret (npm automation token). CDNs (unpkg, jsDelivr, esm.sh) pick the new version up from npm automatically.

After publishing, the pages job deploys the GitHub Pages site: rendered docs, the HTML coverage report, self-hosted badges (npm version + coverage), and the dist/ files at the site root so https://jgermade.github.io/jq79/jq79.js works as a CDN for the latest release. One-time setup: repo Settings → Pages → Source: GitHub Actions.

Every push/PR to main also runs tests + build (ci.yml).