On this pageSFC formatRegistrationCompositionPropsSlotsScoped stylesEngineRenderingHTTP handlersValidateAllMissing propsRegisterFuncHot-reload / FSError handlingScope rulesCustom directivesCustom element API

Component system

htmlc components are Vue Single File Components — .vue files with a required template and an optional style section.

SFC format

A component file has up to three sections:

<!-- components/Card.vue -->
<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <slot>No content provided.</slot>
  </div>
</template>

<!-- Optional: global or scoped CSS -->
<style scoped>
.card {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 1rem;
}
</style>
  • <template> — required; contains the HTML template with directives
  • <script> and <script setup>not supported; using either causes a parse error. htmlc renders components on the server in Go — there is no JavaScript execution context, so script blocks serve no purpose and are rejected to prevent silent misconfiguration. Props are declared via Go types, not export default { props: [...] }. If you are porting a Vue SFC, remove the <script> block entirely.
  • <style> — optional; add scoped attribute to scope styles to this component
  • <script customelement> — optional; declares this component as a Web Custom Element. Contains a plain JavaScript class that extends HTMLElement and calls customElements.define(…). The tag name is derived automatically from the component's file path. Cannot coexist with <script> or <script setup>.

Full three-section example

<!-- components/ui/Counter.vue -->
<template>
  <div class="counter">{{ count }}</div>
</template>

<style scoped>
.counter { font-size: 2rem; }
</style>

<script customelement>
class UiCounter extends HTMLElement {
  connectedCallback() {
    this.querySelector('.counter').textContent = this.getAttribute('count') || '0'
  }
}
customElements.define('ui-counter', UiCounter)
</script>

Custom element components

When a component contains a <script customelement> block, htmlc collects the JavaScript and makes it available as a browser module. The custom element tag name is derived from the component's file path by joining the directory segments and filename (without extension) with hyphens, all lowercased:

File pathTag name
components/Counter.vuecounter
components/ui/Counter.vueui-counter
components/ui/form/Input.vueui-form-input

See the Custom Elements reference for full details including the Go API and the importMap() template function.

Component registration

The engine automatically discovers all .vue files in the component directory. Components are referenced by their filename without the extension.

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

// Register an additional component explicitly
engine.Register("MyCard", "/path/to/MyCard.vue")

In templates, component names follow PascalCase:

<!-- Card.vue in the component dir -->
<Card :title="post.title">
  <p>{{ post.body }}</p>
</Card>

Component composition

Components can nest other components from the same registry. Props are passed as attributes; expressions use : shorthand.

<!-- templates/PostPage.vue -->
<template>
  <Layout :title="title">
    <Card :title="post.title">
      <p>{{ post.body }}</p>
    </Card>
    <Card v-for="related in relatedPosts" :title="related.title" />
  </Layout>
</template>

Props

Props are any data passed to a component. In templates, static props are strings; dynamic props use :.

<!-- Static: value is the literal string "Hello" -->
<Card title="Hello" />

<!-- Dynamic: value is the result of the expression -->
<Card :title="post.title" />

<!-- Spread all props -->
<Card v-bind="post" />

Discover what props a component uses:

$ htmlc props -dir ./templates Card
title
author
body

Slots

Default slot

<!-- In Card.vue -->
<div class="card">
  <slot>Fallback when no content is provided</slot>
</div>

<!-- Usage -->
<Card title="Hello">
  <p>This renders inside the slot.</p>
</Card>

Named slots

<!-- In Layout.vue -->
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>

<!-- Usage -->
<Layout>
  <template #header>
    <nav><a href="/">Home</a></nav>
  </template>
  <article>Main content</article>
  <template #footer><p>&copy; 2024</p></template>
</Layout>

Scoped slots

<!-- In List.vue -->
<ul>
  <li v-for="item in items">
    <slot :item="item">{{ item }}</slot>
  </li>
</ul>

<!-- Usage: destructure slot props -->
<List :items="posts">
  <template #default="{ item }">
    <a :href="item.url">{{ item.title }}</a>
  </template>
</List>

Scoped styles

Add scoped to <style> to confine styles to the component. The engine rewrites selectors and adds a unique scope attribute to matching elements.

<style scoped>
.card   { background: white; border-radius: 8px; }
h2      { color: #333; }
</style>

Output (approximately):

<style>
.card[data-v-a1b2c3]   { background: white; border-radius: 8px; }
h2[data-v-a1b2c3]      { color: #333; }
</style>

Go API

import "github.com/dhamidi/htmlc"

// Create an engine that loads components from a directory
engine, err := htmlc.New(htmlc.Options{
    ComponentDir: "./components",
    Debug:        false,
})
if err != nil {
    log.Fatal(err)
}

Rendering

// Render a fragment (no <!DOCTYPE>)
html, err := engine.RenderFragmentString(context.Background(), "Card", map[string]any{
    "title": "Hello",
    "body":  "World",
})

// Render a full page (<!DOCTYPE html>)
err = engine.RenderPage(context.Background(), w, "HomePage", map[string]any{
    "title": "My site",
})

HTTP handlers

ServeComponent

Returns an http.HandlerFunc that renders a component as an HTML fragment and writes it with Content-Type: text/html; charset=utf-8. The data function is called on every request; pass nil if no data is needed.

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

ServePageComponent

Like ServeComponent but renders a full HTML page (injecting scoped styles into </head>) and lets the data function return an HTTP status code alongside the data map. A status code of 0 is treated as 200.

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

Registers multiple component routes on an http.ServeMux in one call. Each component is served as a full HTML page. Keys are http.ServeMux patterns (e.g. "GET /{$}").

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

WithDataMiddleware

Adds a function that enriches the data map on every HTTP-triggered render. Multiple middleware functions are applied in registration order. Use this to inject values shared across all routes — current user, CSRF token, etc.

Scope note: Middleware values are available only in the top-level page scope. If a child component needs a middleware-supplied value, pass it down as an explicit prop or register it with RegisterFunc instead.

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

Startup validation

ValidateAll

Checks every registered component for unresolvable child component references. Returns a slice of ValidationError (one per problem). Call 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)
}

Missing prop handling

By default a missing prop renders a visible [missing: propName] placeholder so the page still loads and the absent prop is immediately obvious. Override this behaviour with WithMissingPropHandler:

// Abort the render with an error on any missing prop
engine.WithMissingPropHandler(htmlc.ErrorOnMissingProp)

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

Template functions

RegisterFunc

Registers a Go function callable from any template expression rendered by this engine. Unlike props, registered functions are available in every component at every nesting depth — no prop threading needed. Engine 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
})

engine.RegisterFunc("url", func(args ...any) (any, error) {
    name, _ := args[0].(string)
    return router.URLFor(name), nil
})

Use them directly in templates:

<span>{{ formatDate(post.CreatedAt) }}</span>
<a :href="url('home')">Home</a>

Advanced options

Hot-reload

Set Reload: true to re-parse changed .vue files automatically before each render — no server restart required. Disable in production.

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

Embedded filesystem

Set Options.FS to any fs.FS — including embed.FS — to load component files from an embedded or virtual filesystem instead of the OS filesystem. ComponentDir is then interpreted as a path inside the FS.

import "embed"

//go:embed templates
var templateFS embed.FS

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

Note: Hot-reload (Reload: true) only works when the FS also implements fs.StatFS. The standard embed.FS does not implement fs.StatFS, so reload is silently skipped for embedded filesystems.

Context-aware rendering

Pass a context as the first argument to RenderPage and RenderFragmentString to propagate cancellation and deadlines through the render pipeline. ServeComponent and ServePageComponent forward r.Context() automatically.

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

err = engine.RenderPage(ctx, w, "Page", data)
html, err := engine.RenderFragmentString(ctx, "Card", data)

Error handling

Parse and render failures carry structured location information. Use errors.As to inspect them:

_, 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)
    fmt.Println(re.Expr)             // expression that failed
    if re.Location != nil {
        fmt.Println(re.Location.Line)
        fmt.Println(re.Location.Snippet)
    }
}

When location is available, err.Error() produces a compiler-style message:

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>

Scope propagation rules

Each component renders in an isolated scope containing only its own props. Parent scope variables are not inherited. The one exception is functions registered with RegisterFunc — they are injected into every component's scope automatically.

MechanismAvailable in top-level pageAvailable in child components
RenderPage / RenderFragment data mapYesNo — pass as props
WithDataMiddleware valuesYesNo — pass as props
RegisterFunc functionsYesYes (automatic)
Explicit :prop="expr"Yes

Custom directives

engine.RegisterDirective("v-highlight", func(ctx *htmlc.DirectiveContext) error {
    // ctx.Node  — the HTML node being rendered
    // ctx.Value — the directive value expression result
    // ctx.Scope — the current render scope
    ctx.Node.Attr = append(ctx.Node.Attr, html.Attribute{
        Key: "class", Val: "highlighted",
    })
    return nil
})

Custom element API

Components with a <script customelement> block are collected by the engine and served as browser JavaScript modules. Two pieces are needed to wire them into your pages.

ScriptHandler

engine.ScriptHandler() returns an http.Handler that serves the collected custom element scripts as hashed JS files plus an index.js entry point. Mount it at a URL prefix using http.StripPrefix:

http.Handle("/scripts/", http.StripPrefix("/scripts/", engine.ScriptHandler()))

importMap() template function

Place {{ importMap("/scripts/") }} inside the <head> of your page template to emit the browser import map and the module entry point script tag. Pass the same path prefix used when mounting ScriptHandler:

<head>
  <meta charset="UTF-8">
  <title>{{ title }}</title>
  {{ importMap("/scripts/") }}
</head>

See the Custom Elements reference for the full API including collector options and advanced usage.