<link rel="stylesheet" href="/fonts.css" />

Component system

htmlc components are Vue Single File Components — .vue files with template, optional script, and optional style sections.

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: preserved verbatim in output, never executed -->
<script>
export default { props: ['title'] }
</script>

<!-- 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> — optional; preserved verbatim but never executed by the engine
  • <style> — optional; add scoped attribute to scope styles to this component

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("Card", map[string]any{
    "title": "Hello",
    "body":  "World",
})

// Render a full page (<!DOCTYPE html>)
err = engine.RenderPage(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

Use RenderPageContext / RenderFragmentContext 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.RenderPageContext(ctx, w, "Page", data)
err = engine.RenderFragmentContext(ctx, w, "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
})