Skip to content

Latest commit

 

History

400 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

htmlc logo

htmlc

A server-side Go template engine that uses Vue.js Single File Component (.vue) syntax for authoring but renders entirely in Go with no JavaScript runtime.

This is a static rendering engine. There is no reactivity, virtual DOM, or client-side hydration. Templates are evaluated once per request and produce plain HTML.


Table of Contents

  1. Overview
  2. Template Syntax
  3. Directives
  4. Component System
  5. Component Packages and Mounts
  6. Special Attributes
  7. Go API Quick Reference
  8. Expression Language Reference
  9. Debug Mode
  10. Structured Logging
  11. Component Error Handling
  12. Custom Directives
  13. Custom Elements
  14. Compatibility with Vue.js
  15. Testing
  16. html/template Integration
  17. Extension Modules

Overview

htmlc lets you write reusable HTML components in .vue files — the same format used by Vue.js — and render them server-side in Go. There is no Node.js dependency and no JavaScript executed at runtime. The <script> section of a .vue file is parsed and preserved in the output but never executed by the engine.

Key characteristics:

  • Static output — every render call produces a fixed HTML string.
  • Scoped styles — <style scoped> is supported; the engine rewrites selectors and injects scope attributes automatically.
  • Component composition — components can nest other components from the same registry.
  • No reactivity — v-model, @event, and other client-side directives are stripped from the output; they have no meaning in a server-side renderer.

Template Syntax

Supported

Text interpolation

{{ expr }} evaluates the expression against the current render scope and HTML-escapes the result.

<p>Hello, {{ name }}!</p>
<p>{{ a }} + {{ b }} = {{ a + b }}</p>

Multiple interpolations in a single text node are supported.

Expression language

Category Operators / Syntax
Arithmetic +, -, *, /, %, **
Comparison ===, !==, >, <, >=, <=, ==, !=
Logical &&, ||, !
Nullish coalescing ??
Optional chaining obj?.key, arr?.[i]
Ternary condition ? then : else
Member access obj.key, arr[i], arr.length
Function calls fn(args) (via expr.RegisterBuiltin or engine.RegisterFunc)
Method calls obj.Method(), obj.Method(args), obj.method (zero-arg implicit)
Array literals [a, b, c]
Object literals { key: value }

Built-in functions

The engine ships with no pre-registered built-in functions. Use expr.RegisterBuiltin to add custom functions that are callable from templates by name. For measuring collection sizes, use the .length member property instead — it works on strings, slices, arrays, and maps with no registration required:

<!-- number of elements in a slice -->
<span>{{ items.length }}</span>

<!-- number of bytes in a string -->
<span>{{ name.length }}</span>

Go method bindings — Exported methods on Go values placed in the render scope are callable directly from expressions without any registration. Zero-argument methods are called implicitly via dot access; methods with parameters use the normal call syntax:

<!-- Zero-arg method called implicitly -->
<p>{{ post.Summary }}</p>

<!-- Method with arguments -->
<a :href="router.LinkFor('home')">Home</a>

<!-- Lowercase alias works too -->
<p>{{ post.summary }}</p>

Fields take priority over methods of the same name. Pointer receivers are supported. Methods returning (value, error) propagate errors as render errors. Optional chaining (post?.Summary) returns undefined when the receiver is nil.

Not supported

  • Filters ({{ value | filterName }}) — Vue 2 syntax, not implemented.
  • JavaScript function definitions, arrow functions (=>), new, delete.
  • Template literals (backtick strings).
  • Assignment operators (=, +=, etc.) and increment/decrement (++, --).

Directives

Supported directives

Directive Supported Notes
v-text Yes Sets element text content (HTML-escaped). Replaces all children.
v-html Yes Sets element inner HTML (not escaped). Replaces all children. Use with trusted content only.
v-show Yes Adds style="display:none" when the expression is falsy. Merges with any existing style attribute.
v-if Yes Renders the element only when the expression is truthy.
v-else-if Yes Must immediately follow a v-if or v-else-if element (whitespace and HTML comments between are allowed; inter-branch comments are dropped).
v-else Yes Must immediately follow a v-if or v-else-if element (whitespace and HTML comments between are allowed; inter-branch comments are dropped).
v-switch Yes See v-switch syntax below. Must be on a <template> element.
v-case Yes Child of <template v-switch>. Renders when its expression equals the switch value.
v-default Yes Child of <template v-switch>. Renders when no preceding v-case matched.
v-for Yes See v-for syntax below.
v-bind / :attr Yes Dynamic attribute binding. See v-bind notes below.
v-pre Yes Skips all interpolation and directive processing for the element and all its descendants. The v-pre attribute itself is stripped from the output.
v-slot / #name Yes Used on <template> elements (or directly on a component tag) to target named or scoped slots. Shorthand: #name. See Slots under §4.
v-once No-op Accepted and stripped; server-side rendering always renders once, so this directive has no effect.

Not supported

Directive Status
v-on / @event Stripped. Client-side event handlers have no meaning in server-side rendering.
v-model Stripped. Two-way data binding has no meaning in server-side rendering.
v-cloak Not relevant for server-side rendering.
v-memo Not implemented.

v-for syntax

<!-- Array: item only -->
<li v-for="item in items">{{ item }}</li>

<!-- Array: item + index -->
<li v-for="(item, index) in items">{{ index }}: {{ item }}</li>

<!-- Map: value + key -->
<li v-for="(value, key) in obj">{{ key }}: {{ value }}</li>

<!-- Map: value + key + index (index is position in iteration order) -->
<li v-for="(value, key, index) in obj">{{ index }}. {{ key }}: {{ value }}</li>

<!-- Integer range: iterates 1..n inclusive -->
<li v-for="n in 5">{{ n }}</li>

<!-- Multi-element group using <template> -->
<template v-for="item in items">
  <dt>{{ item.term }}</dt>
  <dd>{{ item.def }}</dd>
</template>

Difference from Vue.js: Map iteration order follows Go's reflect.MapKeys() order, which is not guaranteed to be insertion order.

v-switch syntax

v-switch provides a concise switch-statement pattern for conditional rendering. It must be placed on a <template> element; its children carry v-case or v-default directives.

<!-- Switch on a string value -->
<template v-switch="user.role">
  <AdminPanel v-case="'admin'" />
  <ModPanel   v-case="'mod'" />
  <UserPanel  v-default />
</template>

<!-- Switch on a numeric value -->
<template v-switch="step">
  <StepOne   v-case="1" />
  <StepTwo   v-case="2" />
  <StepThree v-case="3" />
  <p v-default>Unknown step</p>
</template>

Rules:

  • Only the first matching v-case branch is rendered.
  • v-default renders when no v-case matched; only the first v-default is evaluated if multiple are present.
  • Children of <template v-switch> that carry neither v-case nor v-default are silently ignored.
  • Values are compared with Go == (strict equality; no type coercion).
  • Using v-switch on a non-<template> element is an error.

