Skip to content

6. phaze-language-tools

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) ← you are here
7. phaze-vscode ← VSCode extension (grammar + LSP client)
8. phaze-glow ← VSCode theme + halo runtime

@madenowhere/phaze-language-tools is the language services engine for .phaze files — the shared backend every editor integration consumes when it wants hover types, completion, go-to-definition, find-references, and diagnostics inside .phaze source. It does not ship a UI: it’s an npm package with a Volar LanguagePlugin (programmatic API) plus a stdio language server (LSP-over-stdio binary). phaze-vscode (#7) consumes it as an LSP backend; future Cursor / JetBrains / Sublime adapters consume the same npm package; the planned phaze check CLI (Phase 4) consumes it headlessly.

The same pattern Astro’s editor stack uses (@astrojs/language-server + @astrojs/ts-plugin), the same Vue uses (@vue/language-core + @vue/language-server), the same Svelte uses (svelte2tsx + svelte-language-server). All of them stand on Volar — a framework for building language tools that bridge a source file to a virtual TS file.

A .phaze file isn’t TypeScript — it has ---page / ---data / ---state fences (page mode) or fenceless component-body code (component mode), and phaze-compile’s format transform produces the actual .tsx the bundler sees. So an off-the-shelf TS language server has nothing to bind against: it doesn’t recognise the extension, can’t parse the fences, doesn’t know how phaze-compile synthesizes the module.

phaze-language-tools fills that gap by inserting a translation layer between the IDE’s TS service and the .phaze source:

What the editor sees
.phaze file open in tab
┌──────────────────────────────────────────────┐
│ Volar's LanguagePlugin dispatch │
│ (LSP / TS-plugin / CLI — same hook) │
└──────────────────────────────────────────────┘
▼ PhazeVirtualCode
┌──────────────────────────────────────────────┐
│ phaze-compile's phazeFormatTransform │
│ → synthetic .tsx body │
│ → v3 sourcemap (.tsx ↔ .phaze positions) │
│ + VLQ-decode → Volar CodeMapping[] │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ TypeScript Language Service │
│ (drives against the synthetic .tsx file) │
│ → hover types, completion, diagnostics, │
│ go-to-def, find-refs │
└──────────────────────────────────────────────┘
▼ responses translated
┌──────────────────────────────────────────────┐
│ Volar routes responses through │
│ CodeMapping[] back to .phaze positions │
└──────────────────────────────────────────────┘
Editor displays the result
at the user's actual .phaze cursor

Everything that makes a .phaze file behave like a .tsx file in the editor sits at one of those two boxes: the virtual-code synth, or the response-mapping pass.

The package exposes two surfaces — one for in-process integrations, one for stdio launchers.

import {
getPhazeLanguagePlugin, // the Volar LanguagePlugin factory
PhazeVirtualCode, // the VirtualCode class (re-exported for embedders)
} from '@madenowhere/phaze-language-tools'
// package.json bin entry
"bin": {
"phaze-language-server": "./bin/phaze-language-server.js"
}
  • ProgrammaticgetPhazeLanguagePlugin() returns a Volar LanguagePlugin<URI, PhazeVirtualCode>. You hand it to createTypeScriptProject (Volar’s project factory) or to any other Volar host (vue-tsc / astro check / a JetBrains plugin’s embedder). This is what phaze check (Phase 4) and future non-LSP integrations will consume.
  • Stdio binaryphaze-language-server is an executable Node entry that wires the same plugin into @volar/language-server/node’s createConnection + createServer pipeline, ready to be spawned by any LSP client (VSCode’s vscode-languageclient, neovim’s nvim-lspconfig, JetBrains’s LSP support, …). phaze-vscode launches it via require.resolve('@madenowhere/phaze-language-tools/bin/phaze-language-server.js') — the resolver walks workspace symlinks, hoisting, and pnpm isolation uniformly.

Both surfaces share the same LanguagePlugin — every editor consumer gets the same hover types, the same completion, the same diagnostics. There’s no “VSCode flavor” vs “JetBrains flavor” of the language services.

The LanguagePlugin object Volar consumes is small — three hooks and a typescript: sub-object that tells the TS service how to treat .phaze files.

{
getLanguageId(uri) { /* '.phaze' → 'phaze', else undefined */ },
createVirtualCode(uri, …) { /* new PhazeVirtualCode(uri.fsPath, snapshot) */ },
updateVirtualCode(uri, …) { /* re-synth on edit */ },
typescript: {
extraFileExtensions: [{ extension: 'phaze', isMixedContent: false, scriptKind: ts.ScriptKind.Deferred }],
getServiceScript(root) { return { code: root, extension: '.tsx', scriptKind: ts.ScriptKind.TSX } },
},
}

The interesting hook is createVirtualCode — Volar calls it the first time it sees a .phaze URI; we instantiate PhazeVirtualCode, which:

  1. Reads the .phaze source from the ts.IScriptSnapshot Volar passes.
  2. Runs phazeFormatTransform(source, { filename }) — the same parser + emit pipeline phaze-compile uses at build time. The output is { code: <synthetic .tsx>, map: <v3 sourcemap> }.
  3. Wraps the emitted .tsx string in a fresh IScriptSnapshot so the TS service treats it like a normal file.
  4. Decodes the v3 map.mappings (VLQ) via @jridgewell/sourcemap-codec and walks each line’s segments into Volar’s offset-based CodeMapping[] format — pre-computing source/generated line-start offsets so each segment’s (line, col) pair converts to a flat byte offset. Every mapping gets data: ALL_FEATURES for now (verification + completion + semantic + navigation + structure + format all enabled).

getServiceScript declares the virtual code’s “physical” file extension and ScriptKind to TypeScript — the TS service sees a .tsx snapshot when it asks “what’s this file?”, which means hover / completion / definition all run their TSX-shaped code paths.

ScriptKind.Deferred in extraFileExtensions tells TS to defer its language-kind selection to our virtual code’s languageId field ('typescriptreact') — that’s how the TS service knows to parse the virtual buffer as TSX rather than picking a default off the extension.

The import type {} from '@volar/typescript' line in language-plugin.ts exists to trigger the module augmentation that adds the typescript?: field to LanguagePlugin — without that import TS doesn’t see the field and rejects the plugin object.

The v3 sourcemap → Volar mapping conversion

Section titled “The v3 sourcemap → Volar mapping conversion”

phaze-compile emits a standard v3 sourcemap from synthetic .tsx to .phaze positions (see phaze-compile → format transform for how the parser + emit track source lines and the Mapper builds the map). v3’s mappings field is a VLQ-encoded string, line-grouped by ; with each line a comma-separated list of segments — [genCol, srcIdx, srcLine, srcCol, nameIdx?]. Volar’s mapping format is offset-based:

interface CodeMapping {
sourceOffsets: number[]
generatedOffsets: number[]
lengths: number[]
data: CodeInformation
}

v3ToVolarMappings is the bridge:

  1. decodeVlq(map.mappings)number[][][] (per-line, per-segment, per-field).
  2. Pre-compute sourceLineStarts and generatedLineStarts once via a one-pass scan over each text for \n positions.
  3. For each [genCol, _srcIdx, srcLine, srcCol] segment, compute srcOffset = sourceLineStarts[srcLine] + srcCol and genOffset = generatedLineStarts[genLineIdx] + genCol.
  4. Emit one CodeMapping per segment, with lengths: [<remaining line length>] so Volar’s range queries work line-by-line.

The conversion runs once per .phaze open and per edit (Volar re-invokes updateVirtualCode on every snapshot change, and PhazeVirtualCode’s constructor re-runs the transform + decode). For typical components (50–200 lines) this is sub-millisecond.

The stdio server is ~30 lines:

const connection = createConnection()
const server = createServer(connection)
connection.onInitialize(async (params) => {
const tsdkPath = params.initializationOptions?.typescript?.tsdk
const tsdk = loadTsdkByPath(tsdkPath, params.locale)
return server.initialize(
params,
createTypeScriptProject(
tsdk.typescript,
tsdk.diagnosticMessages,
() => ({ languagePlugins: [getPhazeLanguagePlugin()] }),
),
[
...createTypeScriptService(tsdk.typescript),
createTypeScriptTwoslashService(tsdk.typescript),
],
)
})
connection.onInitialized(server.initialized)
connection.onShutdown(server.shutdown)
connection.listen()

The pattern mirrors @astrojs/language-server’s server.ts and @vue/language-server’s bin entry. Three pieces matter:

  • tsdk is the path to the workspace’s TypeScript SDK (<workspaceRoot>/node_modules/typescript/lib). The LSP loads tsc from there instead of bundling its own copy, so type-checking matches whatever tsc version your project depends on — same as Vue and Astro do.
  • createTypeScriptProject wires Volar’s project model with our LanguagePlugin. Volar maintains the virtual-code tree across edits; we just register the .phaze plugin.
  • Two services plug into the project: volar-service-typescript (hover, completion, go-to-def, find-refs, semantic diagnostics — routed through our CodeMapping[] back to .phaze coordinates) and volar-service-typescript-twoslash-queries (twoslash query support — used by some MDX-style docs tooling; cheap to include).

The LSP shares the same GlobalRegistry class that phaze-compile uses at build time — one class, separate instances per process. The flow is symmetric to the build path:

  1. Init-time workspace scangetPhazeLanguagePlugin({ workspaceRoot, appPhazePath }) walks the workspace’s .phaze files synchronously (skips node_modules / dist / .git etc.), runs phazeFormatTransform’s parse-only path on each, and registers every @global declaration. The scan blocks the LSP’s onInitialize callback for a few tens of milliseconds — a deliberate trade for order-independence (consumers can be opened first; the registry already knows about them).
  2. Per-file registration — every PhazeVirtualCode construction also calls registry.registerDeclarations(fileName, globalDecls). This handles new files created mid-session AND keeps the registry in lockstep with the latest edit.
  3. Auto-import prefix injection — for any .phaze file that ISN’T app.phaze, buildAutoImportPrefix scans the synthesized .tsx with a fast identifier regex, intersects with the registry, and groups by source file. The result is prepended to the virtual code as import { X, Y } from '<rel>.phaze'; lines.
  4. CodeMapping offset shiftshiftGeneratedOffsets adjusts every existing mapping’s generatedOffsets by the prepended prefix’s byte length. Hover, go-to-def, and find-refs continue to resolve correctly through the prepended imports.

The server derives workspaceRoot from the LSP’s params.workspaceFolders[0]?.uri.fsPath (via vscode-uri’s URI.parse). Projects with a non-default shell location pass initializationOptions.phaze.appPhazePath to override the conventional src/app.phaze.

The build path (phaze-compile’s babel-plugin) uses babel’s ip.scope.hasBinding(name) for proper JS scope analysis — a local declaration shadowing a @global name correctly skips auto-import. The LSP path uses a fast string-level regex scan instead (no babel boot per file edit) — so a shadowing local would still get the import injected. TypeScript’s scope rules handle the shadowing correctly downstream: the auto-import becomes an unused import in that edge case, not incorrect behavior.

phaze-language-tools sits at a different layer from every preceding tool in the pipeline:

  1. Different host. #1#5 plug into Babel, esbuild, Rollup, Vite, Astro, Cloudflare’s Vite plugin. phaze-language-tools plugs into Volar — a Volar host (LSP server, TS-plugin embed, headless CLI) instantiates the LanguagePlugin and lets Volar drive the TS service.
  2. Different runtime. #1#5 run inside the build process (per-file transforms, per-build chunking, per-deploy compilation). phaze-language-tools runs inside the editor’s language-server process — continuously, against live in-memory buffers, every keystroke.
  3. Different deps. It pulls in @volar/language-core, @volar/language-server, @volar/language-service, @volar/typescript, volar-service-typescript, volar-service-typescript-twoslash-queries, @jridgewell/sourcemap-codec, vscode-uri. None of those belong in a build-time package; none of #1#5’s deps belong in a language-server package.
  4. Different consumers. phaze-vscode launches it as an LSP server. Future non-VSCode editor integrations consume the npm package directly. phaze check (Phase 4) embeds the LanguagePlugin for headless type-checking. Bundling these into any of #1#5 would force build-only consumers to install @volar/* they never use.

The package depends on @madenowhere/phaze-compile (workspace) for phazeFormatTransform — the parsing logic is shared. The language services just take the same parsed output the build uses and feed it to a TS service through Volar.

The v1 plugin treats the synthesized .tsx as one root virtual code with line-level mappings. Practical implications:

  • Range precision is line-level. Hover / go-to-def land on the right .phaze line; the exact column inside multi-token lines follows the v3 segments emitted by phaze-compile. No sub-token range narrowing.
  • Diagnostics fire at the synthesized JSX, then map back. Errors from useState typos or wrong-typed bind:value show up in the .phaze file at the right line. Error messages reference TS-compiler terms (not phaze-compile diagnostic codes — those come from phaze-compile at build time).
  • No semantic highlighting beyond TS. Token colorization on the fence text comes from phaze-vscode’s TextMate grammar, not the LSP. The LSP only contributes semantic highlight inside the TSX body.
  • No embedded styles / ---data validation yet. v1 treats ---data blocks as TS-bound type-data syntax; future versions can split it into a separate embedded code (Volar supports nested virtual codes via the embeddedCodes field on VirtualCode) for stricter validation.

These are surface-tuning, not architectural — the Volar pipeline already supports finer mappings + embedded codes; v1 just doesn’t use them yet. The plumbing is in place to add them without changing the package shape.

Install phaze-vscode (#7) from the marketplace — it bundles this package and spawns the language server automatically. No config required.

Use it in another LSP-capable editor (Cursor / Sublime / neovim)

Section titled “Use it in another LSP-capable editor (Cursor / Sublime / neovim)”

Install the npm package as a workspace dependency, then point your editor’s LSP client at the phaze-language-server bin:

Terminal window
pnpm add -D @madenowhere/phaze-language-tools

LSP client config (the exact shape varies per editor — what matters: stdio transport, point at the bin, pass initializationOptions.typescript.tsdk as the absolute path to <workspaceRoot>/node_modules/typescript/lib):

{
"command": ["node", "./node_modules/@madenowhere/phaze-language-tools/bin/phaze-language-server.js", "--stdio"],
"fileTypes": ["phaze"],
"initializationOptions": {
"typescript": { "tsdk": "./node_modules/typescript/lib" }
}
}

Embed the LanguagePlugin in a custom Volar host

Section titled “Embed the LanguagePlugin in a custom Volar host”
import { createTypeScriptProject } from '@volar/language-service'
import { getPhazeLanguagePlugin } from '@madenowhere/phaze-language-tools'
const project = createTypeScriptProject(ts, undefined, () => ({
languagePlugins: [getPhazeLanguagePlugin()],
}))

This is the shape phaze check (Phase 4) will use — drive the project headlessly, walk all .phaze files, collect diagnostics, exit non-zero on errors.

Phase 4 — phaze check — wraps this package as a CLI: walks the project, instantiates the same LanguagePlugin, asks the TS service for diagnostics on every .phaze file, exits non-zero if any are errors. Same model as vue-tsc / astro check / svelte-check. Same single source of truth for what “type-correct phaze code” means across editor + CI.