On this pageThe rendering modelComponents as templatesExpression languageScoped stylesEngine vs RendererServer-side vs client-sideCustom element boundary

Concepts

This page explains how htmlc works internally — the mental models and design decisions behind it. It is aimed at developers who want to reason about performance, debug unexpected output, understand limitations, or integrate htmlc into complex Go applications.

The Rendering Model

At its core, htmlc is a template engine that turns a .vue file and a Go data map into a string of HTML. The data flow is straightforward:

map[string]anyscope / data
+
*.vueparsed AST
Rendererwalks the AST
HTML stringoutput bytes

When a .vue file is first loaded, htmlc parses it into an HTML abstract syntax tree (AST) using golang.org/x/net/html. The <template> and <style> sections are extracted and stored on the Component struct; a <script> or <script setup> block causes an immediate parse error. The AST is kept in memory so that repeated renders incur no parsing cost.

At render time, the Renderer walks the AST node by node. For each node it:

  1. Evaluates any directive attributes (v-if, v-for, v-bind, etc.) against the current scope.
  2. Interpolates {{ expr }} text nodes by evaluating the embedded expression.
  3. Recursively descends into child nodes, potentially with a modified scope (e.g., inside a v-for loop).
  4. Writes the resulting bytes to the output io.Writer.

No JavaScript engine is involved at any point. The expression evaluator is a purpose-built Go library (the expr package) that understands a subset of JavaScript-like expression syntax and evaluates it directly against a map[string]any.

Key insight: Because the AST is built once and reused, and because evaluation is a pure in-memory traversal with no I/O, rendering a component is fast even under concurrent load. The only shared state is read-only (the parsed AST); the scope map is private to each render call.

Components as Templates, Not Objects

In client-side Vue, every component instance is a JavaScript object with its own reactive state, lifecycle hooks, and event listeners. htmlc takes a fundamentally different approach: a component is a stateless template. It has no instance, no lifecycle, and no reactivity.

Each call to RenderPage or RenderFragment is a pure function call:

f(name string, data map[string]any)  (HTML string, error)

The same component can be rendered a thousand times concurrently with different data maps and it will produce independent, deterministic output each time. There is no shared mutable state between renders.

This design makes htmlc easy to reason about and easy to test: if you know the input data, you know the output HTML. It also means that things that are natural in Vue — computed properties, watchers, $emit, two-way binding — simply do not exist in htmlc. They are client-side concerns that belong in JavaScript, not in a Go server renderer.

When a component includes another component (a child tag in the template), the renderer looks up the child by name in the component registry, creates a new scope from the parent's attribute expressions, and renders the child inline. The child has no reference to the parent; prop passing is one-directional and happens at the point of use.

Expression Language

The {{ expr }} interpolation syntax and directive value expressions (e.g., v-if="user.isAdmin", :class="active ? 'on' : 'off'") are all evaluated by a custom Go library: the htmlc/expr package. It is not JavaScript — it is a declarative, side-effect-free subset of JavaScript expression syntax evaluated against a Go map[string]any scope.

What the expression language supports

FeatureExample
Arithmeticprice * qty + shipping
Comparison & equalitycount > 0, status === 'active'
Logical operatorsisAdmin && !isBanned
Nullish coalescinguser.name ?? 'Anonymous'
Ternaryage >= 18 ? 'adult' : 'minor'
Member access (dot)post.author.name
Member access (bracket)items[0], obj["key"]
Optional chaininguser?.address?.city
Array / object literals[1, 2, 3], { "k": v }
Function callsformatDate(post.createdAt)
Method callspost.Summary(), router.LinkFor('home'), post.summary (zero-arg implicit)
String concatenation'Hello, ' + name + '!'
in operator"key" in obj
Typeoftypeof value === 'string'

What the expression language does NOT support

Unsupported constructReason
Assignment (x = y, x++)The evaluator is side-effect-free by design
Arrow functions / closuresNo JavaScript runtime; function values must come from Go
thisNo component instance concept
JS builtins (JSON.parse, Math.max, parseInt)Not registered by default; add via RegisterFunc
Template literals (`${x}`)Not supported; use string concatenation instead
new, class, deleteObject-oriented constructs are not applicable in this context
Spread operator (...arr)Not implemented
Regular expressionsNot implemented

Exposing Go functions to templates with RegisterFunc

The Engine.RegisterFunc method is the bridge between Go and the expression language. Any function registered this way becomes available by name in every expression evaluated by that engine:

engine.RegisterFunc("formatDate", func(args ...any) (any, error) {
    if len(args) != 1 {
        return nil, fmt.Errorf("formatDate: want 1 arg")
    }
    t, ok := args[0].(time.Time)
    if !ok {
        return "", nil
    }
    return t.Format("2 Jan 2006"), nil
})

Once registered, templates can call it like any other expression:

<span>{{ formatDate(post.publishedAt) }}</span>

Functions registered via RegisterFunc are scoped to a single engine instance. For truly global functions (available to all engines in a process), use expr.RegisterBuiltin from the htmlc/expr package directly — but note that it modifies global state and must be called before any concurrent evaluation begins.

Identifiers and scope resolution: When the evaluator encounters an identifier or member expression, it resolves in this order:
  1. Scope map field (or map key)
  2. Exported method on the scope value (field-first; methods only checked when field lookup misses)
  3. Engine-registered functions (RegisterFunc)
  4. Global built-ins (expr.RegisterBuiltin)
If the name is absent from all four, it evaluates to undefined (expr.UndefinedValue), not to an error.

Calling methods on scope values

Exported methods on Go values placed in the render scope are callable directly from expressions without any registration. Unlike RegisterFunc — which requires you to wrap a standalone function — methods work automatically by reflection on any value already in the scope.

Zero-argument methods

A zero-argument method is invoked without parentheses via dot access. Both post.Summary and post.Summary() work identically:

type Post struct { Title string }
func (p Post) Summary() string { return p.Body[:100] }
<p>{{ post.Summary }}</p>   <!-- calls p.Summary() -->
<p>{{ post.Summary() }}</p> <!-- explicit form, same result -->

Methods with arguments

Methods with parameters use normal call syntax. Given a *Router in scope:

func (r *Router) LinkFor(route string) string { ... }
<a :href="router.LinkFor('home')">Home</a>

Lowercase aliases

A lowercase-initial identifier resolves to the exported method of the same name with an uppercase first letter, mirroring the existing field-alias rule:

{{ post.summary }}  <!-- resolves to post.Summary() -->

Behavior rules

  • Fields take priority over methods of the same name (field-first).
  • Pointer receivers are supported when the scope value is a pointer.
  • Methods returning (T, error) — a non-nil error surfaces as a render error.
  • Variadic methods are supported.
  • Optional chaining works: post?.Summary returns undefined when the receiver is nil.

Scoped Styles

When a .vue file contains a <style scoped> block, htmlc transforms its CSS so that the rules apply only to elements produced by that component. This is done without a build step — the transformation happens at parse time when the component is first loaded.

Scope ID generation

Each component gets a stable, unique scope identifier derived from its file path. The ScopeID function computes an FNV-1a 32-bit hash of the path and formats it as an 8-character lowercase hex string:

// Result: "data-v-a1b2c3d4" (the exact value depends on the file path)
id := htmlc.ScopeID("./components/Button.vue")

The scope ID is stable across restarts as long as the component file path does not change. Because it is derived purely from the path, no state is needed — any process that loads the same file will generate the same ID.

CSS selector rewriting

The ScopeCSS function rewrites every CSS selector in a scoped style block by appending an attribute selector to the last compound selector in each rule:

/* Before scoping */
p { color: red; }
.title h2 { font-size: 1.5rem; }

/* After scoping (scope ID: data-v-a1b2c3d4) */
p[data-v-a1b2c3d4] { color: red; }
.title h2[data-v-a1b2c3d4] { font-size: 1.5rem; }

This mirrors the same approach used by the Vue CLI / Vite for client-side SFCs. At-rules such as @media and @keyframes are passed through verbatim; the rewriting only targets regular selector blocks.

Attribute injection

At render time, every HTML element produced by a scoped component receives the scope attribute as an additional HTML attribute. For a component with scope ID data-v-a1b2c3d4:

<!-- Template -->
<p class="intro">Hello</p>

<!-- Rendered output -->
<p class="intro" data-v-a1b2c3d4>Hello</p>

Because the CSS rules now target p[data-v-a1b2c3d4] and the rendered element carries that attribute, the styles are effectively scoped to that component's output without affecting other <p> elements on the page.

StyleCollector and style delivery

htmlc does not inline <style> tags inside component output as it renders. Instead, a StyleCollector accumulates the transformed CSS from every component that participates in a render (the root component and all its children, transitively). The collected styles are returned alongside the rendered HTML and injected into the page in a single location.

The injection strategy differs depending on which render method you use:

MethodStyle injectionUse when
RenderPageFinds the </head> tag in the output and inserts a <style> block immediately before itRendering a full HTML document that contains a <head> section
RenderFragmentPrepends a <style> block to the beginning of the outputRendering a partial HTML snippet (HTMX, turbo frames, layout slots)

This separation of rendering and style collection means you can render the same component into different contexts without changing the component itself. It also avoids duplicate <style> blocks: styles from a component that is used multiple times in the same tree are collected and deduplicated before injection.

The Engine vs. The Renderer

htmlc exposes two levels of API. Understanding when each applies helps you choose the right tool and makes the codebase easier to navigate.

Engine (high-level)

The Engine type is what most applications use. It manages the full lifecycle of component rendering:

  • Discovery: On creation, it walks ComponentDir recursively and parses every .vue file it finds, registering each by its base name without extension.
  • Caching: Parsed ASTs are held in memory and reused across render calls. With Options.Reload = true, the engine stat-checks each file before rendering and re-parses any that have changed.
  • Concurrency: All render methods on Engine are safe for concurrent use from multiple goroutines.
  • HTTP integration:ServeComponent and ServePageComponent wrap components as net/http handler functions. Mount registers a map of routes at once.
  • Data middleware:WithDataMiddleware injects per-request data (current user, CSRF token, feature flags) into every render without modifying individual handler functions.

Create one Engine at application startup and share it across all handlers:

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

Renderer (low-level)

The Renderer type renders a single component given an explicit component registry. It does not perform file discovery, caching, or hot-reload — the caller is responsible for providing all the parsed Component values. This makes it useful for:

  • Testing: Construct a registry with only the components under test; no filesystem access needed.
  • Advanced scenarios: Generate or transform Component values programmatically before rendering.
  • Embedding htmlc into a larger framework that manages its own component lifecycle.

Most application code should use Engine. Renderer is an implementation detail and an escape hatch — it is what Engine uses internally for each render call, but you rarely need to instantiate one directly.

Summary:Engine = file discovery + caching + HTTP helpers + concurrency management. Renderer = one component + one registry + one render call. Start with Engine; reach for Renderer only in tests or when you need to control the registry yourself.

Server-Side vs. Client-Side Vue

htmlc deliberately uses Vue SFC syntax — the same <template>, <style scoped>, and v-* directives that Vue developers already know. This familiarity is intentional: it lowers the learning curve for teams that use Vue on the frontend while still writing Go on the backend.

However, htmlc is not a port of Vue to Go. It is a strict subset of Vue SFC syntax adapted for pure server-side rendering. The differences are fundamental, not incidental:

Aspecthtmlc (server-side)Vue (client-side)
RuntimeGo process; no JavaScript engineBrowser JavaScript engine
Component lifecycleNone — render is a pure functiononMounted, onUpdated, onUnmounted, etc.
ReactivityNone — data is static for the duration of a renderReactive proxies via ref / reactive
Event handlingStripped from output (@click attributes are removed)DOM event listeners attached by the runtime
Two-way bindingNot supported (v-model is stripped)Core feature
OutputA string of HTML bytes, ready to serveA virtual DOM tree that the runtime reconciles with the real DOM

What happens to unsupported directives

htmlc does not error on directives it cannot meaningfully execute in a server context. Instead, it strips them from the output. This means:

  • @click="handler", v-on:submit="..." — the event binding attribute is removed; the element itself is kept.
  • v-model="value" — removed from the element.
  • v-once, v-memo — removed; htmlc always renders each node fresh.

This stripping behaviour is intentional. It allows you to write SFC templates that are shared in concept with a client-side Vue component — the server renders the initial HTML, and a separately bundled Vue application may hydrate and take over. The server's job is to produce ready-to-serve HTML; wiring up interactivity is the client's job.

The design philosophy

htmlc is designed around a single constraint: produce correct HTML as quickly as possible from a Go data map. Everything that would require a JavaScript runtime, mutable state, or asynchronous execution is out of scope. This constraint is what makes htmlc small, fast, and easy to embed — and it is why the library can confidently claim that rendering is a pure, deterministic function from data to HTML.

See the directives reference for the complete list of supported directives, and the Go API reference for the full type signatures discussed on this page.

Custom Elements: The Client-Side Boundary

htmlc is designed to produce static HTML with no client-side JavaScript. Every render call is a pure Go function: data in, HTML bytes out. But real applications sometimes need interactivity — click handlers, live data streams, canvas drawing, animations. <script customelement> is the intentional escape hatch: a way to attach client-side behaviour to a server-rendered component while preserving the clean, stateless rendering model everywhere else.

The key insight is a clean division of labour: htmlc handles the HTML (server), the browser handles the JavaScript (client). Neither side needs to know the implementation details of the other.

How it works: a two-part rendering model

When a component contains a <script customelement> block, htmlc treats it differently from the rest of the component. At parse time, the script source is extracted and stored separately — it is never evaluated by Go. The <template> is rendered as usual, but the output is wrapped in the custom element tag derived from the component name:

<!-- Template (Counter.vue) -->
<template>
  <button id="dec"></button>
  <span id="val">{{ count }}</span>
  <button id="inc">+</button>
</template>

<!-- Rendered output -->
<ui-counter>
  <button id="dec"></button>
  <span id="val">0</span>
  <button id="inc">+</button>
</ui-counter>

On the client side, the browser encounters the <ui-counter> tag, runs customElements.define('ui-counter', …) from the delivered script, and calls connectedCallback(). At that point the custom element class has full access to the already-rendered HTML inside the tag — the buttons and the value span are already in the DOM, ready to query and wire up.

Key insight: The <script customelement> source is extracted at parse time and collected separately. It is never evaluated by Go. The Go renderer only sees the <template>; the browser only sees the compiled HTML and the delivered script file. The two halves are completely decoupled.

Why this design: no hydration, no mismatch

Frameworks like React and Vue offer server-side rendering with client-side hydration: the server renders HTML, the client downloads a JavaScript bundle, and the framework re-renders the virtual DOM to reconcile it with the real DOM. This process is complex and fragile — a mismatch between server and client state causes hydration errors, content flashes, or subtle bugs.

htmlc's custom element approach avoids hydration entirely. The server produces the final DOM; the custom element class enhances it in place. There is no reconciliation, no virtual DOM, and no risk of mismatch. The component can read attribute values or pre-rendered content directly from the DOM as its initial state — no serialisation or JSON embedding required. And because it uses standard browser APIs, there is no build step, no bundler, and no transpilation.

Declarative Shadow DOM: optional progressive enhancement

Adding the shadowdom attribute to the script tag opts the component in to Declarative Shadow DOM:

<script customelement shadowdom>
  class UiCard extends HTMLElement {
    connectedCallback() { /* shadow root already attached */ }
  }
  customElements.define('ui-card', UiCard);
</script>

With shadowdom enabled, the server wraps the rendered HTML in <template shadowrootmode="open">. Browsers that support Declarative Shadow DOM attach the shadow root natively — before any JavaScript loads. This enables streaming SSR for custom elements: the content is visible and styled even on slow connections where JavaScript has not yet arrived.

Shadow DOM is not always the right choice. Use it for components where encapsulation matters (styles that must not bleed out) or where streaming paint is a priority. For simpler components, the plain custom element wrapper is sufficient.

Script delivery and caching

htmlc collects custom element scripts by content hash. If the same component is used ten times on a page, only one script file is emitted — deduplication is automatic. The hashed filenames enable long-lived browser caching: a file whose content has not changed will always have the same URL, and a file whose content has changed will have a new URL.

The importMap() helper emits a <script type="importmap"> block that maps module specifiers to their hashed URLs. This lets custom element scripts use clean import specifiers without a bundler:

<!-- Emitted by importMap() -->
<script type="importmap">
{
  "imports": {
    "ui-counter": "/assets/ui-counter.a1b2c3d4.js"
  }
}
</script>

This is a progressively-enhanced architecture: pages render and function completely without JavaScript — the HTML produced by the server is already the final, complete document. Custom elements load asynchronously and enhance the page when they arrive. Users on slow connections or with JavaScript disabled see fully-formed content from the first byte.

When to use custom elements

Custom elements are the right tool when a component needs genuine interactivity that cannot be expressed in server-rendered HTML alone:

  • Click handlers and form interactions that modify local state
  • Live data via EventSource or WebSocket
  • Canvas drawing, animations, or Web Audio
  • Browser APIs that have no server-side equivalent (geolocation, clipboard, media devices)

They are the wrong tool for components that are purely structural or presentational. A navigation bar, a card layout, a data table rendered from Go — these should remain plain htmlc components with no JavaScript. The more JavaScript a custom element contains, the more it undermines the performance and simplicity benefits of the server-rendering model. Keep the JavaScript minimal: the custom element should enhance the server-rendered HTML, not replace it.

Summary: Custom elements are the deliberate, bounded exception to htmlc's no-client-JavaScript rule. The server renders complete HTML; the browser registers the custom element class and enhances it in place. No hydration, no framework overhead, no build step — just a clean boundary between what Go knows and what the browser does.