On this page
SFC formatRegistrationCompositionPropsSlotsScoped stylesEngineRenderingHTTP handlersValidateAllMissing propsRegisterFuncHot-reload / FSError handlingScope rulesCustom directivesCustom element APIComponent 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, notexport default { props: [...] }. If you are porting a Vue SFC, remove the<script>block entirely.<style>— optional; addscopedattribute to scope styles to this component<script customelement>— optional; declares this component as a Web Custom Element. Contains a plain JavaScript class that extendsHTMLElementand callscustomElements.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 path | Tag name |
|---|---|
components/Counter.vue | counter |
components/ui/Counter.vue | ui-counter |
components/ui/form/Input.vue | ui-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>© 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.
| Mechanism | Available in top-level 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 |
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.