Skip to content

7. phaze-vscode

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)
─── Editor stack ────────────────────────────────────────────────────
6. phaze-language-tools ← Volar LSP backend (.phaze → virtual .tsx)
7. phaze-vscode ← VSCode extension (grammar + LSP client) ← you are here
8. phaze-glow ← VSCode theme + halo runtime

madenowhere.phaze-vscode is the VSCode editor integration for .phaze files. It’s a marketplace extension — code --install-extension madenowhere.phaze-vscode — that contributes everything VSCode needs to treat .phaze files as a first-class language: an extension-to-language association, a TextMate grammar for syntax highlighting, a language configuration for brackets / comments / auto-close, and an LSP client that launches phaze-language-tools (#6) for type-aware features.

It’s deliberately the thin layer of the editor stack. The heavyweight work — parsing .phaze, synthesizing the virtual .tsx, running the TypeScript service against it, mapping responses back to .phaze positions — all lives in #6. phaze-vscode just bridges VSCode’s extension contract to that backend — for .phaze’s TypeScript surface. The exception is Rust: a lang: rust fence body is beyond TypeScript’s reach, so the extension runs its own client-side providers that bridge those bodies to the Rust toolchain directly (Rust in lang: rust fence bodies).

The extension contributes four things via package.json:

"languages": [
{
"id": "phaze",
"extensions": [".phaze"],
"aliases": ["Phaze", "phaze"],
"configuration": "./language-configuration.json"
}
]

Now VSCode knows that .phaze files belong to the phaze language, that “Phaze” is the human-readable label (status bar + Command Palette → Change Language Mode), and that its bracket / comment / auto-close rules live in language-configuration.json. Themes and other extensions can target the phaze language ID.

"grammars": [
{
"language": "phaze",
"scopeName": "source.phaze",
"path": "./syntaxes/phaze.tmLanguage.json",
"embeddedLanguages": { "source.tsx": "typescriptreact" }
}
]

The grammar (syntaxes/phaze.tmLanguage.json) defines several scopes — and a second injection grammar (phaze-directives.injection.tmLanguage.json) colors the use: / on: / class: / bind: / for: directive namespaces inside source.tsx:

  • Named fences (---page / ---data / ---state / ---props / ---platform / ---cloudflare) — distinct token scope so themes can color them as section markers.
  • Device-variant body fences (---mobile / ---tablet / ---desktop) — same section-marker scope, opening per-device body regions.
  • Bare fence (---) — separate scope for the page-mode transition fence between the head and the body.
  • TSX body — everything that’s not a fence is included via source.tsx. VSCode’s embedded-language machinery lets the typescriptreact grammar run over those ranges, so JSX / TS / template literals / regex all colorize natively without re-implementing the TSX grammar.

embeddedLanguages: { "source.tsx": "typescriptreact" } is the magic — VSCode delegates bracket matching (⌘B), comment toggling (⌘/), and snippet expansion inside TSX ranges to the typescriptreact language. Without it those features would fall back to plain text.

language-configuration.json mirrors .tsx for brackets ((), [], {}), auto-close pairs ("", '', “, (), [], {}), comment toggles (//, /* */), surrounding pairs, and folding markers. The reader gets the same editing ergonomics they expect from a .tsx file.

"configurationDefaults": {
"tailwindCSS.includeLanguages": { "phaze": "typescriptreact" }
}

An extension can set the default for another extension’s setting via configurationDefaults. This one teaches Tailwind CSS IntelliSense that a .phaze file is TSX-shaped, so it scans the embedded JSX and offers class completion / hover / lint inside class="…" and className="…" — automatically, with no per-project settings.json. (Tailwind’s default classAttributes already covers both class and className; installing Tailwind IntelliSense is the only prerequisite.)

On .phaze file open, src/extension.js launches phaze-language-tools as a stdio child process via vscode-languageclient:

const { LanguageClient, TransportKind } = require('vscode-languageclient/node')
async function activate(context) {
// The language server is esbuild-bundled into this extension at build
// time (build.mjs resolves @madenowhere/phaze-language-tools' bin and
// bundles it to dist/server.js), so no node_modules ship with the VSIX.
const serverModule = path.join(__dirname, 'server.js')
// Locate the workspace's TypeScript SDK — the user's
// `node_modules/typescript/lib` is the source of truth so the LSP
// matches whatever tsc version the project depends on.
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
const tsdk = path.join(workspaceFolder.uri.fsPath, 'node_modules', 'typescript', 'lib')
const serverOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: { module: serverModule, transport: TransportKind.ipc,
options: { execArgv: ['--nolazy', '--inspect=6009'] } },
}
const clientOptions = {
documentSelector: [{ scheme: 'file', language: 'phaze' }],
initializationOptions: { typescript: { tsdk } },
}
client = new LanguageClient('phazeLanguageServer', 'Phaze Language Server',
serverOptions, clientOptions)
await client.start()
context.subscriptions.push({ dispose: () => client?.stop() })
}

Three details worth understanding:

  • The server is bundled, not resolved at runtime. build.mjs resolves @madenowhere/phaze-language-tools’s bin via require.resolve at build time and esbuild-bundles it to dist/server.js, so the published VSIX ships no node_modules. At runtime extension.js just spawns path.join(__dirname, 'server.js') — no resolver, no per-environment path twiddling. (Astro’s VSCode extension uses the same bundle-the-server approach.)
  • TransportKind.ipc runs the server in a Node child process talking JSON-RPC over the parent-child IPC channel. No port allocation, no stdio buffering surprises. The debug config is identical except for --nolazy --inspect=6009 so you can attach a debugger to the language server during development without touching the production runtime.
  • initializationOptions.typescript.tsdk is the absolute path to the workspace’s TS SDK — phaze-language-tools loads tsc from there instead of bundling its own, so type-checking matches whatever tsc version your project depends on. If the user has no workspace folder open, the extension surfaces a warning and returns (.phaze IntelliSense needs a workspace with TypeScript installed).

What VSCode users see once it’s running:

  • Hover on a signal() call inside a fence shows the real Signal<T> type with the inferred generic.
  • Completion on . after a signal binding lists (), .set, .update, .subscribe, etc.
  • Go-to-definition on a directive name (use:autofocus) jumps to its export in phaze-directives.
  • Find references crosses the .phaze ↔ virtual .tsx boundary transparently.
  • Diagnostics (TS errors) appear at the correct .phaze line.

The mapping back to .phaze positions happens inside Volar (see phaze-language-tools) — for the TypeScript surface, the extension is just a transport.

A transport.phaze fence can carry a Rust body: mark it lang: rust and the body is hoisted to a rust-api handler — a gen-rust build step extracts it, the TS side proxies to it. (It’s the inline-Rust sibling of the rust: proxy knob: same mount point, but you write the handler right there instead of pointing at an existing endpoint.) The TS LSP (#6) is no help inside such a body — its virtual .tsx replaces the body with a fetch(...) stub, and TypeScript can’t type Rust anyway. So phaze-vscode carries a set of client-side providers — it isn’t purely an LSP transport — that bridge those bodies to the real Rust toolchain, keyed on the sourcemap gen-rust emits alongside the extracted .rs.

gen-rust writes every lang: rust body verbatim into rust-api/src/_phaze_gen.rs (indented 4 spaces, one gen line per source line) and, next to it, _phaze_gen.map.json:

{
"source": "../../src/transport/transport.phaze",
"gen": "_phaze_gen.rs",
"indent": 4,
"fences": [
{ "name": "SessionPQC", "phazeStart": 180, "genStart": 10, "lines": 17 }
]
}

Each provider below finds the map whose source resolves to the open .phaze, then translates positions both ways: line 1:1 (phazeStart + (genLine − genStart)), column ± indent.

Hovering in a rust body maps the cursor into _phaze_gen.rs and forwards via vscode.executeHoverProvider(genUri, pos) to whatever answers there — rust-analyzer — rendering the result on the .phaze word. Real Rust types, signatures, and doc comments, in the .phaze file. (Swap the executeCommand id and the same seam yields go-to-definition / completion.)

Red squiggles for real Rust compile errors. The obvious route — read rust-analyzer’s published diagnostics for _phaze_gen.rs via vscode.languages.getDiagnostics — returns nothing: rust-analyzer only publishes native diagnostics for files it has open (a hidden openTextDocument doesn’t count). So the extension runs cargo check --message-format=json on the rust-api crate itself, keeps compiler messages whose primary span is inside a fence body, maps line/column back through the sourcemap, and surfaces them on the .phaze. Editor-state-independent, and verifiable straight from a terminal.

Both bridges read _phaze_gen.rs, so they’re only as fresh as the last gen-rust run. The extension closes that gap by re-running gen-rust on every .phaze save (phaze.rustGenOnSave, default on; only for a .phaze that a _phaze_gen.map.json actually points at). Edit a rust body → save → the generated .rs updates → hover and squiggles follow, no manual step.

It’s a one-shot regeneration per save, deliberately not gen-rust’s own --watch: wiring the watcher into the dev server storms it with reloads (the generated file lands in the app’s watch tree — 100+ reloads observed). Pair it with the consumer’s Vite config so those writes never reload the page:

// vite.config.ts — rust-api is a separate wrangler worker, never in the vite app graph
server: { watch: { ignored: ['**/rust-api/**'] } }

Prereqs: rust-analyzer has loaded rust-api/ (hover); a Rust toolchain with the check target on PATH (diagnostics — override via phaze.rustCheckTarget, default wasm32-unknown-unknown); and gen-rust has run at least once to create the sourcemap. Force a re-check anytime with the Phaze: Recheck Rust command.

#7 and #8 are deliberately split. #7 contributes structural language support (grammar / LSP / language configuration) — works with any color theme. #8 (phaze-glow) is a color theme + an optional workbench patcher for the halo effect — works on any source language, not just .phaze. Splitting them lets a user adopt:

  • Just #7.phaze IntelliSense + their existing theme.
  • Just #8 — Phaze Glow colors / halo on any source language.
  • Both — the full Phaze visual identity.

If they were one extension, you’d have to choose between forcing the theme on every install (rude to users who like Solarized) or hiding the IntelliSense behind a theme switch (unhelpful). Two extensions, two clean install paths.

The two extensions also have different release cadences: language services updates ship whenever phaze-language-tools (#6) ships, which tracks phaze-compile changes. Theme tweaks (color palette adjustments, halo brightness defaults) ship on their own rhythm. Separate VSIX files = separate version histories on the marketplace.

  • No semantic highlighting beyond TS. Token coloring on .phaze fences comes from the TextMate grammar; coloring inside the TSX body comes from the embedded typescriptreact grammar + the TS service’s semantic tokens. There’s no Phaze-specific semantic colorization.
  • No formatter. Prettier handles .tsx and ignores .phaze. A v2 formatter would run phaze-compile’s parser, format each fence body via Prettier’s TSX printer, and reassemble — out of scope for now.
  • No .phaze file template. No “New Phaze File” command; users start from .tsx and rename. (A snippet pack does ship — see contributes.snippets — with common patterns.)
  • No build / dev-server orchestration. The extension doesn’t run pnpm dev; it’s pure editor surface. Build/dev concerns belong to phaze-vite and the host adapters (#4 / #5).
Terminal window
code --install-extension madenowhere.phaze-vscode

Or search “Phaze” by publisher madenowhere in the Extensions view.

The extension activates on first .phaze open — no per-project config. To pair with phaze-glow:

Terminal window
code --install-extension madenowhere.phaze-vscode
code --install-extension madenowhere.phaze-glow

Then switch theme via Command Palette → Color Theme → Phaze Glow.

PathRole
package.jsonExtension manifest — language ID, grammar, language-configuration, the Tailwind IntelliSense configurationDefaults, vscode-languageclient dep, @madenowhere/phaze-language-tools dep.
language-configuration.jsonBrackets, comments, auto-close pairs, indentation, folding — mirrors .tsx.
syntaxes/phaze.tmLanguage.jsonTextMate grammar. Three rules: named fence (---<label>), bare fence (---), TSX-body include (source.tsx).
src/extension.jsActivation entry. Spawns the bundled dist/server.js (path.join(__dirname, 'server.js')), locates the workspace TS SDK, launches the language server over IPC — and registers the client-side providers: folding, document-symbol outline, the Ok( decoration, and the lang: rust Rust bridges (hover → rust-analyzer · cargo check diagnostics · gen-rust-on-save).