Difference from Vue.js: Vue.js does not yet ship v-switch/v-case/ v-default as stable built-ins (as of 2026). htmlc implements the semantics proposed in RFC #482.

v-bind notes

  • :class supports object syntax ({ active: isActive }) and array syntax ([classA, classB]).

  • :style supports object syntax with camelCase keys ({ fontSize: '14px' }); keys are converted to kebab-case in the output.

  • Falsy-value omission: for any :attr/v-bind:attr binding, the attribute is omitted entirely when the bound value is exactly false, null, or undefined. Every other value — including 0, NaN, and the empty string — renders normally. This applies to any attribute name, not just a fixed list, so idioms like :hx-swap-oob="shouldSwap" or :data-turbo-permanent="isPermanent" work for any third-party attribute vocabulary, not only native HTML ones.

  • Boolean attributes (disabled, checked, selected, readonly, required, multiple, autofocus, open) render bare (no ="value") when bound to a non-omitted value, e.g. :disabled="true" renders disabled. This bare-vs-valued rendering is still scoped to these eight names; every other attribute, once past the falsy-omission check above, renders as key="value".

  • :key is rendered as data-key="value" in the output (not as a key attribute).

  • class and :class are merged into a single class attribute.

  • style and :style are merged into a single style attribute.

  • v-bind:attr (long form) is equivalent to :attr (shorthand).

  • v-bind="obj" (attribute spreading): When v-bind is used without an attribute name and its value is a map[string]any, each entry is spread as an HTML attribute. Keys class and style follow the same merge rules as :class/:style. Boolean attribute semantics apply per key.

    <!-- Spread HTMX attributes from a map: -->
    <button v-bind="actions.delete.hxAttrs">Delete</button>
    
    <!-- Spread props into a child component: -->
    <Card v-bind="cardProps" :title="override" />

    On child components, the spread map values are lower priority than explicitly named :prop bindings.


Component System

Supported

Single File Components

A .vue file may have three top-level sections:

<template>
  <!-- required; HTML template -->
</template>

<script>
  // optional; preserved verbatim in output but NOT executed
</script>

<style>
  /* optional; collected and injected as a <style> block */
</style>

Props

Pass data to child components via attributes:

<!-- Dynamic prop (expression evaluated in caller scope) -->
<Card :title="pageTitle" :count="items.length" />

<!-- Static prop (always a string) -->
<Card title="Hello" />

No prop type validation or default values — the engine passes whatever you provide.

Default slot

Use <slot /> inside a component to render the caller's inner content:

<!-- Card.vue -->
<template>
  <div class="card">
    <slot />
  </div>
</template>
<!-- caller -->
<Card>
  <p>This goes into the slot.</p>
</Card>

Slot content is evaluated in the caller's scope, not the child component's scope.

Slots

Default slot

As shown above, <slot /> renders the caller's inner content. Children of <slot> act as fallback content — rendered only when the caller provides nothing:

<!-- Button.vue -->
<template>
  <button>
    <slot>Click me</slot>
  </button>
</template>
<!-- renders "Click me" because no content provided -->
<Button />

<!-- renders "Submit" -->
<Button>Submit</Button>
Named slots

A component can expose multiple insertion points by giving each <slot> a name attribute. The caller targets a named slot with <template v-slot:name> or the # shorthand <template #name>:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header><slot name="header" /></header>
    <main><slot /></main>
    <footer><slot name="footer" /></footer>
  </div>
</template>
<!-- caller -->
<Layout>
  <template #header><h1>{{ pageTitle }}</h1></template>
  <p>Main body content.</p>
  <template #footer><small>© 2024</small></template>
</Layout>

Content without a v-slot / # target goes to the default slot.

Scoped slots

A component can pass data back to the caller's slot content by binding props on the <slot> element. The caller receives them via v-slot="{ … }" or #name="{ … }":

<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items">
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>
<!-- caller — destructured binding -->
<List :items="products">
  <template #default="{ item, index }">
    <strong>{{ index }}.</strong> {{ item.name }}
  </template>
</List>

Binding patterns:

Syntax Effect
v-slot Slot targeted, no props exposed
v-slot="slotProps" All slot props available as slotProps.x
v-slot="{ item }" Destructured; item available directly
v-slot="{ item, index }" Multiple destructured props
Scope rules
  • Slot content is always evaluated in the caller's scope.
  • Slot props (from :prop="expr" on <slot>) are merged into the scope when rendering that slot's content — they do not leak into the rest of the caller's template.
  • Named-slot props are scoped to the <template #name="…"> block.

Scope rules for props and engine functions

Each component renders in an isolated scope containing only its own props. It does not automatically inherit variables from the parent component's scope. This is intentional: it prevents accidental coupling between components and makes data-flow explicit.

Engine-level functions registered via engine.RegisterFunc are an exception: they are injected into every component's scope automatically, at every level of the component tree. Treat them as a lightweight, read-only global namespace for helper functions (URL builders, route matchers, formatters).

WithDataMiddleware values are not propagated automatically — they are available only in the top-level page scope. If a deeply-nested component needs a value supplied by middleware (such as the current user), pass it down as an explicit prop or register it as an engine function instead.

Mechanism Available in page Available in child components
RenderPage / RenderFragment data map Yes No (pass as props)
WithDataMiddleware values Yes No (pass as props)
RegisterFunc functions Yes Yes (automatic)
Explicit :prop="expr" — Yes

The page-to-shell pattern

A common layout structure has a page component that passes request-specific data into a shared shell (layout) component:

<!-- HomePage.vue -->
<template>
  <Shell :title="title">
    <h1>Welcome</h1>
    <p>{{ intro }}</p>
  </Shell>
</template>
<!-- Shell.vue -->
<template>
  <html>
    <head><title>{{ title }}</title></head>
    <body>
      <nav>
        <a :href="url('home')">Home</a>  <!-- url() from RegisterFunc -->
      </nav>
      <main><slot /></main>
    </body>
  </html>
</template>

Render data for the page:

engine.RenderPage(ctx, w, "HomePage", map[string]any{
    "title": "Welcome",
    "intro": "Hello from the server.",
})

Key points:

  • The Shell component receives title as an explicit prop.
  • Helper functions like url are available in Shell automatically via RegisterFunc — they do not need to be passed as props.
  • Slot content (<h1>Welcome</h1>) is evaluated in the page's scope, not Shell's scope, so it can reference title and intro directly.

Component resolution

The engine uses proximity-based resolution: when a tag is encountered in a template, the engine first searches the same directory as the calling component, then walks toward the root one level at a time until a match is found.

For each directory level, the following name-folding strategies are tried in order:

  1. Exact match (e.g. my-card → my-card)
  2. First letter capitalised (e.g. card → Card)
  3. Kebab-case to PascalCase (e.g. my-card → MyCard)
  4. Case-insensitive scan

If no match is found via the proximity walk, the engine falls back to the flat registry (backward-compatible with single-directory projects).

Example: given this component tree:

components/
  Card.vue          ← generic root card
  blog/
    Card.vue        ← blog-specific card
    PostPage.vue    ← references <Card>
  admin/
    Card.vue        ← admin-specific card
    Dashboard.vue   ← references <Card>
  • blog/PostPage.vue referencing <Card> resolves to blog/Card.vue
  • admin/Dashboard.vue referencing <Card> resolves to admin/Card.vue
  • A root template referencing <Card> resolves to Card.vue

The template in blog/PostPage.vue uses an unqualified <Card> tag:

<!-- blog/PostPage.vue -->
<template>
  <Card :title="post.title">{{ post.summary }}</Card>
</template>

Because blog/Card.vue exists in the same directory, it wins over the root-level Card.vue and the admin/Card.vue. No import statement or path qualifier is needed.

A component at the root level (Shell.vue) referencing <Card> resolves to the root-level Card.vue because there is no Card in the root directory's walk other than itself:

<!-- Shell.vue (at root of components/) -->
<template>
  <div><Card :title="title" /></div>
</template>

Explicit cross-directory references

Use <component is="dir/Name"> to reference a component in a specific directory, bypassing proximity resolution:

<!-- always resolves to blog/Card.vue, regardless of caller location -->
<component is="blog/Card" />

<!-- root-relative: always resolves to Card.vue at ComponentDir root -->
<component is="/Card" />

<!-- dynamic version -->
<component :is="'admin/Card'" />

Path-based is values are resolved exactly (no name-folding) and return an error if the component is not found.

Scoped styles

<style scoped>
.button { color: red; }
</style>

The engine rewrites CSS selectors with a data-v-* scope attribute (e.g. .button[data-v-abc123]) and adds that attribute to every HTML element rendered by the component.

Slot content is stamped with the authoring component's scope attribute, not the child component's. This mirrors Vue SFC behaviour: CSS rules in the parent apply to elements the parent authors, even when those elements are rendered inside a child's <slot>.

Content authored in Scope attribute stamped
Child component template data-v-child
Parent slot content data-v-parent
Fallback children of <slot> data-v-child (fallback belongs to the child)

CSS content is extracted verbatim from <style> blocks — quoted string values, @font-face declarations, data URIs, and special characters (&, <, >) are preserved exactly as written and are never HTML-escaped.

Selector scoping reaches into conditional group at-rules: style rules nested inside @media, @supports, and @container are scoped too (so styles inside a media query still match the component's elements). Other at-rules whose contents are not element selectors — @keyframes, @font-face, @page, @import — are passed through verbatim.

Reaching into a child component with :deep()

Ordinary scoped selectors can only ever match elements the authoring component itself renders. .card .title { } in Page.vue's <style scoped> compiles to .card[data-v-page] .title[data-v-page] — a .title that <Card> renders inside .card never carries Page.vue's scope attribute, so the rule silently never matches it, even though the element is right there in the rendered HTML.

:deep() is the escape hatch:

<style scoped>
.card :deep(.title) { color: hotpink; }
</style>

compiles to .card[data-v-page] .title { color: hotpink; }: the scope attribute is still added to .card, but :deep(.title) unwraps to a plain .title with no attribute requirement at all, so it matches a .title regardless of which component rendered it. Everything from :deep( onward in the selector — its argument and anything chained after — is left unscoped; only the compound selector immediately before it is scoped as usual.

Nested composition

Components can freely use other components registered in the same engine.

Dynamic components

Use <component :is="expr"> to render a component whose name is determined at runtime. Use <component is="..."> for a static name. The value must be a non-empty string that names a registered component, a path (dir/Name), or a native HTML element:

<!-- resolve from a variable -->
<component :is="activeView" />

<!-- static name -->
<component is="Card" :title="pageTitle" />

<!-- explicit path — always blog/Card.vue, no proximity walk -->
<component is="blog/Card" />

<!-- root-relative path -->
<component is="/Card" />

<!-- switch between components in a loop -->
<div v-for="item in items">
  <component :is="item.type" :data="item" />
</div>
  • All attributes other than is, :is, or v-bind:is are forwarded to the resolved component as props.
  • Slot content (default and named) works exactly as with a statically-named component.
  • If the resolved name is a known HTML element (e.g. "div", "input"), the tag is rendered as-is rather than looked up in the component registry.
  • is or :is is required; omitting it or supplying a non-string value is a render error.

Native elements and v-native

A hyphenated tag name is normally always treated as a component reference: the engine looks it up in the registry and fails with unknown component if no match is found. Two mechanisms opt a specific hyphenated tag out of that check, letting it render as a plain HTML element instead — attributes and children are still evaluated normally (v-if/v-for honored), there is just no registry lookup:

  • Options.NativeElements []string declares a project-wide allowlist of tag names (matched case-insensitively) that must never resolve as components:

    engine, err := htmlc.New(htmlc.Options{
        ComponentDir:   "templates/",
        NativeElements: []string{"turbo-frame", "turbo-stream"},
    })
  • v-native marks a single element as native HTML for just that occurrence, without adding a project-wide entry:

    <my-one-off-widget v-native data-config="x">{{ msg }}</my-one-off-widget>

    The v-native attribute itself is stripped from the rendered output.

A tag that is neither declared in NativeElements nor marked v-native keeps today's behaviour. A real component nested inside a native element still resolves normally — the passthrough only affects the native element's own tag, not its descendants.

Not supported

Feature Status
<script setup> / Composition API Not supported. <script> content is never executed.
Computed properties, watchers, lifecycle hooks Not applicable (no runtime).
$emit / custom events Not implemented.
provide / inject Not implemented.
Async components Not applicable.
defineProps / defineEmits / withDefaults Not applicable.
Teleport, Suspense, KeepAlive Not applicable.

Component Packages and Mounts

Options.Mounts registers one or more additional, independently-sourced component trees alongside the primary Options.FS/ComponentDir pair — for example, a third-party or in-tree component package distributed with its own fs.FS of .vue files.

type Mount struct {
    Prefix string // namespace prefix, e.g. "radix"; non-empty, no "/", unique across Mounts
    FS     fs.FS  // component source for this mount
    Dir    string // path within FS to scan; "" means the FS root
}
import radixui "github.com/dhamidi/htmlc/ui/radix"

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Mounts: []htmlc.Mount{
        {Prefix: "radix", FS: radixui.FS(), Dir: "components"},
    },
})

Mounts is purely additive: it does not require FS/ComponentDir to be set, and adding it never changes how the primary source's own files are discovered or resolved.

Addressing a mounted component

A mounted component is reachable three ways:

  1. Unqualified, exactly like a local component, whenever the bare name is unambiguous — <Accordion> resolves through the same proximity-then-flat-registry walk as any other tag.
  2. Auto-registered aliases — every mounted component also gets a PascalCase alias (Mount.Prefix + every intermediate directory segment + base name, e.g. radix/dialog/Trigger.vue → RadixDialogTrigger) and the equivalent kebab-case alias (radix-dialog-trigger, derived the same way as custom-element tag names), so a disambiguated reference doesn't need the full path form.
  3. Explicit path: <component is="radix/Accordion"> (or <component :is="expr"> for a dynamic name) always resolves into the named mount, bypassing proximity resolution.
<Accordion />                          <!-- unqualified -->
<RadixAccordion />                     <!-- PascalCase alias -->
<radix-accordion></radix-accordion>    <!-- kebab-case alias -->
<component is="radix/Accordion" />     <!-- explicit path -->

Collision detection

When Options.Mounts is non-empty, ValidateAll additionally reports every flat-registry name (bare name or alias) that more than one source — the primary tree or another mount — attempted to register. This is a hard error, not a warning: an ambiguous name must be resolved to one of its qualified forms (an alias, or <component is="prefix/Name">) before validation passes.


Special Attributes

Attribute Behavior
:key Rendered as data-key="value" in the HTML output. Not used for diffing.
class + :class Both are collected and merged into a single class attribute.
style + :style Both are collected and merged into a single style attribute.

Go API Quick Reference

Create an engine

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",  // recursively scanned for *.vue files
    Reload:       false,         // set true for hot-reload during development
})

Render a full HTML page

Scoped styles are injected before the first </head> tag.

err = engine.RenderPage(ctx, w, "Page", map[string]any{
    "title": "Home",
    "items": []string{"a", "b"},
})

Render an HTML fragment

Scoped styles are prepended before the HTML. Use this for HTMX responses, turbo frames, etc.

err = engine.RenderFragment(w, "Card", map[string]any{
    "title": "My Card",
})

For a long-lived connection or a response containing several fragments — SSE, htmx out-of-band swaps, multiple Turbo Streams actions — see RenderSession below, which deduplicates styles across calls instead of repeating them on every fragment.

Render fragments on a shared RenderSession

NewRenderSession creates a *RenderSession that can be threaded through multiple RenderFragmentSession calls — one long-lived SSE connection, or several fragments concatenated into a single response — so a component's <style scoped> block is emitted only once across the whole session instead of being repeated on every call:

sess := engine.NewRenderSession()

err = engine.RenderFragmentSession(ctx, w, "Counter", map[string]any{"count": 1}, sess)
err = engine.RenderFragmentSession(ctx, w, "Counter", map[string]any{"count": 2}, sess)
err = engine.RenderFragmentSession(ctx, w, "Counter", map[string]any{"count": 3}, sess)
// Counter's <style scoped> block appears in the output of the first call only.

Scripts (from <script customelement> components) are deduplicated the same way across a shared session. RenderSession is safe for use by a single goroutine only — allocate one per connection/response, not one shared across concurrent requests.

Pass a struct as component data

Any Go struct (or pointer to a struct) can be used as a value within the data map. The engine resolves field access using json struct tags when present and falls back to the Go field name (case-insensitive on the first letter):

type Post struct {
    Title   string   `json:"title"`
    Author  string   `json:"author"`
    Tags    []string `json:"tags"`
    Draft   bool     `json:"draft"`
}

post := Post{
    Title:  "Getting started with htmlc",
    Author: "Alice",
    Tags:   []string{"go", "templates"},
    Draft:  false,
}

err = engine.RenderPage(ctx, w, "PostPage", map[string]any{
    "post": post,
})

Inside PostPage.vue the struct fields are accessible by their json tag names:

<template>
  <article>
    <h1>{{ post.title }}</h1>
    <p class="byline">by {{ post.author }}</p>
    <ul>
      <li v-for="tag in post.tags">{{ tag }}</li>
    </ul>
    <span v-if="post.draft" class="badge">Draft</span>
  </article>
</template>

Spread a struct onto a child component with v-bind

When a child component expects the same set of fields that a struct exposes, you can spread the struct directly instead of mapping each field individually:

<!-- PostPage.vue -->
<template>
  <Layout>
    <!-- Spread the post struct: title, author, tags, draft become props of PostCard -->
    <PostCard v-bind="post" />
  </Layout>
</template>

The engine accepts any struct or map[string]any as the right-hand side of v-bind. Embedded struct fields are promoted and resolved just as if they had been declared directly on the outer struct.

Serve a component as an HTTP handler

http.Handle("/widget", engine.ServeComponent("Widget", func(r *http.Request) map[string]any {
    return map[string]any{"id": r.URL.Query().Get("id")}
}))

Pass nil as the second argument if the component needs no data.

Parse a component manually

comp, err := htmlc.ParseFile("path/to/Button.vue", srcString)

Discover expected props

for _, p := range comp.Props() {
    fmt.Println(p.Name, p.Expressions)
}

Inspect parse and render errors

Parse and render failures carry structured location information when the source position can be determined. Use errors.As to inspect them:

import "errors"

_, err := htmlc.ParseFile("Card.vue", src)
var pe *htmlc.ParseError
if errors.As(err, &pe) {
    fmt.Println(pe.Path)             // "Card.vue"
    if pe.Location != nil {
        fmt.Println(pe.Location.Line)    // 1-based line number
        fmt.Println(pe.Location.Snippet) // 3-line source context
    }
}

err = engine.RenderFragment(w, "Card", data)
var re *htmlc.RenderError
if errors.As(err, &re) {
    fmt.Println(re.Component)        // component path
    fmt.Println(re.Expr)             // expression that failed, e.g. "post.Title"
    if re.Location != nil {
        fmt.Println(re.Location.Line)    // approximate line number
        fmt.Println(re.Location.Snippet) // 3-line source context
    }
}

When location information is available, err.Error() prints a compiler-style message with file, line, and a source snippet:

Card.vue:14:5: render Card.vue: expr "post.Title": cannot access property "Title" of null
  13 |   <div class="card">
> 14 |     {{ post.Title }}
  15 |   </div>

When position cannot be determined, the traditional htmlc: ... format is used as a fallback so existing error-checking code continues to work.

Configure missing prop behavior

By default, when a prop is missing from the render scope, the engine renders a visible [missing: propName] placeholder in its place so the page still loads and you can immediately see which prop is absent.

To restore strict error behaviour (rendering aborts with an error), use the built-in ErrorOnMissingProp handler:

engine.WithMissingPropHandler(htmlc.ErrorOnMissingProp)

To silence missing props entirely or substitute a custom value:

// silently substitute an empty string
engine.WithMissingPropHandler(func(name string) (any, error) {
    return "", nil
})

Development hot-reload

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Reload:       true,  // re-parses changed files before each render
})

Load components from an embedded filesystem

Set Options.FS to any fs.FS implementation — including embed.FS — and the engine reads and walks component files through that FS instead of the OS filesystem. ComponentDir is then interpreted as a path within the FS.

import "embed"

//go:embed templates
var templateFS embed.FS

engine, err := htmlc.New(htmlc.Options{
    FS:           templateFS,
    ComponentDir: "templates",
})

This embeds the entire templates/ directory into the binary at compile time. Any fs.FS implementation works — embed.FS, testing/fstest.MapFS, fs.Sub, or a custom virtual filesystem.

Hot-reload (Reload: true) is supported when the FS implements fs.StatFS (which embed.FS does not — embedded files have no mtime). When the FS does not implement fs.StatFS, reload checks are silently skipped.

Context-aware rendering

Pass a context.Context to propagate cancellation and deadlines through the render pipeline:

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

err = engine.RenderPage(ctx, w, "Page", data)
err = engine.RenderFragmentContext(ctx, w, "Card", data)

ServeComponent and ServePageComponent automatically forward r.Context().

Register per-engine template functions

RegisterFunc makes a Go function callable from any template expression rendered by this engine. Engine-level functions act as lower-priority builtins — the render data scope overrides them:

engine.RegisterFunc("formatDate", func(args ...any) (any, error) {
    t, _ := args[0].(time.Time)
    return t.Format("2 Jan 2006"), nil
})
<span>{{ formatDate(post.CreatedAt) }}</span>

Share helper functions across the component tree

Functions registered with RegisterFunc are available in every component at any nesting depth — you do not need to pass them as props:

engine.RegisterFunc("url", func(args ...any) (any, error) {
    name, _ := args[0].(string)
    return router.URLFor(name), nil
})
engine.RegisterFunc("routeActive", func(args ...any) (any, error) {
    name, _ := args[0].(string)
    return r.URL.Path == router.URLFor(name), nil
})
<!-- Shell.vue — url() and routeActive() are available without any prop wiring -->
<template>
  <nav>
    <a :href="url('home')" :class="{ active: routeActive('home') }">Home</a>
    <a :href="url('about')" :class="{ active: routeActive('about') }">About</a>
  </nav>
  <slot />
</template>

This is the recommended approach for router helpers, auth utilities, and any other function that many components across the tree need to call.

For data values (structs, strings, booleans) that vary per request, pass them as explicit props or use WithDataMiddleware and thread them down through the component tree where needed.

Serve a full-page component as an HTTP handler

ServePageComponent is like ServeComponent but renders a full HTML page and lets the data function return an HTTP status code:

http.Handle("/post", engine.ServePageComponent("PostPage", func(r *http.Request) (map[string]any, int) {
    post, err := db.GetPost(r.URL.Query().Get("slug"))
    if err != nil {
        return nil, http.StatusNotFound
    }
    return map[string]any{"post": post}, http.StatusOK
}))

Mount multiple routes at once

Mount registers a set of component routes on an http.ServeMux in one call. Each component is served as a full HTML page. Use WithDataMiddleware to inject common data (auth, CSRF, etc.) shared across all routes:

engine.Mount(mux, map[string]string{
    "GET /{$}":    "HomePage",
    "GET /about":  "AboutPage",
    "GET /posts":  "PostsPage",
})

Inject data for all HTTP routes

WithDataMiddleware adds a function that enriches the data map on every HTTP-triggered render. Multiple middleware functions are applied in registration order:

engine.WithDataMiddleware(func(r *http.Request, data map[string]any) map[string]any {
    data["currentUser"] = sessionUser(r)
    data["csrfToken"]   = csrf.Token(r)
    return data
})

Validate components at startup

ValidateAll checks every registered component for unresolvable child component references and returns a slice of errors. It uses the same proximity-based resolution as the renderer, so a reference that would succeed at render time will not generate a false-positive validation error. Call it once at startup to surface missing-component problems before the first request:

if errs := engine.ValidateAll(); len(errs) > 0 {
    for _, e := range errs {
        log.Printf("component error: %v", e)
    }
    os.Exit(1)
}

Expression Language Reference

Expressions are JavaScript-compatible in syntax and truthiness rules but are evaluated entirely in Go.

Operators (highest to lowest precedence)

Precedence Operators Example
7 (highest) Unary !, unary - !active, -x
6 ** (exponentiation) 2 ** 10
5 *, /, % price * qty
4 +, - a + b
3 >, <, >=, <=, ==, !=, ===, !== count > 0
2 && a && b
2 ||, ?? a || 'default', val ?? 'n/a'
1 (lowest) ? : (ternary) ok ? 'yes' : 'no'

Member access (obj.key, arr[i]), optional chaining (obj?.key, arr?.[i]), and function calls (fn(args)) have the highest binding and are parsed as primary expressions.

Optional chaining short-circuits to undefined when the left-hand side is null or undefined, preventing runtime errors from missing nested data:

{{ user?.address?.city ?? "Unknown" }}
{{ items?.[0]?.name }}

Truthiness (JavaScript-compatible)

Falsy values: false, 0, "" (empty string), null, undefined. Everything else is truthy, including empty arrays and empty objects.

Type notes

  • All numbers are float64 internally (JavaScript number semantics).
  • Accessing a missing map key or an out-of-range index returns undefined (not an error).
  • null and undefined are distinct values. null == undefined is true; null === undefined is false.
  • The ?? operator returns the right-hand side only when the left-hand side is null or undefined (not when it is 0 or "").

Examples

{{ count > 0 ? count : "none" }}
{{ user.name ?? "Guest" }}
{{ items[0].title }}
{{ tags.length }}
{{ price * 1.2 }}
{{ active ? "active" : "" }}

Debug Mode

Enable debug mode via Options.Debug (or the -debug CLI flag) to annotate the rendered HTML with component metadata. When active, the root element of each rendered component receives three data-htmlc-* attributes:

Attribute Value
data-htmlc-component Component name (registry key, original casing)
data-htmlc-file Relative path to the .vue source file
data-htmlc-props HTML-escaped JSON-encoded props passed to the component

If the props cannot be JSON-serialised (for example, a prop value is an io.Reader), data-htmlc-props-error is emitted instead of data-htmlc-props.

Fragment templates (components with no single root element) are silently skipped — there is no element to annotate.

Example output for <HeroBanner headline="Hello"> with debug mode enabled:

<section
  id="hero"
  data-htmlc-component="HeroBanner"
  data-htmlc-file="components/HeroBanner.vue"
  data-htmlc-props="{&quot;headline&quot;:&quot;Hello&quot;}"
>
  ...
</section>

The data-htmlc-* attributes are standard HTML data-* attributes and are accessible via the browser's dataset API (el.dataset.htmlcComponent, el.dataset.htmlcProps, etc.).

Note: Debug mode adds extra attributes and increases output size. Do not use it in production for performance reasons. It does not corrupt the document — all attribute values are HTML-escaped before emission.

AST inspection

The htmlc ast subcommand parses a .vue file and prints its template AST as indented pseudo-XML, without executing the render pipeline:

htmlc ast -dir ./templates PostPage

Example output:

Document
  Element[article] attrs=[]
    Element[h1] attrs=[]
      Text: "{{ post.Title }}"
    Element[p] v-if="post.Draft" attrs=[]
      Text: "Draft"

Structured Logging

The engine can emit one structured log record per component rendered using the standard library's log/slog package. Pass a *slog.Logger to Options.Logger to enable it.

Records are emitted at slog.LevelDebug for successful renders and slog.LevelError for failed renders. Each record includes:

Attribute Type Description
component string Resolved component name (file base name without .vue)
duration duration Wall-clock time for the component subtree
bytes int64 Bytes written by the component subtree
error error Set only on ERROR-level records

The two log messages are available as exported constants so test code can filter records without hardcoding strings:

htmlc.MsgComponentRendered // "component rendered"
htmlc.MsgComponentFailed   // "component render failed"

A nil Logger (the default) disables all slog output with no overhead on the hot path.

Example

import (
    "log/slog"
    "os"

    "github.com/dhamidi/htmlc"
)

logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Logger:       logger,
})

Example log output (one line per component, formatted for readability):

{"time":"...","level":"DEBUG","msg":"component rendered","component":"Card","duration":42000,"bytes":128}
{"time":"...","level":"DEBUG","msg":"component rendered","component":"Page","duration":95000,"bytes":512}

Records appear in post-order: leaf components are logged before their parents.

Notes

  • Logger and Debug mode are independent — both can be enabled simultaneously.
  • If concurrent root renders sharing output are needed, use separate engine instances; a single engine's root-level instrumentation uses a shared countingWriter.

Component Error Handling

By default, the first component render failure aborts the entire page render and the response writer receives nothing. Two complementary features make failures observable and recoverable.

Structured component path

Every *RenderError now carries a ComponentPath []string field — the ordered list of component names from the page root to the failing component:

var rerr *htmlc.RenderError
err := engine.RenderPage(ctx, w, "HomePage", data)
if errors.As(err, &rerr) {
    fmt.Println(strings.Join(rerr.ComponentPath, " > "))
    // Output: HomePage > Layout > Sidebar
}

RenderError.Error() automatically includes the path when depth > 1:

HomePage > Layout > Sidebar: render Sidebar.vue: expr "items.length": type error

In-place error handler

Register a ComponentErrorHandler on Options to intercept component failures, write a placeholder into the output buffer, and allow rendering to continue:

engine, _ := htmlc.New(htmlc.Options{
    ComponentDir:          "templates/",
    ComponentErrorHandler: htmlc.HTMLErrorHandler(), // built-in dev helper
})
err := engine.RenderPage(ctx, w, "HomePage", data)
// err == nil; w contains the page with <div class="htmlc-error"> placeholders

The ComponentErrorHandler type is:

type ComponentErrorHandler func(w io.Writer, path []string, err error) error
  • Return nil to write a placeholder and continue rendering sibling nodes.
  • Return a non-nil error to abort the entire render immediately.

When the handler returns nil for every failure, RenderPage returns nil and the partial page (with placeholders) is written to w exactly like a successful render.

Built-in development helper

HTMLErrorHandler() returns a handler that renders a visible <div class="htmlc-error"> placeholder at each failure site:

<div class="htmlc-error" data-path="HomePage &gt; Sidebar">
  render Sidebar.vue: expr "items.length": type error
</div>

Both path and the error message are HTML-escaped. The htmlc-error class lets you style the placeholder with CSS.

Nil handler (default)

A nil ComponentErrorHandler preserves the existing behaviour: the first component error aborts the render and w receives nothing.


Custom Directives

The engine supports user-defined directives that extend the template language. A custom directive is any Go type that implements the Directive interface:

type Directive interface {
    // Created is called before the element is rendered.
    // Mutate node.Attr or node.Data to affect what is emitted.
    Created(node *html.Node, binding DirectiveBinding, ctx DirectiveContext) error

    // Mounted is called after the element's closing tag has been written.
    // Bytes written to w appear immediately after the element.
    Mounted(w io.Writer, node *html.Node, binding DirectiveBinding, ctx DirectiveContext) error
}

Register a directive on the engine with RegisterDirective (no v- prefix):

engine.RegisterDirective("my-dir", &MyDirective{})

Then use it in templates as v-my-dir:

<div v-my-dir="someExpr" class="wrapper">content</div>

The DirectiveBinding passed to both hooks contains:

Field Type Description
Value any Evaluated directive expression
RawExpr string Un-evaluated expression string
Arg string Argument after : (e.g. "href" in v-my-dir:href)
Modifiers map[string]bool Dot-separated modifiers (e.g. {"prevent": true})

Example: VHighlight

VHighlight is the canonical example of a custom directive in htmlc. It mirrors the v-highlight directive from the Vue.js custom directives guide.

VHighlight is not auto-registered; you must opt in:

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Directives: htmlc.DirectiveRegistry{
        "highlight": &htmlc.VHighlight{},
    },
})

Then use v-highlight in templates:

<p v-highlight="'yellow'">Highlight this text bright yellow</p>
<p v-highlight="theme.accentColour">Dynamic colour from scope</p>

The directive sets background:<colour> on the element's style attribute, merging with any existing inline styles.

VHighlight implements only the Created hook because htmlc is server-side — there is no DOM mounted event. This is the htmlc equivalent of Vue's mounted hook on a custom directive.

DirectiveWithContent

A directive that wants to replace the element's children with custom HTML can implement the optional DirectiveWithContent interface:

type DirectiveWithContent interface {
    Directive
    // InnerHTML returns the raw HTML to use as the element's inner content.
    // Return ("", false) to fall back to normal child rendering.
    InnerHTML() (html string, ok bool)
}

After Created is called, the renderer checks whether the directive implements DirectiveWithContent. If InnerHTML() returns (s, true) the string s is written verbatim between the element's opening and closing tags — the template children are skipped. This is how external directives like v-syntax-highlight inject processed markup without modifying the Go code.

Note: The old v-switch component-dispatch directive (which replaced the host element's tag at runtime) has been removed. Use <component :is="expr"> for dynamic component dispatch, or the new built-in v-switch/v-case/v-default for switch-style conditional rendering.

External Directives

htmlc build can discover and invoke external directives — plain executables living in the component file tree. No recompilation of htmlc is required.

Discovery

When htmlc build starts it walks the -dir component tree. Any file whose base name (without extension) matches v-<directive-name> and has at least one executable bit set is registered as an external directive under the name <directive-name> (the v- prefix is stripped). Files inside hidden directories (names starting with .) are skipped. The directive name must be lower-kebab-case ([a-z][a-z0-9-]*).

templates/
  directives/
    v-syntax-highlight    ← registered as "syntax-highlight"
    v-upper.sh            ← registered as "upper"

Convention: place directive executables alongside the components that use them or in a dedicated directives/ subdirectory.

Protocol

Each directive executable is spawned once at the start of a build. htmlc communicates with it over newline-delimited JSON (NDJSON) on stdin/stdout. Stderr from the subprocess is forwarded verbatim to htmlc's stderr. When the build finishes htmlc closes the subprocess's stdin; the subprocess should drain its input and exit cleanly.

Request envelope (htmlc → directive):

{
  "hook":    "created" | "mounted",
  "id":      "<opaque string echoed in response>",
  "tag":     "<element tag name>",
  "attrs":   { "<name>": "<value>", ... },
  "text":    "<concatenated text content of element's children>",
  "binding": {
    "value":     <evaluated expression>,
    "raw_expr":  "<unevaluated expression string>",
    "arg":       "<directive argument or ''>",
    "modifiers": { "<mod>": true, ... }
  }
}

created response (directive → htmlc):

{
  "id":         "<same id as request>",
  "tag":        "<optional: new tag name>",
  "attrs":      { "<name>": "<value>", ... },
  "inner_html": "<optional: replacement inner HTML>",
  "error":      "<optional: non-empty aborts rendering of this element>"
}
  • inner_html — if present and non-empty, replaces the element's children verbatim (not escaped).

mounted response (directive → htmlc):

{
  "id":    "<same id as request>",
  "html":  "<optional: HTML injected after the element's closing tag>",
  "error": "<optional: non-empty aborts rendering>"
}

If the directive outputs a line that is not valid JSON or echoes the wrong id, htmlc logs a warning to stderr and treats the hook as a no-op.

Example: syntax highlighting

<template>
  <pre v-syntax-highlight="'go'">
func main() {
    fmt.Println("hello")
}
  </pre>
</template>

A minimal v-syntax-highlight shell script skeleton:

#!/usr/bin/env node
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on('line', (line) => {
    const req = JSON.parse(line);
    if (req.hook === 'created') {
        const highlighted = highlight(req.binding.value, req.text); // your highlighter
        process.stdout.write(JSON.stringify({
            id: req.id,
            inner_html: highlighted,
        }) + '\n');
    } else {
        process.stdout.write(JSON.stringify({ id: req.id, html: '' }) + '\n');
    }
});

Test the directive interactively:

echo '{"hook":"created","id":"1","tag":"pre","attrs":{},"text":"fmt.Println(1)","binding":{"value":"go","raw_expr":"'\''go'\''","arg":"","modifiers":{}}}' \
  | ./v-syntax-highlight

Custom Elements

A .vue component can opt in to Web Component (Custom Element) compilation by including a <script customelement> block. The engine wraps the rendered template in the component's derived tag name and writes the JavaScript to a scripts/ directory alongside the HTML.

Declaring a custom element component

<!-- components/ui/DatePicker.vue -->
<template>
  <div class="date-picker">
    <input type="date" />
  </div>
</template>

<script customelement>
class DatePickerElement extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = this.innerHTML;
  }
}
customElements.define('ui-date-picker', DatePickerElement);
</script>

The tag name is derived automatically from the component's file path using kebab-case: ui/DatePicker.vue → ui-date-picker.

For Declarative Shadow DOM (streaming SSR), use the shadowdom attribute:

<script customelement shadowdom>…</script>          <!-- mode: open  -->
<script customelement shadowdom="closed">…</script>  <!-- mode: closed -->

Output structure

htmlc build writes collected scripts to <out>/scripts/ alongside the HTML:

out/
  index.html
  about.html
  scripts/
    a1b2c3d4e5f6a7b8.js   ← ui-date-picker
    ff00112233445566.js   ← widgets-shape-canvas

Scripts are deduplicated by content hash: the same script used across multiple pages produces exactly one file in scripts/. File names are the first 16 hex characters of the SHA-256 hash of the script content.

The scripts/ directory is only created when at least one custom element component is rendered. Projects with no <script customelement> blocks produce no scripts/ directory.

Dev server

htmlc build -dev :8080 serves scripts from memory at /scripts/, rebuilding automatically when source files change.

Go API

// Serve scripts over HTTP — the engine manages the collector internally
http.Handle("/scripts/", http.StripPrefix("/scripts/", engine.ScriptHandler()))

// Or write scripts to disk during a static build
engine.WriteScripts(filepath.Join(outDir, "scripts"))

// For advanced use, access the collector directly
collector := engine.Collector()
importMap := collector.ImportMapJSON("/scripts/")

importMap template function

An importMap template function is automatically available in all page templates and child components when rendering with RenderPage or RenderFragment:

<head>
  <script type="importmap">{{ importMap("/scripts/") }}</script>
</head>

importMap(urlPrefix) returns the same JSON as collector.ImportMapJSON(urlPrefix). If no custom element scripts have been collected, it returns a valid empty import map.


Compatibility with Vue.js

htmlc uses .vue Single File Component syntax and many of the same directive names as Vue.js, but it is a server-side-only renderer with intentional differences. This section documents where htmlc diverges from or extends standard Vue.js behaviour.

Directives

Directive Vue.js behaviour htmlc behaviour
v-switch / v-case / v-default Not in stable Vue.js (proposed in RFC #482) Built-in; v-switch on <template>, children carry v-case / v-default
v-on / @event Client-side event handler Stripped from output
v-model Two-way binding Stripped from output
v-cloak Hide until mounted Not relevant (no mounting); ignored
v-memo Memoised subtree Not implemented
v-once Render once, skip future updates Accepted; no-op (server always renders once)
<component :is="..."> Dynamic component Supported

Expression language

The htmlc expression evaluator supports a subset of JavaScript expressions. The following are not supported:

  • Template literals (backtick strings).
  • Arrow functions and function keyword.
  • new, delete, typeof, instanceof.
  • Assignment operators (=, +=, etc.) and increment/decrement (++, --).
  • Filters ({{ value | filter }}).

Map iteration order

v-for over a Go map iterates in reflect.MapKeys() order, which is not guaranteed to match insertion order. Vue.js preserves insertion order for Object.keys(). Use a slice of objects if deterministic order is required.

Scoped styles

htmlc supports <style scoped> with the same semantics as Vue.js SFCs: a unique data-v-XXXXXXXX attribute is added to all elements rendered by the component, and CSS selectors are rewritten to target that attribute. :deep(...) is supported for reaching into an element a child component renders (see Reaching into a child component with :deep()); the legacy ::v-deep and >>> spellings are not.

No reactivity

htmlc has no virtual DOM, no reactivity system, and no JavaScript runtime. Every render call produces a fixed HTML string. Props are plain Go values passed at render time.


Testing

The htmlctest package provides a lightweight harness for writing unit and integration tests for .vue components. Tests use an in-memory filesystem — no temporary directories, no OS setup — and a fluent DOM-query API to assert that rendered output contains the expected elements, text, and attributes.

Import

import "github.com/dhamidi/htmlc/htmlctest"

Quick start: Build shorthand

Build wraps a template snippet in <template>…</template>, registers it as a component named Root, and returns a *Harness ready to render:

func TestGreeting(t *testing.T) {
    htmlctest.Build(t, `<p class="greeting">Hello {{ name }}!</p>`).
        Fragment("Root", map[string]any{"name": "World"}).
        Find(htmlctest.ByTag("p").WithClass("greeting")).
        AssertText("Hello World!")
}

Multiple components: NewHarness

When the component under test references child components, use NewHarness to register all required files in one call:

func TestCard(t *testing.T) {
    h := htmlctest.NewHarness(t, map[string]string{
        "Badge.vue": `<template><span class="badge">{{ label }}</span></template>`,
        "Card.vue": `<template>
            <div class="card">
                <h2>{{ title }}</h2>
                <Badge :label="status" />
            </div>
        </template>`,
    })

    h.Fragment("Card", map[string]any{
        "title":  "Order #42",
        "status": "shipped",
    }).
        Find(htmlctest.ByTag("h2")).AssertText("Order #42").
        Find(htmlctest.ByClass("badge")).AssertText("shipped")
}

Assertion methods

Method Checks
AssertHTML(want) Exact HTML string after whitespace normalisation
Find(query) Returns a Selection of matched nodes
AssertExists() At least one node matched
AssertNotExists() No nodes matched
AssertCount(n) Exactly n nodes matched
AssertText(text) Normalised text content of the first matched node
AssertAttr(attr, value) Named attribute of the first matched node

All Assert* methods call t.Fatalf on failure and return the receiver so that assertions chain:

result.Find(htmlctest.ByTag("li")).
    AssertCount(3).
    AssertText("First item") // checks text of the first <li>

Query constructors

Constructor Matches
ByTag("div") Elements by tag name (case-insensitive)
ByClass("active") Elements that have the given CSS class
ByAttr("data-id", "42") Elements where data-id="42"

Queries are immutable values. Use combinators to narrow the match:

// <li class="active" data-id="1"> inside a <ul>
htmlctest.ByTag("li").
    WithClass("active").
    WithAttr("data-id", "1").
    Descendant(htmlctest.ByTag("ul"))

Testing v-for output

func TestList(t *testing.T) {
    htmlctest.Build(t, `
        <ul>
            <li v-for="item in items">{{ item }}</li>
        </ul>
    `).
        Fragment("Root", map[string]any{
            "items": []string{"alpha", "beta", "gamma"},
        }).
        Find(htmlctest.ByTag("li")).
        AssertCount(3)
}

Testing conditional rendering

func TestBadge_Hidden(t *testing.T) {
    htmlctest.Build(t, `<span v-if="show" class="badge">NEW</span>`).
        Fragment("Root", map[string]any{"show": false}).
        Find(htmlctest.ByClass("badge")).
        AssertNotExists()
}

html/template Integration

Already using html/template? htmlc works alongside your existing templates with no changes required. Use RegisterTemplate to bring any existing partial into an htmlc component tree, and CompileToTemplate to export new .vue components to any library that requires *html/template.Template. Adopt incrementally — one component at a time.

CompileToTemplate — export a .vue component for stdlib code

CompileToTemplate compiles a named component (and all components it transitively references) into a single *html/template.Template:

tmpl, err := engine.CompileToTemplate("Card")
// tmpl is ready to Execute; sub-components appear as named define blocks.
// Template names are lowercased: "Card" → "card".

var buf strings.Builder
tmpl.Execute(&buf, map[string]any{"title": "Hello"})

Scoped <style> blocks are stripped from the output. Non-recoverable errors are returned as *ConversionError (also testable with errors.Is(err, ErrConversion)).

Supported constructs (vue → tmpl)

Vue syntax Go template output
{{ ident }} {{.ident}}
{{ a.b.c }} {{.a.b.c}}
:attr="ident" / v-bind:attr="ident" attr="{{.ident}}"
v-if="ident" {{if .ident}}…{{end}}
v-else-if="ident" {{else if .ident}}
v-else {{else}}
v-for="item in list" {{range .list}}…{{end}}
v-show="ident" injects style="display:none" conditionally
v-html="ident" {{.ident}} + warning
v-text="ident" {{.ident}} (children discarded)
v-bind="ident" (spread) {{.ident}} + warning
<template v-switch="ident"> {{if eq .ident "…"}}… chain
<slot> {{block "default" .}}…{{end}}
<slot name="N"> {{block "N" .}}…{{end}}
<my-component> (zero props) {{template "my-component" .}}

Complex expressions, bound props on child components, and custom directives return *ConversionError.

RegisterTemplate — use existing partials inside .vue files

RegisterTemplate imports an existing *html/template.Template into the engine's registry as a virtual htmlc component under the given name:

subTmpl, _ := html.template.New("foot-note").Parse("<footer>{{.year}}</footer>")
err := engine.RegisterTemplate("foot-note", subTmpl)
// "foot-note" can now be used as a child component in .vue files.

All {{define}} blocks within tmpl are also registered under their block names. Conversion is validated at registration time; unsupported constructs (e.g. {{with}}) return *ConversionError and nothing is registered. Registering the same name twice keeps the latest value ("last write wins").

Supported constructs (tmpl → vue)

Template syntax Vue output
{{.name}} {{ name }}
{{.a.b}} {{ a.b }}
{{if .cond}}…{{end}} <div v-if="cond">…</div>
{{range .items}}…{{end}} <ul><li v-for="item in items">…</li></ul>
{{block "N" .}}…{{end}} <slot name="N">…</slot>
{{template "Name" .}} <Name />

{{with}}, variable assignments, and multi-command pipelines return *ConversionError.

For a complete walkthrough, see docs/tutorial-template-integration.md.


Extension Modules

This repository ships four additional Go modules, each independently versioned with its own go.mod. Importing github.com/dhamidi/htmlc alone never pulls in any of their dependencies.

Module Description
github.com/dhamidi/htmlc/ui/radix Accordion, Tabs, and Dialog — a small Radix-inspired component library demonstrating Options.Mounts (see Component Packages and Mounts).
github.com/dhamidi/htmlc/hypermedia/htmx Zero-dependency request/response header helpers for htmx (IsHTMXRequest, IsBoosted, SetTrigger).
github.com/dhamidi/htmlc/hypermedia/turbo Zero-dependency Turbo Streams/Frames helpers (WantsStream, FrameID, WriteStream).
github.com/dhamidi/htmlc/hypermedia/datastar PatchElementsFragment, combining RenderFragmentSession with datastar-go to drive a Datastar SSE connection.

See each module's package doc comment (e.g. go doc github.com/dhamidi/htmlc/ui/radix) for its full API.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages