htmlc

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Mar 13, 2026 License: MIT Imports: 22 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. Custom Directives
  10. Compatibility with Vue.js

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

2. 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 (++, --).

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


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

Given a tag name, the engine tries these strategies in order:

  1. Exact match in the registry (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
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. The expression must evaluate to a non-empty string that names a registered component or a native HTML element:

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

<!-- inline string literal -->
<component :is="'Card'" :title="pageTitle">
  <p>slot content</p>
</component>

<!-- switch between components in a loop -->
<div v-for="item in items">
  <component :is="item.type" :data="item" />
</div>
  • All attributes other than :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 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.

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

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

7. 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" : "" }}

8. Debug Mode

Debug mode annotates rendered HTML with structured HTML comments that describe component boundaries, expression values, conditional branch outcomes, and slot contents. The annotated output is valid HTML that renders identically in a browser but carries diagnostic information visible in DevTools or via curl | grep -i debug.

Debug output is intended for development only. Never enable it in production.

Enabling via Go API
engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "templates/",
    Debug:        true,
})
Enabling via CLI
htmlc render --debug -dir ./templates Card -props '{"title":"Hello"}'
htmlc page --debug -dir ./templates PostPage -props '{"slug":"intro"}'
Example annotated output
<!-- [htmlc:debug] component=PostPage file=templates/PostPage.vue -->
<article>
  <!-- [htmlc:debug] expr="post.Title" value=Hello World -->
  <h1>Hello World</h1>
  <!-- [htmlc:debug] v-if="post.Draft" → false: node skipped -->
  <!-- [htmlc:debug] slot=default nodes=2 -->
  <p>Body content here</p>
  <!-- [htmlc:debug] /slot=default -->
</article>
<!-- [htmlc:debug] /component=PostPage -->
What the comments describe
Comment pattern Meaning
component=Name file=path Start of a child component render
/component=Name End of a child component render
expr="..." value=... Expression evaluated during text interpolation
v-if="..." → false: node skipped Conditional node that was not rendered
slot=name nodes=N Start of slot content being rendered
/slot=name End of slot content
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"

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

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

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>

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

This section is empty.

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

Sentinel errors returned by Engine methods.

Functions

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

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) 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) 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) 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) 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 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 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 two files share the same base name the last one
	// encountered in lexical-order traversal wins.
	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 rendered HTML is
	// annotated with HTML comments describing component boundaries, expression
	// values, conditional branch outcomes, and slot contents.
	// Intended for development use only; never enable in production.
	Debug bool
}

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

Directories

Path Synopsis
cmd
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