htmlc

package module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 18, 2026 License: MIT Imports: 30 Imported by: 0

README

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. Special Attributes
  6. Go API Quick Reference
  7. Expression Language Reference
  8. Debug Mode
  9. Structured Logging
  10. Component Error Handling
  11. Custom Directives
  12. Compatibility with Vue.js
  13. Testing
  14. html/template Integration

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 reactivityv-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)
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>
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 between is allowed).
v-else Yes Must immediately follow a v-if or v-else-if element.
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.

  • Boolean attributes (disabled, checked, selected, readonly, required, multiple, autofocus, open) are omitted entirely when the bound value is falsy.

  • :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(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-cardmy-card)
  2. First letter capitalised (e.g. cardCard)
  3. Kebab-case to PascalCase (e.g. my-cardMyCard)
  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. Only non-@-rule selectors are rewritten for scoping.

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.
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.

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(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",
})
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(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.RenderPageContext(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(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(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

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.

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.

Documentation

Overview

Package htmlc is a server-side Vue-style component engine for Go. It parses .vue Single File Components — each containing a <template>, an optional <script>, and an optional <style> section — and renders them to HTML strings ready to serve via net/http or any http.Handler-based framework.

[htmlc logo]

[htmlc logo]: ./logo.svg

Problem it solves

Writing server-rendered HTML in Go typically means either concatenating strings, using html/template (which has no component model), or pulling in a full JavaScript runtime. htmlc gives you Vue's familiar component authoring format — scoped styles, template directives, and component composition — while keeping rendering purely in Go with no JavaScript dependency.

Mental model

There are four main concepts:

  • Engine – the high-level entry point. It owns a Registry of parsed components discovered from a directory tree. Create one with New; call RenderPage or RenderFragment to produce HTML output. ServeComponent wraps a component as an http.Handler for use with net/http.

  • Component – the parsed representation of one .vue file, produced by ParseFile. Holds the template node tree, script text, style text, and scoped-style metadata.

  • Renderer – the low-level walker that evaluates a Component's template against a data scope and writes HTML. Most callers should use Engine instead.

  • StyleCollector – accumulates scoped-style contributions from all components rendered in one request so they can be emitted as a single <style> block at the end.

Template directives

Every directive below is processed server-side. Client-only directives (@click, v-model) are stripped from the output because there is no JavaScript runtime.

{{ expr }}
    Mustache text interpolation; the expression result is HTML-escaped.
    Example: <p>{{ user.name }}</p>

v-text="expr"
    Sets element text content; HTML-escaped; replaces any child nodes.
    Example: <p v-text="msg"></p>

v-html="expr"
    Sets element inner HTML; the value is NOT HTML-escaped.
    Example: <div v-html="rawHTML"></div>

v-if / v-else-if / v-else
    Conditional rendering; only the first truthy branch is emitted.
    Example: <span v-if="score >= 90">A</span>
             <span v-else-if="score >= 70">B</span>
             <span v-else>C</span>

v-switch="expr" (on <template>)
    Switch-statement conditional rendering. Evaluates the expression once;
    the first child with a matching v-case is rendered; v-default renders
    when no case matched. Not part of stable Vue.js; implements RFC #482.
    Example: <template v-switch="user.role">
               <Admin v-case="'admin'" />
               <User  v-default />
             </template>

v-case="expr"
    Child of <template v-switch>. Rendered when its expression equals the
    parent switch value (Go == comparison).

v-default
    Child of <template v-switch>. Rendered when no preceding v-case matched.

v-for="item in items"
    Iterate over a slice or array. Use (item, i) in items for zero-based
    index access.
    Example: <li v-for="(item, i) in items">{{ i }}: {{ item }}</li>

v-for="n in N"
    Integer range: n iterates 1 … N (inclusive).
    Example: <span v-for="n in 3">{{ n }}</span>

v-for="(val, key) in obj"
    Iterate map entries; val is the value, key is the string key.
    Example: <dt v-for="(val, key) in obj">{{ key }}: {{ val }}</dt>

:attr="expr"
    Dynamic attribute binding. Boolean attributes are omitted when the
    expression is falsy, present without a value when truthy.
    Example: <a :href="url">link</a>

:class="{ key: bool }"
    Object-syntax class binding: keys whose values are truthy are
    included; merged with any static class attribute.
    Example: <div class="base" :class="{ active: isActive }">…</div>

:class="[...]"
    Array-syntax class binding: non-empty string elements are included.
    Example: <div :class="['btn', flag ? 'primary' : '']">…</div>

:style="{ camelKey: val }"
    Inline style binding; camelCase keys are converted to kebab-case.
    Example: <p :style="{ fontSize: '14px', color: 'red' }">…</p>

v-bind="obj"
    Attribute spreading: each key-value pair in the map becomes an HTML
    attribute on the element. Keys "class" and "style" are merged with
    any static and dynamic class/style attributes. Boolean attribute
    semantics apply per key. On child components, the map is spread into
    the component's prop scope (explicit :prop bindings take precedence
    over spread values).
    Example: <div v-bind="attrs"></div>
             <Button v-bind="buttonProps" :type="'submit'" />

v-show="expr"
    Adds style="display:none" when the expression is falsy; the element
    is always present in the output.
    Example: <p v-show="visible">content</p>

v-pre
    Skips all interpolation and directive processing for the subtree;
    mustache syntax is emitted literally.
    Example: <code v-pre>{{ raw }}</code>

@click, v-model
    Client-side event and model directives; stripped on server render.
    Example: <button @click="handler">click</button>

Component composition

Components can include other components in their templates. A child component name must start with an uppercase letter to distinguish it from HTML elements.

## Registering components

There are two ways to make components available for composition:

1. Automatic discovery via ComponentDir: every .vue file in the directory tree is registered under its basename (without the .vue extension).

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

2. Manual registration via Engine.Register or by constructing a Registry directly and passing it to NewRenderer.WithComponents.

engine.Register("Card", cardComponent)

// Low-level API:
htmlc.NewRenderer(page).WithComponents(htmlc.Registry{"Card": card})

## Default slot

A child component declares <slot /> as a placeholder. The parent places inner HTML inside the component tag and it is injected at the slot site.

Card.vue:

<template><div class="card"><slot /></div></template>

Page.vue:

<template><Card><p>inner</p></Card></template>

Renders to: <div class="card"><p>inner</p></div>

## Named slots

A child can declare multiple slots by name using <slot name="…">. The parent fills each slot with a <template #name> element. Unmatched content goes to the default slot.

Layout.vue:

<template>
  <div class="layout">
    <slot name="header"></slot>
    <main><slot></slot></main>
    <slot name="footer"></slot>
  </div>
</template>

Page.vue:

<template>
  <Layout>
    <template #header><h1>Title</h1></template>
    <p>Content</p>
    <template #footer><em>Footer</em></template>
  </Layout>
</template>

## Scoped slots

The child passes data up to the parent via slot props. The parent destructures the props with v-slot="{ item, index }" or binds the whole map with v-slot="props".

List.vue:

<template>
  <ul>
    <li v-for="(item, i) in items">
      <slot :item="item" :index="i"></slot>
    </li>
  </ul>
</template>

Page.vue (destructuring):

<template>
  <List :items="items" v-slot="{ item, index }">
    <span>{{ index }}: {{ item }}</span>
  </List>
</template>

Page.vue (whole map bound to a variable):

<template>
  <Child v-slot="props"><p>{{ props.user.name }}</p></Child>
</template>

## Slot fallback content

Children placed inside a <slot> element in the child component are rendered when the parent provides no content for that slot.

Card.vue:

<template>
  <div class="card"><slot><p>No content provided</p></slot></div>
</template>

Page.vue (no slot content supplied):

<template><Card></Card></template>

Renders to: <div class="card"><p>No content provided</p></div>

## Component resolution

When the renderer encounters a component tag it resolves the name using proximity-based resolution, searching for the nearest matching .vue file relative to the calling component's directory.

Algorithm (applied at each directory level, starting at the caller's dir):

  1. Exact match — "my-card" matches "my-card.vue"
  2. Capitalise first letter — "card" matches "Card.vue"
  3. Kebab to PascalCase — "my-card" matches "MyCard.vue"
  4. Case-insensitive scan — "CARD" matches "card.vue"

If none of the four strategies finds a match in the current directory, the engine walks one level toward the ComponentDir root and repeats. After exhausting all directories it falls back to the flat registry (required for manually registered components and backward compatibility with single-directory projects).

Example directory tree:

templates/
  Card.vue          <- generic card used by root templates
  blog/
    Card.vue        <- blog-specific card
    PostPage.vue    <- <Card> resolves to blog/Card.vue
  admin/
    Card.vue        <- admin-specific card
    Dashboard.vue   <- <Card> resolves to admin/Card.vue
  shop/
    ProductPage.vue <- no Card.vue here; walk-up finds Card.vue at root

PostPage.vue and Dashboard.vue both use an unqualified <Card> tag. Because each has a same-named sibling, they resolve independently without any explicit import or path qualifier.

## Explicit cross-directory references

To target a component in a specific directory regardless of the caller's location, use a path-qualified is attribute on <component>:

<!-- always resolves to blog/Card.vue -->
<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 references do not apply name-folding and return a render error if the named component is not found.

Proximity resolution is enabled automatically when ComponentDir is set. Manually registered components (via Engine.Register) are available through the flat-registry fallback regardless of directory.

Scope propagation

Every component renders in an isolated scope that contains only the props explicitly passed to it as attributes. Parent scope variables are not inherited; this makes data flow visible and prevents accidental coupling.

Engine-level functions (registered with Engine.RegisterFunc) are the one exception: they are injected into every component's scope at every depth. Use RegisterFunc for helper functions — URL builders, route helpers, formatters — that need to be callable from any component without explicit prop threading.

WithDataMiddleware values are injected into the top-level render scope only. If a child component needs a middleware-supplied value, pass it down as an explicit prop.

// Good: helper functions via RegisterFunc — available everywhere
engine.RegisterFunc("url", buildURL)
engine.RegisterFunc("routeActive", checkActive)

// Good: per-request data via explicit props
// In Page.vue: <Shell :currentUser="currentUser" />
// In Shell.vue: {{ currentUser.Name }}

// Avoid: relying on middleware values inside child components
// — they are not automatically propagated.

Scoped styles

Adding <style scoped> to a .vue file generates a unique data-v-XXXXXXXX attribute (derived from the file path) that is stamped on every element rendered by that component. The CSS is rewritten by ScopeCSS so every selector targets only elements bearing that attribute.

When using Engine, styles are collected automatically:

  • RenderPage injects a <style> block immediately before </head>.
  • RenderFragment prepends the <style> block to the output.

When using the low-level API, manage styles manually:

sc := &htmlc.StyleCollector{}
out, err := htmlc.NewRenderer(comp).WithStyles(sc).RenderString(nil)
items := sc.All() // []*htmlc.StyleItem; each has a CSS field

Missing prop handling

By default, a prop name that appears in the template but is absent from the scope map causes a render error. Supply a handler to override this behaviour:

// Engine-wide:
engine.WithMissingPropHandler(htmlc.SubstituteMissingProp)

// Per-render with the low-level API:
out, err := htmlc.NewRenderer(comp).
    WithMissingPropHandler(htmlc.SubstituteMissingProp).
    RenderString(nil)

SubstituteMissingProp is the built-in handler; it emits "MISSING PROP: <name>" in place of the missing value. It is useful during development to surface missing data without aborting the render.

Embedded filesystems

Components can be loaded from an embedded filesystem using go:embed:

//go:embed templates
var templateFS embed.FS

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

Note: Engine.Reload only works when the fs.FS also implements fs.StatFS. The standard os.DirFS satisfies this; embed.FS does not, so Reload is a no-op for embedded filesystems.

Low-level API

Use ParseFile, NewRenderer, WithComponents, WithStyles, and WithDirectives directly when you need request-scoped control over component registration, style collection, or custom directives — for example, in tests or one-off renders outside of a long-lived Engine.

// Parse two components from strings.
card, _ := htmlc.ParseFile("Card.vue", `<template><div class="card"><slot /></div></template>`)
page, _ := htmlc.ParseFile("Page.vue", `<template><Card><p>inner</p></Card></template>`)

// Collect scoped styles while rendering.
sc := &htmlc.StyleCollector{}
out, err := htmlc.NewRenderer(page).
    WithComponents(htmlc.Registry{"Card": card}).
    WithStyles(sc).
    RenderString(nil)
if err != nil { /* handle */ }

// Retrieve scoped CSS generated during the render.
for _, item := range sc.All() {
    fmt.Println(item.CSS)
}

Custom directives

htmlc supports a custom directive system inspired by Vue's custom directives (https://vuejs.org/guide/reusability/custom-directives). Types that implement the Directive interface can be registered under a v-name attribute and are invoked during server-side rendering.

Only the Created and Mounted hooks are supported because htmlc renders server-side only — there are no DOM updates or browser events.

  • Created – called before the element is rendered; may mutate the element's tag (node.Data) and attributes (node.Attr).
  • Mounted – called after the element's closing tag has been written; may write additional HTML to the output writer.

Register custom directives via Options.Directives or Engine.RegisterDirective:

engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Directives: htmlc.DirectiveRegistry{
        "my-dir": &MyDirective{},
    },
})

The built-in VHighlight directive is the canonical example: it sets the background colour of the host element to the CSS colour string supplied by the directive's expression — mirroring the v-highlight example from the Vue.js custom directives guide. VHighlight is not pre-registered; to use it, add it via Options.Directives:

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

DirectiveWithContent

A directive that wants to replace the element's children with custom HTML may implement the optional DirectiveWithContent interface in addition to Directive. After Created is called the renderer checks whether the directive implements DirectiveWithContent and, if InnerHTML() returns a non-empty string, writes it verbatim between the opening and closing tags instead of rendering the template children.

External Directives

htmlc build discovers external directives automatically from the component tree. Any executable file whose base name (without extension) matches v-<name> (lower-kebab-case) is registered as an external directive under that name. The executable communicates with htmlc over newline-delimited JSON on stdin/stdout, receiving a request envelope for each hook invocation and responding with a result envelope. See the README for the full protocol description.

Tutorial

The fastest path to a working server is Engine + RenderPage:

engine, err := htmlc.New(htmlc.Options{ComponentDir: "templates/"})
if err != nil { /* handle */ }

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    if err := engine.RenderPage(w, "Page", map[string]any{"title": "Home"}); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
})

Use RenderPage when the component template is a full HTML document (html/head/body); it injects collected <style> blocks before </head>. Use RenderFragment (or RenderFragmentString) for partial HTML snippets — for example, components rendered inside an existing layout or delivered over HTMX.

For development, enable hot-reload so changes to .vue files are picked up without restarting the server:

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

Runtime introspection with expvar

An Engine can publish its configuration and performance counters to the global expvar registry, making them accessible at /debug/vars (served automatically when net/http/pprof or expvar is imported). Call PublishExpvars with a unique prefix after constructing the engine:

engine, err := htmlc.New(htmlc.Options{ComponentDir: "templates/"})
if err != nil { /* handle */ }
engine.PublishExpvars("htmlc")

// Visiting http://localhost:8080/debug/vars now shows, under "htmlc":
//   "reload": 0, "debug": 0, "renders": 42, "renderNanos": 1234567, …

Published variables:

reload        – 1 if hot-reload is enabled, 0 otherwise
debug         – 1 if debug mode is enabled, 0 otherwise
componentDir  – the active component directory
fs            – the type name of the active fs.FS, or "<nil>"
renders       – total renderComponent calls (includes errors)
renderErrors  – total failed renders
reloads       – total hot-reload re-scans performed
renderNanos   – cumulative render time in nanoseconds
components    – number of unique registered components
info.directives – sorted list of registered custom directive names

Two engines in the same process must use different prefixes:

adminEngine.PublishExpvars("htmlc/admin")
publicEngine.PublishExpvars("htmlc/public")

Runtime option mutation

Reload and Debug can be toggled at runtime without restarting the server:

engine.SetReload(true)   // enable hot-reload
engine.SetDebug(false)   // disable debug mode

When debug mode is active, the root element of each rendered component carries three data-htmlc-* attributes: data-htmlc-component (component registry key), data-htmlc-file (relative path to the .vue source file), and data-htmlc-props (HTML-escaped JSON-encoded props). If props cannot be serialised, data-htmlc-props-error is emitted instead of data-htmlc-props. Debug mode should not be used in production; it adds extra attributes and increases HTML output size.

The component directory and filesystem can be changed atomically; discovery is re-run under the engine's write lock and the engine's state is only updated on success:

if err := engine.SetComponentDir("templates/v2"); err != nil {
    log.Printf("component dir change failed: %v", err)
}

if err := engine.SetFS(newFS); err != nil {
    log.Printf("fs change failed: %v", err)
}

Package htmlc implements bidirectional conversion between htmlc .vue components and Go's standard html/template format.

vue→tmpl direction

VueToTemplate converts a parsed *htmlc.Component to a string containing one or more {{define "name"}}…{{end}} blocks, suitable for parsing with html/template.New("").Parse(result).

Supported constructs:

  • Text interpolation: {{ ident }} and {{ a.b.c }}
  • Bound attributes: :attr="ident" and v-bind:attr="ident"
  • v-if / v-else-if / v-else conditional chains
  • v-for="item in list" loops (outer-scope refs produce ConversionError)
  • v-show="ident" injects conditional style="display:none"
  • v-html="ident" emits {{.ident}} with a warning (caller handles HTML safety)
  • v-text="ident" emits {{.ident}}, discarding children
  • v-bind="ident" spread emits {{ .ident }} with a warning
  • <template v-switch="ident"> with v-case / v-default children
  • <slot> and <slot name="N"> emit {{block}} blocks
  • Zero-prop child components emit {{template "name" .}}

Unsupported constructs (return ConversionError):

  • Complex expressions (anything beyond simple identifiers and dot-paths)
  • Bound props on child components
  • Custom directives
  • Outer-scope variable references inside v-for loops

