npm.io
0.1.0-beta.0 • Published 2d ago

@pithyjs/compiler

Licence
MIT
Version
0.1.0-beta.0
Deps
1
Size
152 kB
Vulns
0
Weekly
0

@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:

  1. Parsing — Parse .pithy single-file components and extract <template>, <script>, and <style> blocks.
  2. AST Transformation — Convert parse5 DOM trees into the PithyNode AST used by the runtime.
  3. Module Generation — Compile parsed .pithy files 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 as html\`` templates.
  • <script lang="ts"> — component logic. Every top-level const/let/var/function/class binding it declares — and every import — is automatically available to the template, no manual wiring. (Other top-level forms such as enum/namespace aren't auto-collected; access those through a const alias.)
  • <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 .pithy file 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 attributes
  • ParsedPithyFile — Result of parsing a .pithy file
  • InterpolationInfo — 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