G · 04 guides

Guides / Authoring Components

Authoring Components

A .aihu Single File Component (SFC) is composed of named blocks. Each block uses the @blockname { ... } syntax.

@state block

The @state block declares the reactive contract of the component. It is ordinary TypeScript: each entry — a prop, a derived value, an action, an effect, a resource, or a lifecycle hook — is its own let/const declaration that calls the matching wrapper intrinsic (state, prop, derived, action, resource, effect, onMount/onDispose). One signature applies everywhere: the optional config object comes first, the running code (value thunk or handler) comes last — wrapper(config?, valueOrFn).

State wrapper intrinsics

prop() — a public, reactive property settable from HTML attributes. Always takes a config object (there is no bare prop(default) shorthand).

governedaihuaihu362 B
let name = prop<TypeAnnotation>({
  default: value,
  describe: 'human-readable description',  // optional
  expose: 'read',                          // optional: 'read' | 'write' | 'read write'
  attribute: true,                         // optional
  reflect: false,                          // optional
  required: false,                         // optional
})

Use expose: 'read' to expose the prop value to agents. Add 'write' (expose: 'read write') to also allow agents to set it. The generic <TypeAnnotation> carries the type — there is no separate type: config key.


derived() — a derived, read-only signal. Re-evaluates when dependencies change. Supports bare (no metadata) or wrapped (config object first, thunk second) form.

governedaihuaihu203 B
// bare — value-thunk, no metadata
const name = derived(() => expr)
// wrapped — add describe/expose, config comes first
const namedWithMeta = derived({ describe: '...', expose: 'read' }, () => expr)

There is no type: config key for derived. Annotate the thunk's own return type inline when needed: derived((): T[] => []).


action() — a named method on the component. Bare (no metadata) or wrapped, config first.

governedaihuaihu257 B
// bare handler
const doSomething = action((args) => { /* body */ })
// wrapped — add describe/expose to surface to agents
const doSomethingExposed = action(
  { describe: 'human-readable description', expose: 'read write' },
  (args) => { /* body */ },
)

Writes to props or state from inside an action are plain assignment (count++, count = 0) — there is no setter-function call to make.


effect() — runs a side effect when tracked signals change. Two valid forms:

governedaihuaihu231 B
// auto-tracked — dependencies inferred from what the function reads
effect(() => { /* side effect body */ })
// explicit deps — config object's "on:" lists what to watch
effect({ on: [data] }, () => { /* side effect body */ })

Call effect() as many times as needed in one @state block — each call stands on its own, so there is no name to collide on (unlike the retired named-collection form).


resource() — binds an async fetcher to a reactive signal. Returns a 3-state loader: { pending, value, error }. Bare or wrapped.

governedaihuaihu182 B
// bare fetcher-thunk
const data = resource(() => fetchUsers())
// wrapped — add describe/expose
const user = resource({ describe: '...', expose: 'read' }, () => fetchUser(userId))

onMount() / onDispose() — lifecycle hooks. Plain statement-position calls; there is no collection object to assemble.

governedaihuaihu94 B
onMount(() => { /* runs after first DOM mount */ })
onDispose(() => { /* runs on unmount */ })

There are also onAdopt and onAttributeChange lifecycle hooks, not shown above.

Complete example

governedaihuaihu563 B
@state {
  let count = prop({ default: 0, describe: 'Current counter value', expose: 'read' })

  const doubled = derived(() => count * 2)
  const isHigh = derived(
    { describe: 'True when count exceeds 100', expose: 'read' },
    () => count > 100,
  )

  const increment = action(
    { describe: 'Add 1 to the counter', expose: 'read write' },
    () => { count++ },
  )
  const reset = action(() => { count = 0 })

  onMount(() => console.log('mounted'))
  onDispose(() => console.log('unmounted'))

  effect(() => { document.title = 'Count: ' + count })
}

describe and expose — agent visibility

describe and expose are config keys inside the optional config object passed as the first argument to prop(), derived(), action(), and resource(). They replace the old @agent-level $expose and $describe macros.

  • describe: 'text' — makes the entry visible in the agent's capability description.
  • expose: 'read' — the agent can read this value.
  • expose: 'read write' — the agent can read and write this value (props, actions).

@template block

The @template block defines the DOM output using aihu's template DSL.

Tag naming. Components compile to native custom elements, so every component tag must normalize to a hyphenated name: multi-word PascalCase kebab-cases automatically (<UserCard>user-card), hyphenated tags pass through lowercased, and referencing a single-word component tag is a hard compile error (C450) (<Comment>) — use a hyphenated tag (e.g. <x-comment>) or an explicit hyphenated @meta name. Full rules and examples: see the Composition guide's "Tag naming" section.

The prefix-less template. One rule: naked keywords + naked HTML attributes + naked framework vocabulary. {expr} braces mean expression; quoted strings mean static; $ is retired from both @template and @state — the only surviving $-prefixed forms anywhere in the SFC are the two cross-cutting @agent declarations, $scope and $rate-limit. Reactive attribute bindings are plain braces (class={…}, href={…}, disabled={loading}); events and two-way binds are colon directives (on:click={…}, bind:value={…}); control flow attaches to the element it governs (if={…}, each={item of items}). Older $-prefixed attribute forms and <$…> macro elements from pre-redesign sources are compile errors under this grammar. Run npx aihu migrate <file> to mechanically rewrite older sources.

Text interpolation

  • {expr} — reactive text node. Writes through nodeValue, which updates the text node in place instead of reparsing the element's children.

Event handlers

  • on:click={handlerName} — attach an event listener (quoted identifier reference).
  • on:click={() => expr} — attach an inline handler (curly expression).

A colon separates the directive from the event name: on:<event>. Dotted modifiers compose behavior: on:click.prevent, on:submit.once (supported: .prevent, .stop, .self, .once).

Two-way binding

  • bind:value={signalName} — two-way bind a writable signal to a form element. The name after the colon is the bound property: bind:value, bind:checked, etc.

Conditional rendering

  • if={cond} — remove/insert the element based on a boolean signal or expression.
  • elseif={cond} / else — chain onto the immediately preceding if/elseif element sibling (only whitespace/comments may sit between).
  • show={cond} — toggle visibility without removing from DOM.
  • Wrap multi-element branches in <group> — the invisible fragment carrier.

List rendering

  • each={item, i of items} — render a list, item-first (<binder> [, <index>] of <list-expr>). Destructuring binders work: each={[k, v] of entries}. Pair with key={…} for stable reconciliation, and put empty on the immediately following sibling for the empty state:
governedaihuaihu55 B
<li each={todo of todos} key={todo.id}>{todo.text}</li>

HTML output

  • html={expr} — render raw HTML from an expression (trusted content only). Under output: 'static' this is prerendered too: the value is interpolated unescaped into the HTML you serve, not just injected into the DOM after load. Treat it as innerHTML at build time — never point it at untrusted or remote content.

Memoization and DOM stability

  • key={expr} — key for list reconciliation (pairs with each).
  • memo={expr} — memoize a subtree; only re-renders when expr changes.
  • once — boolean attribute; renders once and never re-renders.
  • raw — boolean attribute; verbatim element, no macro processing.

Class bindings

  • class={cond ? 'active' : ''} — reactive whole-string class expression.
  • class:active={cond} — per-class toggle; composes with a static class="…" base list (the toggle is authoritative when both address the same name).

Special elements

<slot> — inserts slotted children provided by the parent:

governedaihuaihu22 B
<slot name="header" />

Use expose to pass context to slot consumers:

governedaihuaihu99 B
<!-- In UserList.aihu -->
<slot name="row" expose="user, index">
  <!-- default content -->
</slot>

<suspense> — wraps an async resource with a loading fallback:

governedaihuaihu282 B
<!-- Simple: fallback attribute (component name) -->
<suspense fallback="Skeleton">
  <UserProfile />
</suspense>

<!-- Context-aware: slot form -->
<suspense>
  <UserProfile />
  <slot name="fallback">
    {loadAttempts > 3 ? <SlowConnection /> : <Spinner />}
  </slot>
</suspense>

The fallback attribute takes a component name (quoted string); fallbackProps may be added for static props. fallback attribute and <slot name="fallback"> are mutually exclusive.

<shield> — isolates a subtree behind an error boundary:

governedaihuaihu131 B
<shield>
  <UserProfile />
  <slot name="fallback">
    <ErrorPage error="shield.error" retry="shield.retry" />
  </slot>
</shield>

Exposes shield.error (Error) and shield.retry (function) to the fallback slot.

<guard> — conditionally renders based on an auth scope:

governedaihuaihu75 B
<guard scope="admin" fallback="UnauthorizedPage">
  <AdminPanel />
</guard>

Attributes: scope (scope-name), permissions, rateLimit, fallback (component-ref), redirect (path), onDeny (function-ref). Exposes guard.user, guard.reason, guard.path to the fallback slot.

<warp> — renders children into a portal target:

governedaihuaihu59 B
<warp to="#modal-root">
  <div>Portal content</div>
</warp>

Attribute value forms

Every attribute value must be in one of two forms — bare unquoted values are forbidden:

governedaihuaihu194 B
✗ <button on:click=save>            ← parse error (bare value)
✓ <button on:click={save}>          ← handler reference
✓ <button on:click={() => save()}>  ← inline handler expression

Some attributes are boolean-only (present-or-absent): once, raw, else, empty, disabled, required, etc.

@style block

The @style block defines component-scoped styles with reactive capabilities.

$reactive(signal)

Binds a CSS custom property value to a signal. Updates reactively without JavaScript in the template:

governedaihuaihu225 B
@style {
  $global {
    :root {
      --color-primary:    $reactive(primary);
      --color-on-primary: $reactive(onPrimary);
      --color-surface:    $reactive(surface);
    }
  }
  .host { color: $reactive(textColor); }
}

$global { ... } hoists styles out of the shadow root to the document root.

$media(query)

A responsive breakpoint block. Compiles to a standard @media rule but participates in the reactive style system:

governedaihuaihu85 B
@style {
  $media(max-width: 480px) {
    label { grid-template-columns: 1fr; }
  }
}

Standard CSS

All standard CSS is valid inside @style. Styles are scoped to the component shadow root by default (unless $global is used).

@agent block

The @agent block is a vestigial cross-cutting block. Per-name agent metadata (describe, expose) lives on @state wrapper calls, not here.

@agent now holds only two cross-cutting declarations:

governedaihuaihu109 B
@agent {
  $scope "user:read"     // agent permission scope
  $rate-limit 100        // requests per minute
}

Both are optional. The entire block may be omitted. For full agent authoring details — tool exposure, MCP compliance, and the agent capability contract — see the Authoring Agents guide.

Common diagnostics

The compiler enforces the grammar with named diagnostics. The hard errors C304 (Vue-shape :attr=), C305 (colon-form event/bind alias), C306 (plain-curly attribute binding), and C107 (HTML-tag SFC framing) are covered by the Grammar v2 callout under @template. Three more are easy to hit and worth calling out:

C205 — reading a prop in a bare @state const

A plain @state const/let can be emitted before the prop binding, so reading a prop there risks a temporal-dead-zone (TDZ) throw at runtime; the compiler steers you to derived(), where the read happens lazily inside a thunk instead of eagerly at setup:

governedaihuaihu330 B
// ✗ risks C205 — prop read in a plain const/let, no thunk to defer it
@state {
  let name = prop<string>({ default: 'world' })
  const greeting = 'Hello, ' + name + '!'
}

// ✓ read the prop inside derived()
@state {
  let name = prop<string>({ default: 'world' })
  const greeting = derived(() => 'Hello, ' + name + '!')
}

Beyond sidestepping the hazard, derived() is also what keeps greeting reactive — a bare const would only capture the prop's value once at setup, where derived() re-reads it whenever name changes.

C204 — unknown @block

The only recognized top-level blocks are @state, @template, @style, @agent, @route (plus the deprecated @layout shorthand). Any other @<name> header is an unknown block (C204). The most common offender is a v0 @props block — the hint steers you to declare props via prop() inside @state:

governedaihuaihu184 B
// ✗ C204 — there is no @props block
@props { name: { default: 'world' } }

// ✓ declare props via prop() inside @state
@state {
  let name = prop<string>({ default: 'world' })
}

C450 — single-word component tag (custom elements need a hyphen)

A component tag (or a component's resolved name) that normalizes to a single hyphen-less word can never be a valid custom-element name, so the compiler rejects it with C450. <UserCard> kebab-cases fine (user-card), but <Comment> resolves to comment — no hyphen, hard error:

governedaihuaihu189 B
// ✗ C450 — 'Comment' resolves to 'comment', which has no hyphen
<Comment item={c} />

// ✓ use a hyphenated tag (and file stem), or set a hyphenated @meta name
<x-comment item={c} />

See the Composition guide's "Tag naming" section for the full normalization table.

W210 — on:<non-event> (use html for innerHTML)

on:<name> referencing anything that is not a real DOM event compiles to a dead on<name> handler that never fires; the compiler warns with W210. To set raw HTML reactively, use the html attribute, not an on: binding:

governedaihuaihu155 B
// ✗ W210 — on:innerHTML is not a DOM event → dead handler
<div on:innerHTML={markup}></div>

// ✓ use the html attribute
<div html={markup}></div>

For the full diagnostic mapping, see the Migration guide.