tmpl→vue direction

TemplateToVue converts html/template source text to .vue component source. This direction is explicitly best-effort; see TemplateToVue for details.

Example

Example demonstrates end-to-end use of the htmlc engine: create an Engine from a directory of .vue files, then render a component as an HTML fragment.

package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/dhamidi/htmlc"
)

func main() {
	dir, err := os.MkdirTemp("", "htmlc-example-*")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	// Write a simple component with no scoped styles so the output is stable.
	vue := `<template><p>Hello, {{ name }}!</p></template>`
	if err := os.WriteFile(filepath.Join(dir, "Greeting.vue"), []byte(vue), 0644); err != nil {
		log.Fatal(err)
	}

	engine, err := htmlc.New(htmlc.Options{ComponentDir: dir})
	if err != nil {
		log.Fatal(err)
	}

	out, err := engine.RenderFragmentString("Greeting", map[string]any{"name": "World"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p>Hello, World!</p>

Index

Examples

Constants

View Source
const MsgComponentFailed = "component render failed"

MsgComponentFailed is the slog message emitted at LevelError when a component render fails.

View Source
const MsgComponentRendered = "component rendered"

MsgComponentRendered is the slog message emitted at LevelDebug when a component renders successfully.

Variables

View Source
var (
	// ErrComponentNotFound is returned when the requested component name is not
	// registered in the engine.
	ErrComponentNotFound = errors.New("htmlc: component not found")

	// ErrMissingProp is returned when a required prop is absent from the render
	// scope and no MissingPropFunc has been set.
	ErrMissingProp = errors.New("htmlc: missing required prop")

	// ErrConversion is returned (wrapped) when an html/template conversion fails.
	// Callers can use errors.As to extract the underlying *ConversionError:
	//
	//	var cerr *ConversionError
	//	if errors.As(err, &cerr) {
	//	    fmt.Println(cerr.Location)
	//	}
	ErrConversion = errors.New("htmlc: conversion failed")
)

Sentinel errors returned by Engine methods.

View Source
var InspectorScript string

InspectorScript is the source of the htmlc on-page inspector tool. It is a self-contained JavaScript module that, when executed, registers and appends the <htmlc-inspector> custom element to the document body.

The script is automatically injected before </body> by RenderPage and RenderPageContext when the engine is created with Options{Debug: true}.

Functions

func DotPrefix added in v0.7.0

func DotPrefix(expr string) (string, error)

DotPrefix converts an htmlc expression to a Go template dot-accessor.

"name"   → ".name"
"a.b.c"  → ".a.b.c"
"."      → "."

Returns an error for ExprComplex inputs.

func ErrorOnMissingProp added in v0.2.0

func ErrorOnMissingProp(name string) (any, error)

ErrorOnMissingProp is a MissingPropFunc that aborts rendering with an error whenever a prop is missing. Use it to restore strict validation:

renderer.WithMissingPropHandler(htmlc.ErrorOnMissingProp)

func Render

func Render(w io.Writer, c *Component, scope map[string]any) error

Render is a convenience wrapper that creates a temporary Renderer for c and writes the rendered HTML directly to w. It does not collect styles or support component composition; use NewRenderer with WithStyles and WithComponents for those features.

Example (ComponentProps)

ExampleRender_componentProps shows a parent passing a dynamic prop via :title="expr" and a static string prop via class="x" to a child component.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	header, _ := htmlc.ParseFile("Header.vue", `<template><h1>{{ title }}</h1></template>`)
	page, _ := htmlc.ParseFile("Page.vue", `<template><Header :title="heading" class="main"></Header></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"Header": header}).
		RenderString(map[string]any{"heading": "My Site"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<h1>My Site</h1>
Example (ComponentSlot)

ExampleRender_componentSlot shows a parent component passing inner HTML content into a child component's <slot /> placeholder.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	card, _ := htmlc.ParseFile("Card.vue", `<template><div class="card"><slot /></div></template>`)
	page, _ := htmlc.ParseFile("Page.vue", `<template><Card><p>inner</p></Card></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"Card": card}).
		RenderString(nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<div class="card"><p>inner</p></div>
Example (EventPassthrough)

ExampleRender_eventPassthrough shows that client-side directives such as @click and v-model are stripped from server-rendered output because there is no client-side Vue runtime to process them.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><button @click="handler">click</button><input v-model="name"></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<button>click</button><input>
Example (Interpolation)

ExampleRender_interpolation shows {{ expr }} text interpolation: member access, arithmetic, and ternary expressions all evaluated at render time.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><p>{{ user.name }}, total {{ price * qty }}, active: {{ active ? "yes" : "no" }}</p></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, map[string]any{
		"user":   map[string]any{"name": "Alice"},
		"price":  float64(10),
		"qty":    float64(3),
		"active": true,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p>Alice, total 30, active: yes</p>
Example (NamedSlots)

ExampleRender_namedSlots demonstrates named slots: a Layout component declares header and footer named slots plus a default slot for body content, and the caller fills each slot with a <template #name> element.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	layout, _ := htmlc.ParseFile("Layout.vue",
		`<template><div class="layout"><slot name="header"></slot><main><slot></slot></main><slot name="footer"></slot></div></template>`)
	page, _ := htmlc.ParseFile("Page.vue",
		`<template><Layout><template #header><h1>Title</h1></template><p>Content</p><template #footer><em>Footer</em></template></Layout></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"Layout": layout}).
		RenderString(nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<div class="layout"><h1>Title</h1><main><p>Content</p></main><em>Footer</em></div>
Example (ScopedSlots)

ExampleRender_scopedSlots demonstrates scoped slots: a List component passes each item and its index to the caller via slot props, and the caller uses v-slot="{ item, index }" to render a custom item template.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	list, _ := htmlc.ParseFile("List.vue",
		`<template><ul><li v-for="(item, i) in items"><slot :item="item" :index="i"></slot></li></ul></template>`)
	page, _ := htmlc.ParseFile("Page.vue",
		`<template><List :items="items" v-slot="{ item, index }"><span>{{ index }}: {{ item }}</span></List></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"List": list}).
		RenderString(map[string]any{"items": []any{"a", "b"}})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<ul><li><span>0: a</span></li><li><span>1: b</span></li></ul>
Example (SingleVariableSlotBinding)

ExampleRender_singleVariableSlotBinding demonstrates v-slot="slotProps" binding: the entire slot props map is bound to a single variable, allowing the caller to access any prop via slotProps.key.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	child, _ := htmlc.ParseFile("Child.vue",
		`<template><div><slot :user="theuser" :count="total"></slot></div></template>`)
	page, _ := htmlc.ParseFile("Page.vue",
		`<template><Child :theuser="u" :total="n" v-slot="props"><p>{{ props.user.name }}: {{ props.count }}</p></Child></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"Child": child}).
		RenderString(map[string]any{
			"u": map[string]any{"name": "Alice"},
			"n": float64(3),
		})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<div><p>Alice: 3</p></div>
Example (SlotFallbackContent)

ExampleRender_slotFallbackContent demonstrates slot fallback content: when the caller provides no content for a slot, the child component's fallback children inside <slot>…</slot> are rendered instead.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	card, _ := htmlc.ParseFile("Card.vue",
		`<template><div class="card"><slot><p>No content provided</p></slot></div></template>`)
	page, _ := htmlc.ParseFile("Page.vue", `<template><Card></Card></template>`)
	out, err := htmlc.NewRenderer(page).
		WithComponents(htmlc.Registry{"Card": card}).
		RenderString(nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<div class="card"><p>No content provided</p></div>
Example (VBind)

ExampleRender_vBind shows :attr dynamic attribute binding, and how boolean attributes are handled: :disabled="false" omits the attribute entirely while :disabled="true" emits it without a value.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc"
)

func main() {
	// Dynamic href binding.
	c1, _ := htmlc.ParseFile("t.vue", `<template><a :href="url">link</a></template>`)
	o1, _ := htmlc.RenderString(c1, map[string]any{"url": "https://example.com"})
	fmt.Println(o1)

	// :disabled="false" — attribute is omitted.
	c2, _ := htmlc.ParseFile("t.vue", `<template><button :disabled="false">enabled</button></template>`)
	o2, _ := htmlc.RenderString(c2, nil)
	fmt.Println(o2)

	// :disabled="true" — attribute is present without a value.
	c3, _ := htmlc.ParseFile("t.vue", `<template><button :disabled="true">disabled</button></template>`)
	o3, _ := htmlc.RenderString(c3, nil)
	fmt.Println(o3)
}
Output:
<a href="https://example.com">link</a>
<button>enabled</button>
<button disabled>disabled</button>
Example (VBindClass)

ExampleRender_vBindClass shows :class with object syntax (keys whose values are truthy are included) and array syntax, both merged with a static class.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc"
)

func main() {
	// Object syntax merged with static class="base".
	c1, _ := htmlc.ParseFile("t.vue", `<template><div class="base" :class="{ active: isActive, hidden: isHidden }">x</div></template>`)
	o1, _ := htmlc.RenderString(c1, map[string]any{"isActive": true, "isHidden": false})
	fmt.Println(o1)

	// Array syntax: non-empty strings are included.
	c2, _ := htmlc.ParseFile("t.vue", `<template><div :class="['btn', flag ? 'primary' : '']">y</div></template>`)
	o2, _ := htmlc.RenderString(c2, map[string]any{"flag": true})
	fmt.Println(o2)
}
Output:
<div class="base active">x</div>
<div class="btn primary">y</div>
Example (VBindStyle)

ExampleRender_vBindStyle shows :style with an object whose camelCase keys are automatically converted to kebab-case CSS property names.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><p :style="{ color: 'red', fontSize: '14px' }">styled</p></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p style="color:red;font-size:14px">styled</p>
Example (VFor)

ExampleRender_vFor shows v-for iterating over an array, with (item, index) destructuring, and over an integer range (n in N produces 1..N).

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc"
)

func main() {
	// Plain array.
	c1, _ := htmlc.ParseFile("t.vue", `<template><li v-for="item in items">{{ item }}</li></template>`)
	o1, _ := htmlc.RenderString(c1, map[string]any{"items": []any{"a", "b", "c"}})
	fmt.Println(o1)

	// Array with zero-based index.
	c2, _ := htmlc.ParseFile("t.vue", `<template><li v-for="(item, i) in items">{{ i }}:{{ item }}</li></template>`)
	o2, _ := htmlc.RenderString(c2, map[string]any{"items": []any{"x", "y"}})
	fmt.Println(o2)

	// Integer range: n iterates 1, 2, 3.
	c3, _ := htmlc.ParseFile("t.vue", `<template><span v-for="n in 3">{{ n }}</span></template>`)
	o3, _ := htmlc.RenderString(c3, nil)
	fmt.Println(o3)
}
Output:
<li>a</li><li>b</li><li>c</li>
<li>0:x</li><li>1:y</li>
<span>1</span><span>2</span><span>3</span>
Example (VForObject)

ExampleRender_vForObject shows v-for iterating map entries using the (value, key) destructuring form.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><dt v-for="(value, key) in obj">{{ key }}: {{ value }}</dt></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	// Use a single-entry map so iteration order is deterministic.
	out, err := htmlc.RenderString(comp, map[string]any{
		"obj": map[string]any{"lang": "Go"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<dt>lang: Go</dt>
Example (VHtml)

ExampleRender_vHtml shows v-html="expr" which renders a raw HTML string as element content without escaping angle brackets or other HTML characters.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><div v-html="raw"></div></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, map[string]any{"raw": "<b>bold</b>"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<div><b>bold</b></div>
Example (VIf)

ExampleRender_vIf shows v-if/v-else-if/v-else conditional rendering: only the first truthy branch produces output; the rest are skipped entirely.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><span v-if="score >= 90">A</span><span v-else-if="score >= 70">B</span><span v-else>C</span></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, map[string]any{"score": float64(75)})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<span>B</span>
Example (VPre)

ExampleRender_vPre shows v-pre: mustache syntax inside the element is emitted literally without any interpolation or directive processing.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><code v-pre>{{ raw }}</code></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, map[string]any{"raw": "ignored"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<code>{{ raw }}</code>
Example (VShow)

ExampleRender_vShow shows v-show: a falsy expression injects style="display:none" while the element is still present in the DOM; a truthy expression renders the element normally.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><p v-show="false">hidden</p><p v-show="true">visible</p></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p style="display:none">hidden</p><p>visible</p>
Example (VText)

ExampleRender_vText shows v-text="expr" which sets element text content with HTML escaping, replacing any child nodes.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	const src = `<template><p v-text="msg"></p></template>`
	comp, err := htmlc.ParseFile("t.vue", src)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.RenderString(comp, map[string]any{"msg": "Hello & World"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p>Hello &amp; World</p>

func RenderString

func RenderString(c *Component, scope map[string]any) (string, error)

RenderString is a convenience wrapper that creates a temporary Renderer for c and renders it against scope, returning the result as a string. It does not collect styles or support component composition; use NewRenderer with WithStyles and WithComponents for those features.

func ScopeCSS

func ScopeCSS(css, scopeAttr string) string

ScopeCSS rewrites the CSS text so that every selector in every non-@-rule has scopeAttr appended to its last compound selector. scopeAttr should be a full attribute selector string, e.g. "[data-v-a1b2c3d4]".

@-rules (such as @media or @keyframes) are passed through verbatim, including their nested blocks.

func ScopeID

func ScopeID(path string) string

ScopeID computes the scope attribute name for a component at the given file path. The result is "data-v-" followed by the 8 lower-case hex digits of the FNV-1a 32-bit hash of path.

func Snippet added in v0.7.0

func Snippet(src string, line int) string

Snippet returns a ≈3-line context window around line (1-based) in src, with a ">" marker on the target line — matches existing htmlc error style.

func SubstituteMissingProp

func SubstituteMissingProp(name string) (any, error)

SubstituteMissingProp returns a placeholder string "MISSING PROP: <name>" for any missing prop.

Types

type Component

type Component struct {
	// Template is the root of the parsed HTML node tree for the <template> section.
	Template *html.Node
	// Script is the raw text content of the <script> section (empty if absent).
	Script string
	// Style is the raw text content of the <style> section (empty if absent).
	Style string
	// Scoped reports whether the <style> tag carried the scoped attribute.
	Scoped bool
	// Path is the source file path passed to ParseFile.
	Path string
	// Source is the raw source text of the file, stored for location-aware error reporting.
	Source string
	// Warnings holds non-fatal issues found during parsing, such as self-closing
	// custom component tags that were automatically rewritten.
	Warnings []string
}

Component holds the parsed representation of a .vue Single File Component.

func ParseFile

func ParseFile(path, src string) (*Component, error)

ParseFile parses a .vue Single File Component from src and returns a Component. Only the top-level <template>, <script>, and <style> sections are extracted. <script> and <style> are optional; <template> is required. The template HTML is parsed into a node tree accessible via Component.Template.

Example

ExampleParseFile parses an inline .vue source string and inspects the resulting Component's path.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	comp, err := htmlc.ParseFile("Greeting.vue", `<template><p>Hello!</p></template>`)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(comp.Path)
}
Output:
Greeting.vue

func (*Component) Props

func (c *Component) Props() []PropInfo

Props walks the component's parsed template AST and returns all top-level variable references (props) that the template uses.

Identifiers starting with '$' are excluded. v-for loop variables are excluded within their subtree.

Example

ExampleComponent_Props shows how to call Props() to discover the prop names a component template uses and the expressions in which they appear.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	comp, err := htmlc.ParseFile("t.vue", `<template><p>{{ message }}</p></template>`)
	if err != nil {
		log.Fatal(err)
	}
	props := comp.Props()
	fmt.Println(len(props))
	fmt.Println(props[0].Name)
	fmt.Println(props[0].Expressions[0])
}
Output:
1
message
message

type ComponentErrorHandler added in v0.8.0

type ComponentErrorHandler func(w io.Writer, path []string, err error) error

ComponentErrorHandler is called when a child component fails to render. w is the writer at the failure site; path is the full component path from the page root to the failing component. err is the render error.

Return nil to write a placeholder and continue rendering sibling nodes. Return a non-nil error to abort the entire render immediately.

func HTMLErrorHandler added in v0.8.0

func HTMLErrorHandler() ComponentErrorHandler

HTMLErrorHandler returns a ComponentErrorHandler that renders a visible <div> placeholder for each failed component. It is intended for development use. The generated markup uses the class "htmlc-error" for easy targeting with CSS. path and err are HTML-escaped before inclusion.

type ConversionError added in v0.7.0

type ConversionError struct {
	Component string          // component name, e.g. "PostPage"
	Directive string          // directive name, e.g. "v-if" (may be empty)
	Message   string          // human-readable cause
	Location  *SourceLocation // source position; may be nil
	Cause     error           // underlying error; may be nil
}

ConversionError is returned when a .vue→tmpl or tmpl→.vue conversion encounters an unsupported construct. It is always returned wrapped together with ErrConversion so callers can detect it with either errors.Is or errors.As:

var cerr *htmlc.ConversionError
if errors.As(err, &cerr) {
    log.Printf("conversion failed at %s:%d: %s", cerr.Location.File, cerr.Location.Line, cerr.Message)
}

Conversion halts on the first unsupported construct; no partial output is produced.

func (*ConversionError) Error added in v0.7.0

func (e *ConversionError) Error() string

func (*ConversionError) Unwrap added in v0.7.0

func (e *ConversionError) Unwrap() error

type Directive

type Directive interface {
	// Created is called before the element is rendered. The hook receives a
	// shallow-cloned working node whose Attr slice and Data field may be
	// freely mutated; mutations affect what the renderer emits for this
	// element but do not modify the shared parsed template.
	//
	// Common uses:
	//   - Add, remove, or rewrite attributes (node.Attr).
	//   - Change the element tag (node.Data) to redirect rendering to a
	//     different component.
	//   - Return a non-nil error to abort rendering of this element.
	Created(node *html.Node, binding DirectiveBinding, ctx DirectiveContext) error

	// Mounted is called after the element's closing tag has been written to w.
	// The hook may write additional HTML after the element.
	//
	// w is the same writer the renderer uses; bytes written here appear
	// immediately after the element in the output stream.
	//
	// Return a non-nil error to abort rendering.
	Mounted(w io.Writer, node *html.Node, binding DirectiveBinding, ctx DirectiveContext) error
}

Directive is the interface implemented by custom directive types.

Register a custom directive with Engine.RegisterDirective or Renderer.WithDirectives. In a template, reference it as v-<name>:

<div v-my-directive="someExpr">…</div>

Only the Created and Mounted hooks are called because htmlc renders server- side. There are no DOM updates, component unmounting, or browser events.

type DirectiveBinding

type DirectiveBinding struct {
	// Value is the result of evaluating the directive expression against the
	// current scope. For example, v-switch="item.type" yields item.type's value.
	Value any

	// RawExpr is the un-evaluated expression string from the template attribute.
	RawExpr string

	// Arg is the directive argument after the colon, e.g. "href" in v-bind:href.
	// Empty string when no argument is present.
	Arg string

	// Modifiers is the set of dot-separated modifiers, e.g. {"prevent": true}
	// from v-on:click.prevent. Empty map when no modifiers are present.
	Modifiers map[string]bool
}

DirectiveBinding holds the evaluated binding for a custom directive invocation.

type DirectiveContext

type DirectiveContext struct {
	// Registry is the component registry the renderer is using. Directives
	// can use this to verify or resolve component names.
	Registry Registry

	// RenderedChildHTML is the fully rendered inner HTML of the directive's
	// host element, with all template expressions evaluated and child
	// components expanded. It is empty for void elements.
	// Available in both Created and Mounted hooks.
	RenderedChildHTML string
}

DirectiveContext provides directive hooks read-only access to renderer state.

type DirectiveRegistry

type DirectiveRegistry map[string]Directive

DirectiveRegistry maps directive names (without the "v-" prefix) to their implementations. Keys are lower-kebab-case; the renderer normalises names before lookup.

type DirectiveWithContent added in v0.5.0

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)
}

DirectiveWithContent is an optional extension of the Directive interface. When a directive's Created hook wants to replace the element's children with custom HTML it should implement this interface.

The renderer checks for this interface after calling Created. If InnerHTML returns a non-empty string the element's template children are skipped and the string is written verbatim between the opening and closing tags (equivalent to v-html on the element itself).

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine is the entry point for rendering .vue components. Create one with New; call RenderPage or RenderFragment to produce HTML. ServeComponent wraps a component as a net/http handler so it can be mounted directly in an http.Handler-based server.

Engine is safe for concurrent use. All render methods may be called from multiple goroutines simultaneously.

Example (ScopedStyles)

ExampleEngine_scopedStyles shows that a component with <style scoped> adds a unique data attribute to each HTML element and scopes its CSS selectors to match only elements within that component.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	// ParseFile with a fixed path gives a deterministic scope ID.
	const path = "Button.vue"
	comp, err := htmlc.ParseFile(path, `<template><p>hello</p></template><style scoped>p{color:red}</style>`)
	if err != nil {
		log.Fatal(err)
	}
	sc := &htmlc.StyleCollector{}
	out, err := htmlc.NewRenderer(comp).WithStyles(sc).RenderString(nil)
	if err != nil {
		log.Fatal(err)
	}
	items := sc.All()
	fmt.Println(out)
	fmt.Println(items[0].CSS)
}
Output:
<p data-v-6fc690bb>hello</p>
p[data-v-6fc690bb]{color:red}

func New

func New(opts Options) (*Engine, error)

New creates an Engine configured by opts. If opts.ComponentDir is set the directory is walked recursively and all *.vue files are registered.

func (*Engine) CompileToTemplate added in v0.7.0

func (e *Engine) CompileToTemplate(componentName string) (*htmltmpl.Template, error)

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

The root component becomes the primary named template; all sub-components are added as named {{ define }} blocks in the same template set. Template names follow Go convention: the component name is lowercased (e.g. "Card" → "card").

Scoped <style> blocks are stripped from the output. Non-recoverable conversion errors (unsupported directives, complex expressions) are returned as *ConversionError with source location information, wrapped together with ErrConversion so callers can test with either errors.Is or errors.As.

The returned *html/template.Template is safe to call with Execute or ExecuteTemplate for any data value compatible with the component's props.

Example

ExampleEngine_CompileToTemplate demonstrates compiling a .vue component to a *html/template.Template and executing it with Go template data.

The component name is lowercased to form the template name: "Hello" → "hello". Sub-components referenced by the root are included as named {{ define }} blocks in the same template set.

package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"

	"github.com/dhamidi/htmlc"
)

func main() {
	dir, err := os.MkdirTemp("", "htmlc-ex-compile-*")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	// A .vue component with a simple text interpolation.
	vue := `<template><p>{{ message }}</p></template>`
	if err := os.WriteFile(filepath.Join(dir, "Hello.vue"), []byte(vue), 0644); err != nil {
		log.Fatal(err)
	}

	engine, err := htmlc.New(htmlc.Options{ComponentDir: dir})
	if err != nil {
		log.Fatal(err)
	}

	// CompileToTemplate converts the .vue component to a *html/template.Template.
	tmpl, err := engine.CompileToTemplate("Hello")
	if err != nil {
		log.Fatal(err)
	}

	var buf strings.Builder
	if err := tmpl.Execute(&buf, map[string]any{"message": "Hello"}); err != nil {
		log.Fatal(err)
	}
	fmt.Println(buf.String())
}
Output:
<p>Hello</p>

func (*Engine) Components

func (e *Engine) Components() []string

Components returns the names of all registered components in sorted order. Lowercase aliases added automatically by the engine are excluded.

func (*Engine) Has

func (e *Engine) Has(name string) bool

Has reports whether name is a registered component.

func (*Engine) Mount

func (e *Engine) Mount(mux *http.ServeMux, routes map[string]string)

Mount registers a set of component routes on mux. The routes map keys are patterns accepted by http.ServeMux (e.g. "GET /{$}", "GET /about"), and values are component names. Each component is served as a full HTML page via ServePageComponent with no data function (use WithDataMiddleware to inject common data, or register routes manually for per-route data).

func (*Engine) PublishExpvars added in v0.7.0

func (e *Engine) PublishExpvars(prefix string) *Engine

PublishExpvars registers the engine's configuration and performance counters in the global expvar registry under the given prefix. The prefix must be unique across all engines in the process; calling PublishExpvars with a prefix that is already registered panics (same as expvar.NewMap).

After calling PublishExpvars, the engine's vars are accessible at /debug/vars under the key prefix, and the following sub-keys are available:

reload        – 1 if hot-reload is enabled, 0 otherwise
debug         – 1 if debug mode is enabled, 0 otherwise
componentDir  – the current component directory
fs            – the type name of the current fs.FS, or "<nil>"
renders       – total number of renderComponent calls
renderErrors  – total number of failed renders
reloads       – total number of hot-reload re-scans performed
renderNanos   – cumulative render time in nanoseconds
components    – number of unique registered components
info.directives – sorted list of registered custom directive names

PublishExpvars returns the Engine so calls can be chained.

func (*Engine) Register

func (e *Engine) Register(name, path string) error

Register manually adds a component from path to the engine's registry under name, without requiring a directory scan. This is useful when components are generated programmatically or loaded from locations outside ComponentDir.

func (*Engine) RegisterDirective

func (e *Engine) RegisterDirective(name string, dir Directive)

RegisterDirective adds a custom directive to the engine under the given name (without the "v-" prefix). It replaces any previously registered directive with the same name. Panics if dir is nil.

func (*Engine) RegisterFunc

func (e *Engine) RegisterFunc(name string, fn func(...any) (any, error)) *Engine

RegisterFunc adds a per-engine function available in all template expressions rendered by this engine. The function can be called from templates as name(). Engine-level functions act as lower-priority builtins: the render data scope takes precedence over them, which in turn takes precedence over the global expr.RegisterBuiltin table.

Functions registered here are propagated automatically into every child component's scope, so they are available at any nesting depth without being threaded through as explicit props.

RegisterFunc returns the Engine so calls can be chained.

func (*Engine) RegisterTemplate added in v0.7.0

func (e *Engine) RegisterTemplate(name string, tmpl *htmltmpl.Template) error

RegisterTemplate registers an existing *html/template.Template as a virtual htmlc component under name. The template is converted to htmlc's internal representation using the tmpl→vue conversion.

All named {{ define }} blocks within tmpl are also registered as components accessible by their block names.

If conversion fails (unsupported template constructs), RegisterTemplate returns a *ConversionError wrapped with ErrConversion and does not register anything.

RegisterTemplate validates the template at registration time (fail-fast behaviour); it does not defer validation to render time.

When called with a name already in use, the new registration wins ("last write wins"), consistent with flat-registry behaviour.

Example

ExampleEngine_RegisterTemplate demonstrates registering an existing *html/template.Template as an htmlc component so it can be used inside .vue component trees.

After registration, the component name resolves normally inside any .vue file: <site-header /> renders the registered template's output.

package main

import (
	"fmt"
	htmltemplate "html/template"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	engine, err := htmlc.New(htmlc.Options{})
	if err != nil {
		log.Fatal(err)
	}

	// An existing html/template from a legacy codebase.
	headerTmpl := htmltemplate.Must(
		htmltemplate.New("site-header").Parse(`<header>Site Title</header>`),
	)

	// Register it as an htmlc component. All named {{ define }} blocks within
	// headerTmpl are also registered under their own names.
	if err := engine.RegisterTemplate("site-header", headerTmpl); err != nil {
		log.Fatal(err)
	}

	fmt.Println(engine.Has("site-header"))
}
Output:
true

func (*Engine) RenderFragment

func (e *Engine) RenderFragment(w io.Writer, name string, data map[string]any) error

RenderFragment renders name as an HTML fragment, writing the result to w, and prepends the collected <style> block to the output. Unlike RenderPage, it does not search for a </head> tag — it simply places the styles before the HTML, making it suitable for partial page updates (e.g. HTMX responses, turbo frames, or any context where a complete HTML document structure is not present).

For full HTML documents that include a <head> section, use RenderPage instead so that styles are injected in the document head.

func (*Engine) RenderFragmentContext

func (e *Engine) RenderFragmentContext(ctx context.Context, w io.Writer, name string, data map[string]any) error

RenderFragmentContext is like RenderFragment but accepts a context.Context. The render is aborted and ctx.Err() is returned if the context is cancelled or its deadline is exceeded during rendering.

func (*Engine) RenderFragmentString

func (e *Engine) RenderFragmentString(name string, data map[string]any) (string, error)

RenderFragmentString renders name as an HTML fragment and returns the result as a string. It is a convenience wrapper around RenderFragment for callers that need a string rather than writing to an io.Writer.

func (*Engine) RenderPage

func (e *Engine) RenderPage(w io.Writer, name string, data map[string]any) error

RenderPage renders name as a full HTML page, writing the result to w. It collects all scoped styles from the component tree and inserts them as a <style> block immediately before the first </head> tag, keeping styles in the document head where browsers expect them. If the output contains no </head> the style block is prepended to the output instead.

Use RenderPage when rendering a complete HTML document (e.g. a page component that includes <!DOCTYPE html>, <html>, <head>, and <body>). For partial HTML — such as HTMX responses or turbo-frame updates — use RenderFragment instead, which prepends styles without searching for </head>.

Example

ExampleEngine_RenderPage demonstrates full-page rendering: collected <style> blocks are injected immediately before </head>.

package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/dhamidi/htmlc"
)

func main() {
	dir, err := os.MkdirTemp("", "htmlc-example-*")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	const vue = `<template><html><head><title>Demo</title></head><body><p>Hello</p></body></html></template><style>body{margin:0}</style>`
	if err := os.WriteFile(filepath.Join(dir, "Page.vue"), []byte(vue), 0644); err != nil {
		log.Fatal(err)
	}

	engine, err := htmlc.New(htmlc.Options{ComponentDir: dir})
	if err != nil {
		log.Fatal(err)
	}

	out, err := engine.RenderPageString("Page", nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<html><head><title>Demo</title><style>body{margin:0}</style></head><body><p>Hello</p></body></html>

func (*Engine) RenderPageContext

func (e *Engine) RenderPageContext(ctx context.Context, w io.Writer, name string, data map[string]any) error

RenderPageContext is like RenderPage but accepts a context.Context. The render is aborted and ctx.Err() is returned if the context is cancelled or its deadline is exceeded during rendering.

func (*Engine) RenderPageString

func (e *Engine) RenderPageString(name string, data map[string]any) (string, error)

RenderPageString renders name as a full HTML page and returns the result as a string. It is a convenience wrapper around RenderPage for callers that need a string rather than writing to an io.Writer.

func (*Engine) ServeComponent

func (e *Engine) ServeComponent(name string, data func(*http.Request) map[string]any) http.HandlerFunc

ServeComponent returns an http.HandlerFunc that renders name as a fragment and writes it with content-type "text/html; charset=utf-8". The data function is called on every request to obtain the data map passed to the template; it may be nil (in which case no data is provided).

Data middleware registered via WithDataMiddleware is applied after the data function, allowing common data (e.g. the current user or CSRF token) to be injected globally.

Example

ExampleEngine_ServeComponent shows how ServeComponent wraps a component as an http.HandlerFunc, demonstrated with an httptest round-trip.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"

	"github.com/dhamidi/htmlc"
)

func main() {
	dir, err := os.MkdirTemp("", "htmlc-example-*")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	if err := os.WriteFile(filepath.Join(dir, "Hello.vue"), []byte(`<template><p>hello</p></template>`), 0644); err != nil {
		log.Fatal(err)
	}

	engine, err := htmlc.New(htmlc.Options{ComponentDir: dir})
	if err != nil {
		log.Fatal(err)
	}

	h := engine.ServeComponent("Hello", nil)
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	h(rec, req)

	fmt.Println(rec.Code)
	fmt.Println(rec.Header().Get("Content-Type"))
	fmt.Println(rec.Body.String())
}
Output:
200
text/html; charset=utf-8
<p>hello</p>

func (*Engine) ServePageComponent

func (e *Engine) ServePageComponent(name string, data func(*http.Request) (map[string]any, int)) http.HandlerFunc

ServePageComponent returns an http.HandlerFunc that renders name as a full HTML page (using RenderPage, which injects styles into </head>) and writes it with content-type "text/html; charset=utf-8".

The data function is called on every request to obtain the data map and the HTTP status code to send. If the data function is nil, a 200 OK response with no template data is used. A status code of 0 is treated as 200.

Data middleware registered via WithDataMiddleware is applied after the data function.

func (*Engine) SetComponentDir added in v0.7.0

func (e *Engine) SetComponentDir(dir string) error

SetComponentDir changes the component directory at runtime, re-running discovery atomically under the engine's write lock. If discovery fails, the engine's state is unchanged and the error is returned.

func (*Engine) SetDebug added in v0.7.0

func (e *Engine) SetDebug(enabled bool)

SetDebug enables or disables debug render mode at runtime. When enabled, the root element of each rendered component carries data-htmlc-* attributes for component name, source file, and serialised props. See Options.Debug for the full description.

func (*Engine) SetFS added in v0.7.0

func (e *Engine) SetFS(fsys fs.FS) error

SetFS changes the fs.FS used for component discovery and file reads at runtime, re-running discovery atomically under the engine's write lock. If discovery fails, the engine's state is unchanged and the error is returned.

func (*Engine) SetReload added in v0.7.0

func (e *Engine) SetReload(enabled bool)

SetReload enables or disables hot-reload at runtime. When enabled, the engine checks component file modification times before each render and automatically re-parses changed files. The change takes effect on the next render call.

func (*Engine) TemplateText added in v0.7.0

func (e *Engine) TemplateText(componentName string) (text string, warnings []string, err error)

TemplateText returns the raw html/template-compatible text for componentName and all its statically-referenced sub-components. The text consists of {{ define }} blocks suitable for html/template.New("").Parse(text).

This is the text form of CompileToTemplate; see that method for full semantics. warnings contains any non-fatal conversion warnings emitted by the html/template conversion (for example, data-contract notices for v-html and v-bind spread).

Error types follow the same conventions as CompileToTemplate: ErrComponentNotFound when componentName is not registered, and *ConversionError (wrapped with ErrConversion) when a directive or expression cannot be converted.

func (*Engine) ValidateAll

func (e *Engine) ValidateAll() []ValidationError

ValidateAll checks every registered component for unresolvable child component references and returns a slice of ValidationError (one per problem). An empty slice means all components are valid.

ValidateAll uses the same proximity-based resolution as the renderer: a reference that can be resolved via the proximity walk or the flat registry is considered valid. Only references that cannot be found by either mechanism are reported as errors.

ValidateAll is intended to be called once at application startup to surface missing-component problems early ("fail fast").

func (*Engine) WithDataMiddleware

func (e *Engine) WithDataMiddleware(fn func(*http.Request, map[string]any) map[string]any) *Engine

WithDataMiddleware adds a function that is called on every HTTP-triggered render to augment the data map. Middleware functions are called in registration order; later middleware can overwrite keys set by earlier ones.

Data middleware applies only to the HTTP-triggered top-level render and is not automatically propagated into child component scopes. If child components need access to values injected by middleware, those values must be passed as explicit props or the same values should be registered via RegisterFunc.

WithDataMiddleware returns the Engine so calls can be chained.

func (*Engine) WithMissingPropHandler

func (e *Engine) WithMissingPropHandler(fn MissingPropFunc) *Engine

WithMissingPropHandler sets the function called when any component rendered by this engine has a missing prop. The default behaviour (when no handler is set) is to render a visible "[missing: <name>]" placeholder in place of the prop value. Use ErrorOnMissingProp to restore strict error behaviour, or SubstituteMissingProp to use the legacy "MISSING PROP: <name>" format.

type ExprKind added in v0.7.0

type ExprKind int

ExprKind classifies a template expression string.

const (
	ExprSimpleIdent ExprKind = iota // "name"
	ExprDotPath                     // "a.b.c"
	ExprComplex                     // anything else — errors on conversion
)

func ClassifyExpr added in v0.7.0

func ClassifyExpr(expr string) ExprKind

ClassifyExpr inspects expr and returns its kind. "." alone is treated as ExprSimpleIdent (maps to "." in html/template).

type MapProps added in v0.8.0

type MapProps struct {
	// contains filtered or unexported fields
}

MapProps wraps map[string]any and implements Props.

func (MapProps) Get added in v0.8.0

func (p MapProps) Get(key string) (any, bool)

func (MapProps) Keys added in v0.8.0

func (p MapProps) Keys() []string

type MissingPropFunc

type MissingPropFunc func(name string) (any, error)

MissingPropFunc is called when a prop expected by the component's template is not present in the render scope. It receives the prop name and returns a substitute value, or an error to abort rendering.

type Options

type Options struct {
	// ComponentDir is the directory to scan recursively for *.vue files.
	// Components are discovered by walking the tree in lexical order; each file
	// is registered by its base name without extension (e.g. "Button.vue"
	// becomes "Button").
	//
	// When ComponentDir is set the engine uses proximity-based resolution:
	// a tag reference in a template is first looked up in the same directory
	// as the calling component, then walks toward the root one level at a time.
	// This allows same-named components in different subdirectories to coexist
	// without conflict. See the README for details and examples.
	//
	// When two files share the same base name and directory the last one
	// encountered in lexical-order traversal wins in the flat registry
	// (backward-compatibility fallback path).
	ComponentDir string
	// Reload enables hot-reload for development use. When true, the engine
	// checks the modification time of every registered component file before
	// each render and automatically re-parses any file that has changed since
	// it was last loaded. This lets you edit .vue files and see the results
	// without restarting the server. For production, leave Reload false and
	// create the Engine once at startup.
	//
	// When FS is also set, reload only works if the FS implements fs.StatFS.
	// If it does not, reload is silently skipped for all entries.
	Reload bool
	// FS, when set, is used instead of the OS filesystem for all file reads
	// and directory walks. ComponentDir is then interpreted as a path within
	// this FS. This allows callers to use embedded filesystems (//go:embed),
	// in-memory virtual filesystems, or any other fs.FS implementation.
	//
	// When FS is nil, the OS filesystem is used (default behaviour).
	FS fs.FS
	// Directives registers custom directives available to all components rendered
	// by this engine. Keys are directive names without the "v-" prefix
	// (e.g. "switch" handles v-switch). Built-in directives (v-if, v-for, etc.)
	// cannot be overridden.
	Directives DirectiveRegistry
	// Debug enables debug render mode. When true, the root element of each
	// rendered component carries three data-htmlc-* attributes:
	// data-htmlc-component (component name), data-htmlc-file (source path),
	// and data-htmlc-props (JSON-encoded props). If props cannot be
	// serialised, data-htmlc-props-error is emitted instead. Debug mode
	// has no effect on components whose template has no single root element
	// (fragment templates). Debug mode should not be used in production as
	// it adds extra attributes and increases output size.
	Debug bool
	// Logger, if non-nil, receives one structured log record per component
	// rendered. Records are emitted at slog.LevelDebug for successful renders
	// and slog.LevelError for failed renders. Each record includes the
	// component name, render duration (subtree), bytes written (subtree), and
	// any error. The nil value (default) disables all slog output.
	Logger *slog.Logger
	// ComponentErrorHandler, if non-nil, is called in place of aborting the
	// render when a child component fails. The handler writes an HTML
	// placeholder to w and returns nil to continue rendering, or returns a
	// non-nil error to abort. When the handler returns nil for all failures,
	// the partial page (with placeholders) is written to the io.Writer passed
	// to RenderPage. The nil value (default) preserves the existing behaviour:
	// the first component error aborts the render and w receives nothing.
	ComponentErrorHandler ComponentErrorHandler
}

Options holds configuration for creating a new Engine.

type ParseError

type ParseError struct {
	// Path is the source file path.
	Path string
	// Msg is the human-readable description of the parse failure.
	Msg string
	// Location holds the source position of the error, or nil if unknown.
	Location *SourceLocation
}

ParseError is returned when a .vue file cannot be parsed.

func (*ParseError) Error

func (e *ParseError) Error() string

type PropInfo

type PropInfo struct {
	Name        string
	Expressions []string
}

PropInfo describes a single top-level prop (variable reference) found in a component's template, together with the expression strings in which it appears.

type Props added in v0.8.0

type Props interface {
	Keys() []string
	Get(key string) (any, bool)
}

Props is the interface htmlc demands of any value used as component props.

type Registry

type Registry map[string]*Component

Registry maps component names to their parsed components. Keys may be PascalCase (e.g., "Card") or kebab-case (e.g., "my-card"). Registry is part of the low-level API; most callers should use Engine, which builds and maintains a Registry automatically from a component directory.

type RenderError

type RenderError struct {
	// Component is the component name being rendered when the error occurred.
	Component string
	// ComponentPath is the ordered path of component names from the page root
	// to the failing component (e.g. ["HomePage", "Layout", "Sidebar"]).
	// It is populated at each component boundary as the error travels up the
	// call stack. The last element matches Component.
	ComponentPath []string
	// Expr is the template expression that triggered the error (may be empty).
	Expr string
	// Wrapped is the underlying error.
	Wrapped error
	// Location holds the source position of the error, or nil if unknown.
	Location *SourceLocation
}

RenderError is returned when template rendering fails for a named component.

func (*RenderError) Error

func (e *RenderError) Error() string

func (*RenderError) Unwrap

func (e *RenderError) Unwrap() error

type Renderer

type Renderer struct {
	// contains filtered or unexported fields
}

Renderer walks a component's parsed template and produces HTML output. It is the low-level rendering primitive — most callers should use Engine (via RenderPage or RenderFragment) rather than constructing a Renderer directly. Use NewRenderer when you need fine-grained control over style collection or registry attachment.

Engine-level functions (from Engine.RegisterFunc) are available in child components only when the renderer is created by Engine — Engine calls WithFuncs automatically. Callers using the low-level NewRenderer API who want engine functions to propagate into child components must call WithFuncs explicitly.

func NewRenderer

func NewRenderer(c *Component) *Renderer

NewRenderer creates a Renderer for c. Call WithStyles and WithComponents before Render to enable style collection and component composition. Call WithFuncs to make engine-registered functions available in this component and all child components rendered from it.

func (*Renderer) Render

func (r *Renderer) Render(w io.Writer, scope map[string]any) error

Render evaluates the component's template against the given data scope and writes the rendered HTML directly to w.

func (*Renderer) RenderString

func (r *Renderer) RenderString(scope map[string]any) (string, error)

RenderString evaluates the component's template against the given data scope and returns the rendered HTML as a string. It is a convenience wrapper around Render.

func (*Renderer) WithComponentErrorHandler added in v0.8.0

func (r *Renderer) WithComponentErrorHandler(h ComponentErrorHandler) *Renderer

WithComponentErrorHandler sets the handler called when a child component fails to render. When nil (default), the first component error aborts the render. Returns the Renderer for chaining.

func (*Renderer) WithComponentPath added in v0.8.0

func (r *Renderer) WithComponentPath(path []string) *Renderer

WithComponentPath sets the ordered component path from the page root to this renderer's component. Returns the Renderer for chaining.

func (*Renderer) WithComponents

func (r *Renderer) WithComponents(reg Registry) *Renderer

WithComponents attaches a component registry to this renderer, enabling component composition. Returns the Renderer for chaining.

func (*Renderer) WithContext

func (r *Renderer) WithContext(ctx context.Context) *Renderer

WithContext attaches a context.Context to the renderer. The render is aborted and ctx.Err() is returned if the context is cancelled or its deadline is exceeded. Returns the Renderer for chaining.

func (*Renderer) WithDirectives

func (r *Renderer) WithDirectives(dr DirectiveRegistry) *Renderer

WithDirectives attaches a custom directive registry. Directives registered here are invoked when the renderer encounters v-<name> attributes that are not built-in directives. Returns the Renderer for chaining.

func (*Renderer) WithFuncs added in v0.2.2

func (r *Renderer) WithFuncs(funcs map[string]any) *Renderer

WithFuncs attaches engine-registered functions to this renderer so they are available in template expressions and propagated to all child renderers. Returns the Renderer for chaining.

func (*Renderer) WithLogger added in v0.7.0

func (r *Renderer) WithLogger(l *slog.Logger) *Renderer

WithLogger attaches a *slog.Logger to this renderer. When non-nil, one structured log record is emitted per child component dispatch. Returns the Renderer for chaining.

func (*Renderer) WithMissingPropHandler

func (r *Renderer) WithMissingPropHandler(fn MissingPropFunc) *Renderer

WithMissingPropHandler sets a handler that is called when a prop referenced in the template is not present in the render scope. Returns the Renderer for chaining.

Example

ExampleRenderer_WithMissingPropHandler shows SubstituteMissingProp substituting a placeholder string when a required template prop is absent.

package main

import (
	"fmt"
	"log"

	"github.com/dhamidi/htmlc"
)

func main() {
	comp, err := htmlc.ParseFile("t.vue", `<template><p>{{ name }}</p></template>`)
	if err != nil {
		log.Fatal(err)
	}
	out, err := htmlc.NewRenderer(comp).
		WithMissingPropHandler(htmlc.SubstituteMissingProp).
		RenderString(nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out)
}
Output:
<p>MISSING PROP: name</p>

func (*Renderer) WithNSComponents added in v0.6.0

func (r *Renderer) WithNSComponents(ns map[string]map[string]*Component, componentDir string) *Renderer

WithNSComponents attaches a namespaced component registry and the engine's ComponentDir to this renderer, enabling proximity-based component resolution. ns maps forward-slash relative directory paths to local component names to parsed components; componentDir is the value of Options.ComponentDir used when the engine was created.

When set, resolveComponent walks up the directory tree from the caller component's location before falling back to the flat registry. Returns the Renderer for chaining.

func (*Renderer) WithStyles

func (r *Renderer) WithStyles(sc *StyleCollector) *Renderer

WithStyles sets sc as the StyleCollector that will receive this component's style contribution when Render is called. It returns the Renderer for chaining.

type SlotDefinition

type SlotDefinition struct {
	Nodes       []*html.Node
	ParentScope map[string]any
	BindingVar  string
	Bindings    []string
	// Component is the component that authored this slot content.
	// It is used to stamp the correct scope attribute on slot elements.
	// May be nil when the parent has no component context (rare).
	Component *Component
	// SlotDefs holds the slot definitions that were active in the authoring
	// component at the time this slot content was captured.  When the slot
	// content itself contains <slot /> elements they must be resolved against
	// this map, not against the consuming component's slot definitions.
	SlotDefs map[string]*SlotDefinition
}

SlotDefinition captures everything needed to defer slot rendering: the AST nodes from the caller's template, the caller's scope at invocation time, and the parsed binding information from the v-slot / # directive.

type SourceLocation added in v0.2.0

type SourceLocation struct {
	File    string // source file path
	Line    int    // 1-based line number (0 = unknown)
	Column  int    // 1-based column (0 = unknown)
	Snippet string // ≈3-line context around the error (may be empty)
}

SourceLocation describes a position within a source file.

type StructProps added in v0.8.0

type StructProps struct {
	// contains filtered or unexported fields
}

StructProps wraps a dereferenced reflect.Value of kind Struct and implements Props lazily — no upfront map allocation.

func (StructProps) Get added in v0.8.0

func (p StructProps) Get(key string) (any, bool)

Get resolves a key using three-step lookup:

  1. Exact json tag match (case-sensitive).
  2. Exact Go field name match (case-sensitive).
  3. First-rune-lowercased Go field name match — only when no json tag is present.

Returns nil, true when the resolved field value is a typed nil pointer.

func (StructProps) Keys added in v0.8.0

func (p StructProps) Keys() []string

Keys enumerates canonical keys using the two-pass algorithm: direct (non-anonymous) fields first, then anonymous embedded fields. The first-rune-lowercase alias is never added to Keys — it is a lookup-only affordance.

type StyleCollector

type StyleCollector struct {
	// contains filtered or unexported fields
}

StyleCollector accumulates StyleContributions from one or more component renders into a single ordered list, deduplicating repeated contributions from the same scoped component. It is part of the low-level API; Engine creates and manages a StyleCollector automatically on each render call.

func (*StyleCollector) Add

func (sc *StyleCollector) Add(c StyleContribution)

Add appends c to the collector, skipping duplicates. Two contributions are considered duplicates when they share the same composite key (ScopeID + CSS), so the same scoped component rendered N times contributes its CSS only once, while different components or differing global CSS blocks are each kept.

func (*StyleCollector) All

func (sc *StyleCollector) All() []StyleContribution

All returns all StyleContributions in the order they were added. The slice is nil when no contributions have been collected.

type StyleContribution

type StyleContribution struct {
	// ScopeID is the scope attribute name (e.g. "data-v-a1b2c3d4") for a
	// scoped component's styles, or empty for global (non-scoped) styles.
	ScopeID string
	// CSS is the stylesheet text. For scoped components it has already been
	// rewritten by ScopeCSS; for global styles it is passed through verbatim.
	CSS string
}

StyleContribution holds a style block contributed by a component during render.

type TemplateToVueResult added in v0.7.0

type TemplateToVueResult struct {
	Text     string
	Warnings []string
}

TemplateToVueResult holds the converted .vue source text and any non-fatal warnings generated during conversion.

func TemplateToVue added in v0.7.0

func TemplateToVue(src, componentName string) (*TemplateToVueResult, error)

TemplateToVue converts the text of an html/template file to .vue syntax. src is the raw template source; componentName is used only for error messages.

The conversion is explicitly best-effort: only constructs with unambiguous .vue equivalents are translated. The first unsupported construct halts conversion and returns a *ConversionError; no partial output is produced.

Supported constructs

  • Text nodes: emitted verbatim
  • {{.ident}} and {{.a.b.c}}: converted to {{ ident }} and {{ a.b.c }}
  • {{if .cond}}…{{end}}: wraps body in <div v-if="cond">
  • {{if .cond}}…{{else}}…{{end}}: emits v-if and v-else on synthetic <div>
  • {{range .items}}…{{end}}: wraps body in <ul><li v-for="item in items">
  • {{block "name" .}}…{{end}}: emits <slot name="name"> (or <slot> for "default")
  • {{template "Name" .}}: emits <Name />

Unsupported constructs (return ConversionError)

  • {{.items | len}} or any multi-command pipeline
  • {{with .x}}
  • Variable assignments ($x := …)
  • Actions whose pipeline is not a single FieldNode
  • {{template "Name" expr}} where expr is not .

Note: {{block "name" .}} is desugared by the template parser into a {{define "name"}} block plus a {{template "name" .}} call. TemplateToVue detects this by checking whether a parsed sub-tree with that name exists alongside the root tree in the parse result.

type VHighlight added in v0.3.0

type VHighlight struct{}

VHighlight is an example custom directive that sets the background colour of the host element. It is the htmlc equivalent of the v-highlight directive shown in the Vue.js custom directives guide (https://vuejs.org/guide/reusability/custom-directives.html).

Register it on an engine and then use v-highlight in templates:

engine.RegisterDirective("highlight", &htmlc.VHighlight{})

Template usage:

<p v-highlight="'yellow'">Highlight this text bright yellow</p>

The expression must evaluate to a non-empty CSS colour string. If the host element already has a style attribute, the background property is appended; existing style declarations are preserved.

func (*VHighlight) Created added in v0.3.0

func (v *VHighlight) Created(node *html.Node, binding DirectiveBinding, ctx DirectiveContext) error

Created merges `background: <colour>` into the host element's style attribute.

func (*VHighlight) Mounted added in v0.3.0

Mounted is a no-op for VHighlight.

type ValidationError

type ValidationError struct {
	// Component is the name of the component that has the problem.
	Component string
	// Message describes the problem.
	Message string
}

ValidationError describes a single problem found by Engine.ValidateAll.

func (ValidationError) Error

func (e ValidationError) Error() string

type VueToTemplateResult added in v0.7.0

type VueToTemplateResult struct {
	Text     string
	Warnings []string
}

VueToTemplateResult holds the converted template text and any non-fatal warnings generated during conversion.

func VueToTemplate added in v0.7.0

func VueToTemplate(tmpl *html.Node, componentName string) (*VueToTemplateResult, error)

VueToTemplate converts a parsed .vue component template tree to Go html/template syntax. tmpl is the root *html.Node from the component's Template field (i.e. the parsed <template> section).

The result is a string containing one {{define "componentName"}}…{{end}} block, suitable for combining with other such blocks and parsing via html/template.New("").Parse(combined).

componentName is the base name used for the {{define}} block. It is passed through unchanged; callers that require lowercase names (as Engine.CompileToTemplate does) must lowercase before calling.

Non-fatal issues (for example, data-contract warnings for v-html and v-bind spread) are returned in VueToTemplateResult.Warnings. Fatal issues return nil and a non-nil *ConversionError on the first unsupported construct encountered; no partial output is produced.

Directories

Path Synopsis
cmd
expvar-demo command
Command expvar-demo runs a small HTTP server that demonstrates the htmlc expvar integration.
Command expvar-demo runs a small HTTP server that demonstrates the htmlc expvar integration.
htmlc command
Package expr implements the expression language used in htmlc templates.
Package expr implements the expression language used in htmlc templates.
Package htmlctest provides helpers for testing htmlc components.
Package htmlctest provides helpers for testing htmlc components.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL