@sprlab/wccompiler
v0.16.7
Published
Zero-runtime compiler that transforms .wcc single-file components into native web components with signals-based reactivity
Maintainers
Readme
wcCompiler
Zero-runtime compiler that transforms .wcc single-file components into native web components. No framework, no virtual DOM, no runtime — just vanilla JavaScript custom elements with signals-based reactivity.
Install
npm install -D @sprlab/wccompilerQuick Start
1. Create a component
<!-- src/wcc-counter.wcc -->
<script>
import { defineComponent, signal } from 'wcc'
export default defineComponent({
tag: 'wcc-counter',
})
const count = signal(0)
function increment() {
count.set(count() + 1)
}
</script>
<template>
<div class="counter">
<span>{{count()}}</span>
<button @click="increment">+</button>
</div>
</template>
<style>
.counter { display: flex; gap: 8px; align-items: center; }
</style>2. Build
npx wcc build3. Use
<script type="module" src="dist/wcc-counter.js"></script>
<wcc-counter></wcc-counter>The compiled output is a single .js file with zero dependencies — works in any browser that supports custom elements.
How It Works
src/wcc-counter.wcc dist/wcc-counter.js
┌──────────────────┐ ┌──────────────────────────┐
│ <script> │ │ // Reactive runtime │
│ signal, effect │ ───► │ // (inline or imported) │
│ <template> │ build │ class WccCounter extends │
│ {{count()}} │ │ HTMLElement { ... } │
│ <style> │ │ customElements.define(...) │
└──────────────────┘ └──────────────────────────┘
+
dist/__wcc-signals.js (shared mode)The compiler reads your .wcc source, extracts script/template/style blocks, analyzes reactive declarations, walks the template DOM for bindings and directives, and generates a self-contained custom element class. CSS is automatically scoped by tag name.
Single File Component (.wcc)
wcCompiler uses a single-file component format with the .wcc extension. Each file contains three blocks:
<script>— Component logic (signals, props, events, lifecycle)<template>— HTML template with directives<style>— Scoped CSS
<script>
import { defineComponent, signal } from 'wcc'
export default defineComponent({
tag: 'wcc-my-component',
})
const message = signal('Hello')
</script>
<template>
<p>{{message()}}</p>
</template>
<style>
p { color: steelblue; }
</style>Use <script lang="ts"> for TypeScript support. The CLI discovers and compiles all .wcc files in your source directory.
Coming from Vue?
If you're familiar with Vue, here's how wcCompiler maps:
| Vue | wcCompiler |
|-----|------------|
| ref(0) | signal(0) |
| computed(() => ...) | computed(() => ...) |
| watch(source, cb) | watch(source, cb) |
| v-if | if |
| v-else-if | else-if |
| v-else | else |
| v-for="item in items" | each="item in items()" |
| v-show | show |
| v-model | model |
| @click | @click |
| :prop | :prop |
| defineProps() | defineProps() |
| defineEmits() | defineEmits() |
| onMounted() | onMount() |
| onUnmounted() | onDestroy() |
| <slot> | <slot> |
Key differences: signals use .set() to write and () to read. Template directives have no v- prefix. Output is vanilla JS with no runtime framework.
Reactivity
Signals
const count = signal(0) // create
count() // read → 0
count.set(5) // write → 5Note:
.set()is the public API for writing signals. The compiled output uses direct invocation (count(5)) as an internal optimization — both forms are equivalent, but.set()is the recommended way to write signals in your source code.
Computed
const doubled = computed(() => count() * 2)
doubled() // auto-updates when count changesEffects
effect(() => {
console.log('Count is:', count()) // re-runs on change
})Effects support cleanup — return a function to run before re-execution:
effect(() => {
const id = setInterval(() => tick.set(tick() + 1), 1000)
return () => clearInterval(id) // called before re-run
})Batch
Group multiple signal writes into a single update pass:
import { batch } from 'wcc'
batch(() => {
firstName.set('John')
lastName.set('Doe')
age.set(30)
})
// Effects run once after all three writes, not three timesNested batches are supported — effects flush only when the outermost batch completes.
Watch
// Watch a signal directly
watch(count, (newVal, oldVal) => {
console.log(`Changed from ${oldVal} to ${newVal}`)
})
// Watch a getter function (useful for props or derived values)
watch(() => props.count, (newVal, oldVal) => {
console.log(`Prop changed: ${oldVal} → ${newVal}`)
})
// Watch a derived expression
watch(() => count() * 2, (newVal, oldVal) => {
console.log(`Doubled changed: ${oldVal} → ${newVal}`)
})watch observes a specific signal or getter and provides both old and new values. The callback does not run on initial mount — only on subsequent changes.
Constants
const TAX_RATE = 0.21 // non-reactive, no signal() wrapperProps
const props = defineProps({ label: 'Click', count: 0 })<wcc-counter label="Clicks:" count="5"></wcc-counter>You can also call defineProps without assignment — the props are available by name in the template:
defineProps({ label: 'Click' })<span>{{label}}</span>TypeScript generics:
const props = defineProps<{ label: string, count: number }>({ label: 'Click', count: 0 })Props are reactive — they update when attributes change. Supports boolean and number coercion.
Custom Events
const emit = defineEmits(['change', 'reset'])
function handleClick() {
emit('change', count())
}TypeScript call signatures:
const emit = defineEmits<{ (e: 'change', value: number): void }>()The compiler validates emit calls against declared events at compile time.
defineModel (Two-Way Binding)
defineModel declares a prop that supports two-way binding across frameworks:
import { defineModel } from 'wcc'
const count = defineModel({ name: 'count', default: 0 })
const title = defineModel({ name: 'title', default: 'untitled' })Read and write like a signal:
count() // read current value
count.set(5) // write — updates internal state + emits eventsEvents emitted on write:
| Event | Purpose |
|-------|---------|
| count-changed | Kebab-case — for Vue plugin, addEventListener |
| countChanged | camelCase — for Angular, direct binding |
| countChange | Angular [(count)] banana-box syntax |
| wcc:model | Generic — for vanilla JS and WCC-to-WCC |
Usage per framework:
<!-- Vue (with plugin) -->
<wcc-counter v-model:count="ref"></wcc-counter>
<!-- Vue (without plugin) -->
<wcc-counter :count="ref" @count-changed="ref = $event.detail"></wcc-counter>// React (with wrapper)
<WccCounter count={count} onCountChange={(value) => setCount(value)} />
// React (native CE)
<wcc-counter count={count} oncountchanged={(e) => setCount(e.detail)} /><!-- Angular (zero-config two-way) -->
<wcc-counter [(count)]="signal"></wcc-counter>
<!-- Angular (manual) -->
<wcc-counter [count]="signal()" (countChange)="signal.set($event.detail)"></wcc-counter>Template Directives
Text Interpolation
Signals and computeds require () to read their value in templates:
<span>{{count()}}</span>
<p>You have {{items().length}} items.</p>
<span>{{doubled()}}</span>Props accessed without assignment use their name directly (no parentheses):
<span>{{label}}</span>
<p>Hello, {{name}}!</p>Event Binding
<button @click="increment">+</button>
<input @input="handleInput">Event handlers support expressions and inline arguments:
<button @click="removeItem(item)">×</button>
<button @click="() => doSomething()">Do it</button>Conditional Rendering
<div if="status() === 'active'">Active</div>
<div else-if="status() === 'pending'">Pending</div>
<div else>Inactive</div>List Rendering
<li each="item in items()">{{item.name}}</li>
<li each="(item, index) in items()">{{index}}: {{item.name}}</li>The source expression calls the signal (items()) to read the current array. Supports keyed rendering with :key:
<li each="item in items()" :key="item.id">{{item.name}}</li>Numeric ranges are also supported:
<li each="n in 5">Item {{n}}</li>Nested Directives in each
Directives work inside each blocks — including conditionals and nested loops:
<div each="user in users()">
<span>{{user.name}}</span>
<span if="user.active" class="badge">Active</span>
<span else class="badge muted">Inactive</span>
<ul>
<li each="role in user.roles">{{role}}</li>
</ul>
</div>Visibility Toggle
<div show="isVisible()">Shown or hidden via CSS display</div>Two-Way Binding
<input type="text" model="name">
<input type="number" model="age">
<input type="checkbox" model="agree">
<input type="radio" name="color" value="red" model="color">
<select model="country">...</select>
<textarea model="bio"></textarea>Attribute Binding
<a :href="url()">Link</a>
<button :disabled="isLoading()">Submit</button>
<div :class="{ active: isActive(), error: hasError() }">...</div>
<div :style="{ color: textColor() }">...</div>Template Refs
const canvas = templateRef('myCanvas')
onMount(() => {
const ctx = canvas.value.getContext('2d')
})<canvas ref="myCanvas"></canvas>Slots
Named Slots
Component template:
<div class="card">
<slot name="header">Default Header</slot>
<slot>Default Body</slot>
<slot name="footer">Default Footer</slot>
</div>Consumer:
<wcc-card>
<template #header><strong>Custom Header</strong></template>
<p>Custom body content</p>
<template #footer>Custom footer</template>
</wcc-card>Scoped Slots
Component template (passes reactive data to consumer):
<slot name="stats" :likes="likes">Likes: {{likes}}</slot>Consumer (receives data via template props):
<wcc-card>
<template #stats="{ likes }">🔥 {{likes}} likes!</template>
</wcc-card>Nested Components
Components can import and use other components in their templates using PascalCase tags:
<!-- src/nested/wcc-profile.wcc -->
<script>
import { defineComponent, signal } from 'wcc'
import WccBadge from './wcc-badge.wcc'
export default defineComponent({ tag: 'wcc-profile' })
const count = signal(0)
function increment() {
count.set(count() + 1)
}
</script>
<template>
<div class="profile">
<WccBadge :count="count()" @click="increment"></WccBadge>
</div>
</template>- Named import:
import WccBadge from './wcc-badge.wcc'— the PascalCase identifier becomes the tag alias in the template - Side-effect import:
import './wcc-child.wcc'— registers the child without using it in the template (for programmatic creation) - Reactive props: Use
:prop="expr"to pass reactive data down — updates automatically when the expression changes - Event listening: Use
@event="handler"to listen to custom events emitted by the child - Compile-time validation: Using a PascalCase tag without a matching import throws an error at build time
- Hyphenated tags: Tags like
<my-element>without a corresponding import are treated as plain custom elements (no import generated)
Lifecycle Hooks
onMount(() => {
console.log('Component connected to DOM')
})
onMount(async () => {
const data = await fetch('/api/items').then(r => r.json())
items.set(data)
})
onDestroy(() => {
console.log('Component removed from DOM')
})Async callbacks are wrapped in an IIFE — connectedCallback itself stays synchronous.
Details:
- Multiple
onMount/onDestroycalls are supported — they all run in declaration order connectedCallbackis idempotent — re-mounting a component (e.g., moving it in the DOM) re-attaches listeners and effects cleanly- All effects and event listeners are automatically cleaned up in
disconnectedCallbackvia AbortController
CSS Scoping
Styles are automatically scoped to the component using tag-name prefixing:
/* Input */
.counter { display: flex; }
/* Output */
wcc-counter .counter { display: flex; }@media rules are recursively scoped. @keyframes are preserved without prefixing.
TypeScript
Use <script lang="ts"> in your .wcc file for full type support:
<script lang="ts">
import { defineComponent, defineProps, defineEmits, signal, computed, watch, defineExpose } from 'wcc'
export default defineComponent({
tag: 'wcc-typescript',
})
const props = defineProps<{ title: string, count: number }>({ title: 'Demo', count: 0 })
const emit = defineEmits<{ (e: 'update', value: number): void }>()
const doubled = computed<number>(() => props.count * 2)
const watchLog = signal<string>('(no changes yet)')
watch(() => props.count, (newVal, oldVal) => {
watchLog.set(`count changed: ${oldVal} → ${newVal}`)
})
function handleUpdate(): void {
emit('update', doubled())
}
defineExpose({ doubled, handleUpdate, watchLog })
</script>
<template>
<div class="demo">
<span>{{title}}: {{count}}</span>
<span>Doubled: {{doubled()}}</span>
<span>Watch: {{watchLog()}}</span>
<button @click="handleUpdate">Update</button>
</div>
</template>
<style>
.demo { font-family: sans-serif; }
</style>defineExpose() exposes methods and properties for external access via ref.
// wcc-timer.wcc — exposes start/stop/elapsed
const elapsed = signal(0)
let interval = null
function start() { interval = setInterval(() => elapsed.set(elapsed() + 1), 1000) }
function stop() { clearInterval(interval) }
defineExpose({ elapsed, start, stop })<!-- Parent component accessing exposed API -->
<script>
import { defineComponent, templateRef, onMount } from 'wcc'
import './wcc-timer.wcc'
export default defineComponent({ tag: 'wcc-app' })
const timer = templateRef('timer')
onMount(() => {
timer.value.start() // call exposed method
console.log(timer.value.elapsed) // read exposed signal
})
</script>
<template>
<wcc-timer ref="timer"></wcc-timer>
</template>The language server automatically generates a typed interface (PascalCase of the tag name) that can be imported by consumers:
// In the parent component:
import type { WccTimer } from './wcc-timer.wcc'
const timer = templateRef<WccTimer>('timer')
timer.value!.start() // ✅ typedCLI
wcc build # Compile all .wcc files from input/ to output/
wcc build --bundle # Compile + produce a single bundle.js (works from file://)
wcc build --minify # Compile with minification
wcc build --bundle --minify # Production bundle (smallest output)
wcc dev # Build + watch + live-reload dev serverThe CLI discovers all .wcc files in your source directory and compiles each into a standalone .js file.
Bundle Mode
The --bundle flag produces a single bundle.js file that includes all components and their dependencies in one IIFE (Immediately Invoked Function Expression). This file:
- Works with
<script src="bundle.js">(notype="module"needed) - Works from
file://protocol (no server required) - Includes all child component imports resolved and inlined
- Includes the reactive runtime
- Supports
--minifyfor production
<!-- Works by double-clicking the HTML file — no server needed -->
<!DOCTYPE html>
<html>
<body>
<wcc-my-app></wcc-my-app>
<script src="dist/bundle.js"></script>
</body>
</html>When to use --bundle:
- Static HTML files opened from disk
- Electron apps loading local files
- Offline-first applications
- Quick prototyping without a dev server
- Distributing a complete app as HTML + JS
When NOT to use --bundle:
- Apps served via HTTP (use ES modules for better caching)
- When you need per-component lazy loading
- When using a bundler like Vite/Webpack (they handle bundling themselves)
Configuration
Create wcc.config.js in your project root:
export default {
port: 4100, // dev server port (default: 4100)
input: 'src', // source directory (default: 'src')
output: 'dist', // output directory (default: 'dist')
standalone: false // inline runtime per component (default: false)
}All options are optional — defaults shown above.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| port | number | 4100 | Dev server port for wcc dev |
| input | string | 'src' | Source directory containing .wcc files |
| output | string | 'dist' | Output directory for compiled .js files |
| standalone | boolean | false | Inline reactive runtime in each component |
Standalone Mode
Controls whether the reactive runtime is inlined in each component or imported from a shared module.
// wcc.config.js
export default {
standalone: true // inline runtime in every component (default: false)
}standalone: false(default) — Components import the runtime from a shared__wcc-signals.jsfile. Smaller per-component size when using multiple components.standalone: true— Each component includes the full reactive runtime inline. Zero external dependencies per component.
Output difference:
Default (false): component.js → imports __wcc-signals.js
Standalone (true): component.js → runtime inlined, zero importsWhen to use standalone:
- Publishing components as npm packages
- Embedding widgets in third-party sites
- CDN distribution (
<script src="component.js">) - Micro-frontends where you don't control the host
When NOT to use standalone:
- Apps with multiple components (runtime would be duplicated in each)
- Internal projects where you control the build
Per-Component Override
Override the global setting for individual components:
<script>
import { defineComponent, signal } from 'wcc'
export default defineComponent({
tag: 'wcc-widget',
standalone: true, // this component is self-contained regardless of global config
})
</script>Component-level standalone always takes precedence over the global config. This lets you have a project with shared runtime but mark specific components as fully self-contained for distribution.
Reactive Scope Isolation
Each standalone component has its own isolated reactive runtime. Signals from component A cannot be observed by effects in component B — they are completely independent. This is by design for distribution scenarios where components must be self-contained. If you need cross-component reactivity (e.g., shared state), use the default shared mode (standalone: false).
Framework Integrations
WCC components are native custom elements — they work in any framework. Props, events, and named slots work natively with zero WCC-specific config. Two-way binding is zero-config in Angular; Vue requires a plugin. Scoped slots require a framework plugin or directive for idiomatic syntax.
Feature Support Matrix
| Feature | Vue (plugin) | Angular (directive) | React 19 (plugin) |
|---------|--------------|--------------------|--------------------|
| Props | ✅ :count="ref" | ✅ [count]="signal()" | ✅ count={state} |
| Events | ✅ @count-changed="handler($event.detail)" | ✅ (count-changed)="handler($event.detail)" | ✅ oncountchanged={(e) => handler(e.detail)} |
| Two-way binding | ✅ v-model:count="ref" | ✅ [(count)]="signal" | ❌ Not applicable |
| Default slot | ✅ children | ✅ children | ✅ children |
| Named slots | ✅ <template #name> | ✅ <div slot-name> | ✅ <WccCard.Header> |
| Scoped slots | ✅ <template #name="{ prop }"> | ✅ <ng-template slot="name" let-prop> | ✅ <WccList.Item>{(prop) => jsx}</WccList.Item> |
Vue (with wccVuePlugin)
// vite.config.js
import { wccVuePlugin } from '@sprlab/wccompiler/integrations/vue'
export default defineConfig({ plugins: [wccVuePlugin()] })<script setup>
import { ref } from 'vue'
const count = ref(0)
const text = ref('')
</script>
<template>
<!-- Props -->
<wcc-counter :count="count" label="Clicks"></wcc-counter>
<!-- Events -->
<wcc-counter @count-changed="count = $event.detail"></wcc-counter>
<!-- Two-way binding (v-model) -->
<wcc-counter v-model:count="count"></wcc-counter>
<wcc-input v-model.trim="text"></wcc-input>
<!-- Default slot -->
<wcc-card>
<p>Body content</p>
</wcc-card>
<!-- Named slots -->
<wcc-card>
<template #header><strong>Title</strong></template>
<p>Body</p>
<template #footer>Footer text</template>
</wcc-card>
<!-- Scoped slots -->
<wcc-list>
<template #item="{ item, index }">
<li>{{ index }}: {{ item }}</li>
</template>
</wcc-list>
</template>The plugin provides: isCustomElement config, v-model:prop support, v-model modifiers (.trim, .number), and scoped slot syntax ({{prop}} → {%prop%} escape).
Angular (with WccSlotsDirective)
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular'
@Component({
imports: [WccSlotsDirective, WccSlotDef],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<!-- Props -->
<wcc-counter [count]="count" label="Clicks"></wcc-counter>
<!-- Events -->
<wcc-counter (count-changed)="onCount($event.detail)"></wcc-counter>
<!-- Two-way binding (banana-box) -->
<wcc-counter [(count)]="count"></wcc-counter>
<!-- Default slot -->
<wcc-card>
<p>Body content</p>
</wcc-card>
<!-- Named slots -->
<wcc-card wccSlots>
<strong slot-header>Title</strong>
<p>Body</p>
<span slot-footer>Footer text</span>
</wcc-card>
<!-- Scoped slots -->
<wcc-list wccSlots>
<ng-template slot="item" let-item let-index="index">
<li>{{ index }}: {{ item }}</li>
</ng-template>
</wcc-list>
`
})
export class AppComponent {
count = 0
onCount(value: number) { this.count = value }
}Angular needs no plugin for props, events, or two-way binding — only CUSTOM_ELEMENTS_SCHEMA. The directive is only needed for named slots (with slot-name syntax) and scoped slots.
React 19 (with wccReactPlugin)
// vite.config.js
import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [wccReactPlugin({ prefix: 'wcc-' }), react()] })import { useState } from 'react'
import { WccCard, WccList } from './dist/wcc-react'
export default function App() {
const [count, setCount] = useState(0)
return (
<>
{/* Props */}
<wcc-counter count={count} label="Clicks"></wcc-counter>
{/* Events */}
<wcc-counter oncountchanged={(e) => setCount(e.detail)}></wcc-counter>
{/* Default slot */}
<WccCard>
<p>Body content</p>
</WccCard>
{/* Named slots (compound pattern) */}
<WccCard>
<WccCard.Header><strong>Title</strong></WccCard.Header>
<p>Body</p>
<WccCard.Footer>Footer text</WccCard.Footer>
</WccCard>
{/* Named slots (props pattern) */}
<wcc-card header={<strong>Title</strong>} footer="Footer text">
<p>Body</p>
</wcc-card>
{/* Scoped slots (compound pattern) */}
<WccList>
<WccList.Item>{(item, index) => <li>{index}: {item}</li>}</WccList.Item>
</WccList>
{/* Scoped slots (render prop pattern) */}
<wcc-list renderItem={(item, index) => <li>{index}: {item}</li>} />
</>
)
}The plugin transforms PascalCase tags, compound components, props-as-slots, and render props at build time. Import stubs from ./dist/wcc-react (auto-generated by wcc build).
Vanilla (no framework)
No configuration needed:
<script type="module" src="dist/wcc-counter.js"></script>
<script type="module" src="dist/wcc-card.js"></script>
<script type="module" src="dist/wcc-list.js"></script>
<!-- Props (attributes) -->
<wcc-counter count="0" label="Clicks"></wcc-counter>
<!-- Events -->
<script>
document.querySelector('wcc-counter')
.addEventListener('count-changed', (e) => console.log(e.detail))
</script>
<!-- Default slot -->
<wcc-card>
<p>Body content</p>
</wcc-card>
<!-- Named slots -->
<wcc-card>
<strong slot="header">Title</strong>
<p>Body</p>
<span slot="footer">Footer text</span>
</wcc-card>
<!-- Scoped slots -->
<wcc-list>
<template #item="{ item, index }">
<li>{{index}}: {{item}}</li>
</template>
</wcc-list>TypeScript Types for Frameworks
wcc build auto-generates typed stubs for each framework in the dist/ folder:
dist/
├── wcc-vue.d.ts ← Vue/Volar prop autocompletion
├── wcc-vue.js ← Vue component stubs
├── wcc-react.d.ts ← React compound component types
├── wcc-react.js ← React component stubs
└── ...Vue (Volar autocompletion)
Add dist/wcc-vue.d.ts to your tsconfig to get prop/event autocompletion in .vue templates:
// tsconfig.json
{
"include": ["src/**/*", "dist/wcc-vue.d.ts"]
}After this, Volar provides:
- Prop autocompletion:
<wcc-counter :la|→ suggestslabel - Type-checking:
<wcc-counter :count="'string'">→ type error (expects number) - Event types on hover
React
React 19 treats custom elements (hyphenated tags) as any in JSX — this is by React's design. No additional type setup needed. Compound component stubs (WccCard.Header) are typed via dist/wcc-react.d.ts and work when imported directly.
Angular
Angular's CUSTOM_ELEMENTS_SCHEMA disables all type-checking on custom elements. No additional type setup possible from the library side.
Editor Support
The wcCompiler (.wcc) Language Support extension is available on the VS Code Marketplace. It provides syntax highlighting, completions, and diagnostics for .wcc files.
Runtime Helper
An optional wcc-runtime.js is copied to your output directory for declarative host-page bindings:
<wcc-counter :count="count" @change="handleChange"></wcc-counter>
<script type="module">
import './dist/wcc-counter.js'
import { init, on, set, get } from './dist/wcc-runtime.js'
on('handleChange', (e) => set('count', e.detail))
init({ count: 0 })
</script>License
MIT
