Skip to content

Editor language support for .axo, plus release and site workflows - #1

Merged
tavobarrientos merged 11 commits into
mainfrom
014-editor-language-support
Aug 26, 2026
Merged

Editor language support for .axo, plus release and site workflows#1
tavobarrientos merged 11 commits into
mainfrom
014-editor-language-support

Conversation

@tavobarrientos

Copy link
Copy Markdown
Member

Feature 014, plus the workflow gaps it exposed along the way.

What this adds

A VS Code extension (extension/) giving .axo files colour, region-aware editing, completion, hover, diagnostics and go-to-definition. Works from one build on VS Code and its forks. 80 tests, no editor required to run them.

A shared scanner (src/scan.ts). matchBrace and splitFrontmatter were private to the compiler; the editor needs the same answers, and a second implementation would drift. Now published as ./scan and bundled into the extension, with a drift guard asserting the two still agree across the whole built-in corpus.

Three workflows. publish.yml (npm, OIDC Trusted Publishing), site.yml (Pages), and extension-release.yml (both extension registries). Action versions refreshed everywhere — they were several majors behind and the last CI run said so.

The landing page (site/) is now a deck built by Axo, so it cannot drift from what the framework does.

Notable

  • Compiler behaviour is unchanged by the scanner extraction: the pre-existing 83 tests pass untouched, which is the evidence for that claim.
  • The editor deliberately reports less than the build, never more. It matches the compiler's exact client:* list rather than the wider one FR-012 describes, because client:whatever compiles today — widening it belongs in the compiler.
  • Packaging the extension for the first time showed the VSIX carried .vscode/ and a second copy of the scanner; excluding those took it from 13 files to 8.
  • npm pack --dry-run --json runs prepare, whose build output corrupts the JSON — the publish payload check would have failed on its first real run.

Not done

engines.vscode is provisional until the forks are measured, and neither registry publisher identity exists yet, so US-2 cannot complete. specs/014-editor-language-support/tasks.md tracks the rest: 44 of 53.

Why this PR exists now

None of these workflows has ever executed — the push trigger is branches: [main]. This gives ci.yml and site.yml their first real run.

matchBrace and splitFrontmatter were private to the compiler, and the
editor extension needs exactly the same answers about where a region
ends. A second implementation would drift, and the editor would
eventually disagree with the build about a file both are reading.

The scanner now works in offsets rather than extracted strings, because
an editor has to map a token back to a position in the document and a
copied substring has forgotten where it came from. matchBrace throws
with the offset of the unclosed brace, so a caller can point at it.

Published as ./scan, which is a new public entry point and so a new
contract — the pinned export list in the test moves deliberately.
Compiler behaviour is unchanged: the existing suite passes untouched,
which is the whole evidence for that claim.
Feature 014. Six stories, nineteen EARS requirements and seven success
criteria, with the plan, the Phase 0 research, and contracts for the
scanner and the ambient API table.

Two decisions are load-bearing. A TextMate grammar cannot resolve a
balanced brace, so colouring is two layers: a declarative grammar for
instant baseline colour, corrected by semantic tokens driven by the
real scanner. And the editor may report less than the build but never
more, which is what keeps a squiggle trustworthy.
A .axo file has been plain text in every editor since the format was
invented, which is a poor start to a loop built on writing components.

Colouring is two layers. The TextMate grammar is declarative, so it
costs no activation and works before any code runs; it handles the
region split, tags, directives and <slot>. It cannot balance a brace,
so semantic tokens driven by the shared scanner mark the true extent of
every expression — that is the case class:list={['a', `x-${y}`]} gets
wrong under a regex.

On top of that: region-aware editing, where comment toggling follows
the embedded-language mapping rather than a single declared style;
completion and hover from one ambient API table, tested to cover
docs/writing-components.md in both directions; and diagnostics for the
two rules the build enforces.

The diagnostics match the compiler's exact client:* list rather than
the wider client:* the spec describes, because client:whatever compiles
today and flagging it would have the editor report a problem the build
does not have. That widening belongs in the compiler.

Everything decidable is a pure function under src/language/; the editor
API appears only in src/adapters/. That is what lets 60 tests run under
node --test with no editor to download.
Go-to-definition on <Pricing> opens components/Pricing.axo. There is no
index to keep in sync — the filename IS the component name, so this is
a path lookup.

A deck component shadows a built-in of the same name, so the deck is
searched first and its file is the only result: opening the built-in
someone overrode would show them code the build will not run. A tag
that resolves to nothing opens nothing and raises nothing, because a
lowercase element or a not-yet-written component is not an error.

Filesystem access is injected, so the ordering — which IS the
requirement — is tested without a fixture tree.

Also adds the drift guard FR-019 asks for. It compares behaviour rather
than bytes: esbuild rewrites the module on the way into the bundle, so
a byte comparison would fail on every build while proving nothing. Both
scanners must answer identically across the corpus, and agree that a
malformed document is malformed.

The language modules now emit as .mjs. They are ESM in a package with
no "type" field, since the host bundle has to stay CommonJS, and Node
was reparsing and warning on every import.
Tagging `extension-v0.1.0` builds, verifies, packages and publishes to
the VS Code Marketplace and Open VSX. Both run, and the second runs
even if the first fails — a credential problem should not silently
halve a release. If they end up disagreeing the run fails loudly,
because a split release is fixed by publishing the same VSIX by hand,
not by cutting a new version.

Publishing needs VSCE_PAT and OVSX_PAT, which do not exist yet. A
preflight step names the missing secret instead of letting vsce fail
with something vaguer. Until then, workflow_dispatch packages the VSIX
and uploads it without publishing anything.

Packaging it for the first time showed the VSIX carried .vscode/ — the
launch configs for developing this extension, meaningless to anyone who
installs it — and dist/language/, the ESM copies that exist so tests can
import modules one at a time, which the host bundle already inlines. It
also had no LICENSE despite declaring MIT. Excluding the first two and
adding the licence and a README takes it from 13 files to 8, and from
15.77 KB to 11.1 KB.

The CI job also never ran the extension's 80 tests: they import the
framework's dist/ — the drift guard compares the bundled scanner against
it, and the parity tests ask the real compiler whether it rejects a
document — so without building the framework first they cannot run at
all. Both workflows now do that.
publish.yml ships @ambystech/axo on a published GitHub Release, via npm
Trusted Publishing — OIDC rather than a long-lived NPM_TOKEN, with
provenance attached automatically. Ported from ambykit, which already
runs this. A trusted publisher is configured on a package that exists,
though, and this one has never been published: the first release likely
needs a manual npm publish to create the name.

The payload check nearly shipped broken. `npm pack --dry-run --json`
runs `prepare`, and the build prints to stdout, so the JSON never
parses — on any platform. It runs with --ignore-scripts now, against
the dist/ built two steps earlier.

site.yml builds site/ and deploys it to Pages. The landing page is a
deck built by Axo, so every push proves what the page claims: the
workflow refuses a non-public deck and fails if dist/index.html reaches
any external host. If it goes red, the page is lying.

Action versions were several majors behind — the last CI run carried a
deprecation annotation about exactly that. checkout v4→v7, setup-node
v4→v7, upload-artifact v4→v7, the Pages actions to v6/v5/v5, and
gh-release v2→v3, across every workflow including the deck template
users get scaffolded.

Also: a concurrency group so a new push cancels the superseded run, and
the npm cache the extension job never had.
axo.ambystech.io has been a claim in package.json with nothing behind
it. Now it is six slides in site/, scaffolded by `axo new` and built by
`axo build` — the framework's own output, deployed on every push.

That is the point of building it this way rather than as a docs site:
the page cannot drift from what the framework actually does, because it
IS what the framework produces. The workflow checks the built deck
reaches no external host, so the offline guarantee on slide two is
verified by the thing serving slide two.

Writing it found a real edge, too — <Flow> takes at most four <Step>s
and the pipeline has five. The build refused, named the file, and said
to use <Phases>. Exactly the behaviour slide four describes.

The deck depends on the framework by `file:..`, like extension/ does,
so it compiles against the working tree rather than a published
version that does not exist yet.
The first run failed at configure-pages because Pages is not enabled on
the repo yet — but it failed AFTER building the deck and passing both
guards, which is the part a pull request actually needs to know.

A PR should not require Pages to be configured in order to verify that
the deck still compiles and still reaches no external host. Gate the two
Pages steps on main, where there is something to deploy.
Two axes, each earning its place. The compiler builds file:// URLs and
normalises separators by hand in three modules — the code most likely
to break off Linux, and exactly what a Linux-only CI cannot see. And
`engines` promises Node >=20 while only 20 was ever tested, though the
one CI failure this repo has had was a Node-version difference.

Adding the matrix meant fixing what it would have caught immediately.
`node --test test/*.test.mjs` depends on the SHELL expanding the glob:
POSIX shells do, cmd.exe and PowerShell do not, so on Windows the
literal string reaches Node — which can expand it itself, but only from
v22. Quoting moves the problem rather than solving it, since then it
always reaches Node and v20 fails everywhere instead of just on Windows.
There is no form of that command that works on Windows with Node 20.

So scripts/test.mjs builds the file list, where neither the shell nor
the Node version has an opinion. Both packages use it, which also means
`npm test` now works for a Windows contributor on the floor version —
it did not before.
esbuild reports REAL paths, with symlinks resolved. macOS makes its temp
dir /var/folders/…, a symlink to /private/var/folders/…, so the key
written from the unresolved path never matched the path esbuild handed
back. The lookup missed, and a broken component was blamed on the
generated .jsx in .axo/ rather than the file someone actually wrote —
the opposite of what Principle 4 promises.

Linux and Windows do not symlink their temp dirs, which is why seven of
the eight matrix cells were green and only macOS was red. The matrix
found this on its first run.

Both ends are resolved now, so they agree on every platform.
@tavobarrientos
tavobarrientos merged commit e9fc5ff into main Aug 26, 2026
10 checks passed
@tavobarrientos
tavobarrientos deleted the 014-editor-language-support branch August 26, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant