@pithyjs/compiler
@pithyjs/compiler
Parser and transformer for PithyJS .pithy single-file components. Converts templates into an intermediate AST and generates executable JavaScript modules.
Installation
npm install @pithyjs/compiler
pnpm add @pithyjs/compiler
yarn add @pithyjs/compiler
Overview
The compiler provides three main capabilities:
- Parsing — Parse
.pithysingle-file components and extract<template>,<script>, and<style>blocks. - AST Transformation — Convert parse5 DOM trees into the PithyNode AST used by the runtime.
- Module Generation — Compile parsed
.pithyfiles into ES modules that export PithyJS components.
The .pithy file format
A .pithy single-file component has three optional blocks — <template>, <script>, and <style>:
<template>
<button @click="increment" class="btn" [class.active]="count() > 0">+</button>
<p>count: {{ count() }}</p>
</template>
<script lang="ts">
import { signal } from "@pithyjs/signals";
const count = signal(0);
const increment = () => count.set(count() + 1);
</script>
<style>
.btn { color: red; }
</style>
<template>— HTML with{{ }}interpolations and directives (@click,[class.active],@if,@for, …), same ashtml\`` templates.<script lang="ts">— component logic. Every top-levelconst/let/var/function/classbinding it declares — and every import — is automatically available to the template, no manual wiring. (Other top-level forms such asenum/namespacearen't auto-collected; access those through aconstalias.)<style>— scoped to the component (a per-file scope id is generated and applied to the template + injected CSS).
Each file compiles to a module whose default export is the component factory:
export default function Counter() {
/* returns { el, destroy } */
}
Using .pithy files in an app
.pithy files are transformed by pithyPlugin() from @pithyjs/vite-plugin:
// vite.config.ts
import { pithyPlugin } from "@pithyjs/vite-plugin";
export default defineConfig({ plugins: [pithyPlugin() /* … */] });
Add a module declaration so TypeScript understands .pithy imports:
// vite-env.d.ts
declare module "*.pithy" {
const component: (props?: Record<string, unknown>) => {
el: HTMLElement | DocumentFragment;
destroy: () => void;
};
export default component;
}
Then import and render it like any other component:
import Counter from "./Counter.pithy";
// use <Counter></Counter> inside an html`` template
HMR: editing a
.pithyfile invalidates its module and reloads the component. Signal state resets on reload — an accepted 0.1 limitation.
Usage
Parsing a .pithy file
import { parsePithyFile } from '@pithyjs/compiler';
const result = parsePithyFile(`
<template>
<button @click="increment()">Count: {{ count() }}</button>
</template>
<script lang="ts">
import { signal } from '@pithyjs/signals';
const count = signal(0);
const increment = () => count.set(n => n + 1);
</script>
<style scoped>
button { color: var(--color-primary); }
</style>
`);
console.log(result.template); // '<button @click="increment()">...'
console.log(result.script); // { content: '...', lang: 'ts' }
console.log(result.style); // { content: '...', lang: 'css', scoped: true }
console.log(result.identifiers); // ['increment', 'count']
Extracting directives from attributes
import { extractDirectivesFromAttrs } from '@pithyjs/compiler';
const directives = extractDirectivesFromAttrs({
'@click': 'handleClick()',
'@if': 'isVisible()',
'[disabled]': '!enabled()',
'[class.active]': 'isActive()',
});
console.log(directives.events); // { click: 'handleClick()' }
console.log(directives.if); // 'isVisible()'
console.log(directives.bound); // { disabled: '!enabled()' }
console.log(directives.classMap); // { active: 'isActive()' }
Compiling to a module
import { parsePithyFile, compileToModule } from '@pithyjs/compiler';
const sourceCode = `
<template>
<button @click="increment()">Count: {{ count() }}</button>
</template>
<script lang="ts">
import { signal } from '@pithyjs/signals';
const count = signal(0);
const increment = () => count.set(n => n + 1);
</script>
`;
const parsed = parsePithyFile(sourceCode);
const moduleCode = compileToModule(parsed, { filename: 'Counter.pithy' });
// Produces an ES module string that exports a PithyJS component function
API Reference
| API | Signature | Stability | Description |
|---|---|---|---|
| parsePithyFile | (code: string) => ParsedPithyFile |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| extractDirectivesFromAttrs | (attrs: Record<string, string>) => ParsedDirectives |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| forEachDirectiveAttr | (d: ParsedDirectives, cb: (attr: string, value: string) => void) => void |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| collectIfChain | (nodes: PithyNode[], startIndex: number) => PithyNode[] |
stable | - |
| isDefaultCase | (node: PithyNode) => boolean |
stable | - |
| isStructuralNode | (node: PithyNode) => boolean |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| parse5ToPithyTree | `(node: Parse5Node, importedIdentifiers?: Set, tagNameCasingMap: Map<number, string>, parentId?: string, attributeNameCasingMap?: Map<string, string>, source?: string) => PithyNode | null` | stable |
| API | Signature | Stability | Description |
|---|---|---|---|
| ParseFragmentFn | (html: string, options?: { sourceCodeLocationInfo?: boolean }) => { childNodes: unknown[] } |
experimental | - |
| parseTemplate | (src: string, parseFragment: ParseFragmentFn) => { nodes: PithyNode[] } |
experimental | - |
| expandSelfClosingComponents | (src: string) => { result: string; insertions: Array<{ at: number; len: number }> } |
experimental | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| extractOriginalTagNames | (template: string) => Map<number, string> |
stable | - |
| extractOriginalAttributeNames | (template: string) => Map<string, string> |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| compileToModule | (parsed: ParsedPithyFile, options?: { filename?: string; scopeId?: string }) => string |
stable | - |
| API | Signature | Stability | Description |
|---|---|---|---|
| warnOnLowercaseComponentTags | (template: string, importedIdentifiers: Set<string>) => void |
stable | - |
Examples
should handle @routerOutlet (camelCase)
const attrs = { '@routerOutlet': '' };
const directives = extractDirectivesFromAttrs(attrs);
directives.routerOutlet; // → true
should handle @if directive
const attrs = { '@if': 'condition()' };
const directives = extractDirectivesFromAttrs(attrs);
directives.if; // → 'condition()'
should handle @for directive
const attrs = { '@for': '(item, i) of items' };
const directives = extractDirectivesFromAttrs(attrs);
directives.for; // → defined
directives.for?.itemVar; // → 'item'
directives.for?.indexVar; // → 'i'
directives.for?.collectionExpr; // → 'items'
should handle @defer directive with visible
const attrs = { '@defer': 'visible' };
const directives = extractDirectivesFromAttrs(attrs);
directives.defer; // → 'visible'
should extract @click as event
const attrs = { '@click': 'handleClick()' };
const directives = extractDirectivesFromAttrs(attrs);
directives.events?.click; // → 'handleClick()'
should handle [attr] syntax
const attrs = { '[disabled]': '!enabled()' };
const directives = extractDirectivesFromAttrs(attrs);
directives.bound?.disabled; // → '!enabled()'
should handle @ref directive
const attrs = { '@ref': 'inputRef' };
const directives = extractDirectivesFromAttrs(attrs);
directives.ref; // → 'inputRef'
should handle @error directive
const attrs = { '@error': 'errorSignal' };
const directives = extractDirectivesFromAttrs(attrs);
directives.error; // → 'errorSignal'
import { forEachDirectiveAttr } from '@pithyjs/compiler';
forEachDirectiveAttr(node.directives, (attr, value) => {
el.setAttribute(attr, value);
});
// Sets: data-on-click="handler()", data-ref="myRef", etc.
// Given: [div@if, span@else-if, p@else, footer]
collectIfChain(nodes, 0) // → [div@if, span@else-if, p@else]
isDefaultCase({ type: 'element', attrs: { '@default': '' } }) // → true
isDefaultCase({ type: 'element', attrs: { class: 'box' } }) // → false
isStructuralNode({ type: 'element', directives: { if: 'show()' } }) // → true
isStructuralNode({ type: 'element', attrs: { '@default': '' } }) // → true
isStructuralNode({ type: 'element', directives: { ref: 'myRef' } }) // → false
isStructuralNode({ type: 'text', textContent: 'hello' }) // → false
Architecture
The compiler processes .pithy files through three layers:
.pithy source → parsePithyFile() → { template, script, style }
↓
template string → parse5 → parse5ToPithyTree() → PithyNode AST
↓
{ parsed } → compileToModule() → ES module string
Key types
PithyNode— Core AST node (element, text, or slot)ParsedDirectives— All recognized directives from element attributesParsedPithyFile— Result of parsing a.pithyfileInterpolationInfo— Metadata for{{ expression }}interpolations
Directive mapping
The forEachDirectiveAttr() function is the single source of truth for mapping ParsedDirectives fields to data-* attributes used by the runtime binders.
Testing
| Feature | Unit | Integration | E2E | Status |
|---|---|---|---|---|
| extractDirectivesFromAttrs | - | - | - | Missing unit, integration |
cd packages/compiler && pnpm test
License
MIT