Skip to content

1. phaze-tsplugin

1. phaze-tsplugin ← editor (TS Language Service) ← you are here
2. phaze-compile ← build-time AST rewriting
├── babel-plugin.ts ← the actual Babel plugin (all the visitors live here)
└── vite-plugin.ts ← thin wrapper that adapts it for Vite
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)

The first tool in the pipeline runs farthest from the user’s eventual deployed app — it lives inside the editor. phaze-tsplugin is a TypeScript Language Service plugin, not a build-time transform. It produces no output bytes, runs no AST passes, and never sees production. Its sole job is to silence one specific category of diagnostic that TypeScript would otherwise (incorrectly) raise in editors that consume Phaze’s JSX use:directive syntax.

This page covers both the why — what problem this tool exists to solve, and why it’s a separate package from babel-plugin.ts in phaze-compiler — and the full reference (install steps, tsconfig.json wiring, diagnostic codes filtered).

Write a phaze component that uses a use:autofocus directive on an input:

import { autofocus } from '@madenowhere/phaze-directives'
import { signal } from '@madenowhere/phaze'
const value = signal('')
<input use:autofocus={true} value={value} />

The IDE shows the autofocus import faded or wavy-underlined with the message:

'autofocus' is declared but its value is never read. ts(6133)

If you also have "noUnusedLocals": true in tsconfig.json, tsc --noEmit errors on it.

This is wrong. The import IS used — <input use:autofocus={true} /> is a Phaze JSX namespace attribute that phaze-compile rewrites at build time to autofocus(__el, () => true), which references the imported binding. The problem is the rewrite happens at build time, but TypeScript’s reference tracker analyzes source. From source-level TS’s perspective, use:autofocus is a JSXNamespacedName attribute — not a JSXIdentifier call or property access — and the reference tracker doesn’t traverse into it looking for binding usages.

So TS counts autofocus as imported-and-unused. Hence TS6133.

phaze-tsplugin patches the TypeScript Language Service’s getSemanticDiagnostics and getSuggestionDiagnostics methods. When the LS returns its diagnostic list, the plugin walks the same file’s AST looking for JSXNamespacedName attributes with namespace use, collects the local-identifier names referenced, and drops any TS6133/TS6192/TS6196 diagnostic whose target name appears in that set.

TypeScript Language Service Editor diagnostics panel
───────────────────────── ────────────────────────
ts.getSemanticDiagnostics(file)
[TS6133: 'autofocus' is unused, ← would show as wavy underline
TS6133: 'unrelatedImport' unused]
phaze-tsplugin wraps the response:
├── walks file's AST
├── finds <input use:autofocus={…}/>
├── records {autofocus}
└── drops the autofocus diagnostic
[TS6133: 'unrelatedImport' unused] ← only the genuinely-unused one survives
wavy underline only on
the genuinely-unused import

Other unused-binding diagnostics survive untouched. Only the use:NAME-namespace false positives get filtered.

Why not just register the namespace handling in the TypeScript types?

Section titled “Why not just register the namespace handling in the TypeScript types?”

A common reaction: “can’t @madenowhere/phaze ship a TypeScript type that says ‘a use:foo JSX attribute references the identifier foo’?”

Short answer: no — TypeScript’s reference graph isn’t a type-level concept. Types describe value shapes (what’s assignable to what); the reference graph is computed by the compiler’s binder pass walking AST nodes and recording identifier mentions. There’s no way to express “this JSX attribute counts as a reference to this identifier” in a .d.ts file. The reference tracker has to be modified by patching the language service at runtime, which is exactly what a Language Service plugin does.

This is the same mechanism Astro’s TS plugin uses to teach TypeScript about .astro files, the same mechanism Vue’s TS plugin uses for SFC blocks, and the same mechanism Prisma’s plugin uses for generated client types. All of them sit at the same “patch the diagnostic stream” layer.

Why it’s a separate package from babel-plugin.ts in phaze-compiler

Section titled “Why it’s a separate package from babel-plugin.ts in phaze-compiler”

The babel plugin lives at packages/compile/src/babel-plugin.ts and is the engine inside phaze-compile. It’s the one that actually rewrites the use:autofocus namespace attribute into the post-creation IIFE that references autofocus. So why isn’t the editor-side plugin part of the same package?

Three reasons:

  1. Different host APIs, different ASTs. babel-plugin.ts in phaze-compiler uses Babel’s PluginObj<{ visitor: … }> API and Babel’s AST shape (@babel/types). phaze-tsplugin uses tsTypes.LanguageService wrap and TypeScript’s compiler API AST (ts.SourceFile, ts.JsxAttribute, …). The two AST shapes are structurally similar but the type definitions are not interchangeable, and the plugin contracts have no shared base.

  2. Different runtime processes. babel-plugin.ts in phaze-compiler runs inside the build’s Node process — once per file, against your committed source, as part of Vite’s transform pipeline. phaze-tsplugin runs inside the TypeScript Language Server process the editor spawns (tsserver) — continuously, against your in-memory editor buffer, every keystroke. Shipping them as one package would force editor-only consumers (who never need Babel) to install Babel, and force build consumers (who never need TypeScript’s LanguageService API) to install typescript/lib/tsserverlibrary.

  3. Different concerns. babel-plugin.ts produces transformed source — generates code. phaze-tsplugin filters diagnostics — modifies a read-only output. One writes; the other reads-and-suppresses. Combining them into one codebase would tangle two completely different mental models.

The two are complementary: babel-plugin.ts in phaze-compiler makes your use:foo syntax actually do something at runtime. phaze-tsplugin makes the editor stop yelling at the import that babel-plugin.ts in phaze-compiler will use to do that. You need both to get clean Phaze DX.

Terminal window
pnpm add -D @madenowhere/phaze-tsplugin

Then enable it in your tsconfig.json:

{
"compilerOptions": {
"plugins": [{ "name": "@madenowhere/phaze-tsplugin" }]
}
}

Editor pickup:

  • VS Code — picks up the plugin automatically via its bundled TypeScript language server. Run “TypeScript: Restart TS Server” from the command palette the first time. After that, edits to the plugin (or tsconfig.json reloads) take effect on the next TS-server restart.
  • JetBrains IDEs — same tsconfig.json setup; the IDE’s TypeScript service picks it up.
  • Neovim / other editors using tsserver — works as long as the editor uses your repo’s typescript install (not a bundled fallback).

If you use noUnusedLocals: true in CI / tsc --noEmit, the plugin doesn’t apply at the command-line — tsc doesn’t load language-service plugins. CI builds that need the same suppression should either omit noUnusedLocals for the affected files or use // @ts-expect-error comments. In practice, app code passes CI cleanly because the import IS used at the JSX level; only the editor’s reference tracker doesn’t see it. The plugin’s role is purely UX.

TS codeWhat it meansPhaze case
TS6133“‘X’ is declared but its value is never read”Most common — import used only via use:X
TS6192”All imports in import declaration are unused”An import block that only contains use:X-consumed names
TS6196“‘X’ is declared but only used in type position (rarer)“Less common but the namespace augmentation can trigger it

Only diagnostics for names matching use:NAME attributes in the same file get filtered. Genuinely-unused bindings still surface.

This plugin is intentionally scoped to one thing. It does not:

  • Transform or rewrite source code (that’s phaze-compile)
  • Run during your build pipeline (Vite / Oxc / Astro never see it)
  • Affect your shipped bundle in any way
  • Modify the runtime
  • Add JSX-type augmentations (those live in @madenowhere/phaze’s types and in directive packages’ ambient declarations)

If you remove the plugin from tsconfig.json, builds still work — you just see TS6133 / TS6192 false positives in the editor and on tsc --noEmit for files that only consume directive imports via use:.

packages/phaze-tsplugin/src/index.ts is the entire plugin — under 200 lines. The diagnostic-filter walk is straightforward: collect the set of JSXNamespacedName identifiers in the file whose namespace is use, then drop any unused-binding diagnostic whose target name is in that set.

It’s 200 lines and an opt-in tsconfig.json entry. Marginal install/maintenance cost. The payoff: every directive-importing file in your app stops flagging false unused-import errors, and noUnusedLocals: true CI passes work end-to-end without // eslint-disable / // @ts-expect-error boilerplate per file.

For a UI framework whose canonical surface relies on JSX namespace attributes (use:, class:, bind:), this is table stakes. Without it, the editor experience pushes users away from the namespaces — defeating the directive system before they ever ship a build.