hml

package module
v0.6.1 Latest Latest
Warning

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

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 10 Imported by: 0

README

hml

A small, structure-aware template language for Go. Indentation maps to HTML nesting, so the parser builds a tree and the renderer emits matched tags. Malformed HTML is not expressible.

.hml files are source and runtime input. There is no transpiler, generated code, or build step. The engine evaluates but never computes; formatting happens in Go and arrives pre-formatted.

tmpl, err := hml.Parse(src, "show.hml", transforms)
out, err := tmpl.Render(locals, partialFn)

See doc.go for the grammar, security model, and value semantics.

One buffer per page

Render returns a string. A page that renders a partial per row pays a buffer and a copy per row on that path. RenderContextTo writes into a buffer the caller owns, and a PartialWriter that calls it on the same buffer renders the whole page into one:

var partial hml.PartialWriter
partial = func(name string, ctx *hml.Context, w *strings.Builder) error {
	return load(name).RenderContextTo(w, ctx, partial)
}
var w strings.Builder
err := page.RenderContextTo(&w, hml.NewContext(locals), partial)

RenderContext and PartialFunc stay, and render the same bytes.

Checking locals

A parsed template reports what it reads, so an app can check its locals once at startup rather than one page at a time in production:

tmpl.Names()   // free top-level identifiers the template reads
tmpl.Renders() // partials it renders by literal name

Names answers for one file. A partial inherits its caller's locals, so follow Renders to check a whole page.

Check at startup that each template resolves all literal partials:

for path, tmpl := range templates {
	for _, name := range tmpl.Renders() {
		if _, ok := templates[name]; !ok {
			log.Fatalf("%s: missing partial %q", path, name)
		}
	}
}

HTTP handler

An HTTP handler prepares locals, resolves partials, and renders HTML into the response:

func handleShow(w http.ResponseWriter, r *http.Request) {
	locals := map[string]any{
		"title": "Projects",
		"items": []string{"Alpha", "Beta"},
	}

	var partial hml.PartialWriter
	partial = func(name string, ctx *hml.Context, b *strings.Builder) error {
		tmpl, ok := templates[name]
		if !ok {
			return fmt.Errorf("unknown partial %q", name)
		}
		return tmpl.RenderContextTo(b, ctx, partial)
	}

	var buf strings.Builder
	if err := templates["show"].RenderContextTo(&buf, hml.NewContext(locals), partial); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	io.WriteString(w, buf.String())
}

Render coverage

Parse checks a condition's syntax and nothing about its type. A non-bool in an - if is a render error, so a view that no test renders is a view that nothing type-checks. HasCondition names the views that need such a test, and the viewcover package and command find the ones that lack it.

The app calls viewcover.Trace(path) where it resolves a view. With HML_TRACE set, that prints the path to stdout once per process. A -v test run then carries one line per view it reached, and the command diffs that against the views with a condition:

go get -tool github.com/croaky/hml/cmd/viewcover
HML_TRACE=1 go test -v ./... > run.txt
go tool viewcover -views ui/views -trace run.txt

Stdout rather than a file so the run stays cacheable: Go's test cache replays what a binary printed and keys on the env vars it read, so an unchanged package replays its trace without running.

Transforms

The engine ships zero built-ins. Rich text renders through app-registered transforms, invoked as = name(field), each of which must sanitize its own output:

transforms := map[string]hml.Transform{
	"markdown": func(s string) string {
		var buf bytes.Buffer
		if err := goldmark.Convert([]byte(s), &buf); err != nil {
			return ""
		}
		return mdPolicy.Sanitize(buf.String())
	},
}

A name the map does not hold is not an error here. It compiles to a call on a helper func the app injects as a local, so a misspelled transform name is a render error. The engine itself is stdlib-only.

Editors

This repo is also a tree-sitter grammar: grammar.js, an external scanner for indentation, and highlight and injection queries under queries/. The parser itself is not committed; tree-sitter generate writes it, and nvim-treesitter runs that at install time.

GitHub repo is a mirror

Development happens on cibot, a self-hosted review and CI server, which holds in progress branches. GitHub receives main and the tags so go get works.

License

MIT

Documentation

Overview

Package hml renders a small, structure-aware template language. It parses .hml source into an AST (indentation → tree) and renders that tree to well-formed HTML. .hml files are the source and the runtime input — no transpiler, no generated artifacts, no build step.

Why hml, not string templates

hml is structure-aware. Indentation maps to HTML nesting, so the parser builds a tree, not a string. The renderer walks that tree and emits matched open/close tag pairs. Malformed HTML (unclosed tags, mismatched nesting, orphaned closing tags) is structurally impossible.

This is the core argument from https://www.devever.net/~hl/stringtemplates: AST-based HTML generation prevents entire classes of bugs that string template systems create, because the template system cannot produce structurally invalid output. String template systems like Go's html/template, ERB, and Jinja concatenate strings with escaping. A forgotten closing tag, a misplaced {{end}}, or a conditional that opens a tag without closing it are all expressible. In hml, they are parse errors.

Security model

What the renderer guarantees:

  • Well-formed HTML. Every tag opened by the tree is closed by the tree. You cannot produce <div><p></div></p>.

  • = expr HTML-escapes output (&, <, >, ") unless the value is a SafeString. There is no raw-output syntax: != is a parse error. The only unescaped paths are SafeString values (produced by the renderer itself: rendered partials, layout body, csrf tags) and the transform builtins, which sanitize inside the engine.

  • Attribute values are constrained by the context the attribute puts them in. A URL attribute (href, src, action, formaction, poster, cite, background, ping, xlink:href) takes a relative URL or one of http, https, mailto, tel; anything else renders as the sentinel #ZgotmplZ, as in html/template, so javascript:alert(1) never reaches the browser. An on* attribute is a JavaScript context and a style attribute a CSS context: each takes code the template author wrote, which is application source, or a value the handler marked SafeJS or SafeCSS. A plain dynamic value there is a render error, because no escaping makes untrusted data safe as code.

    Code the author wrote means a string literal, wherever it was typed. It stays authored through a hash literal, a partial argument, and a ** splat, so markup factored into a partial means what it meant inline. What ends authorship is the template's assembling something: an interpolated string is data, however literal its segments, because a value it did not write is now part of it.

  • The parser rejects anything outside the dumb subset — no method calls on data, no arbitrary code, no eval. The only callables are allowlisted helper funcs injected as locals (see Subset grammar). The parser IS the linter.

  • Templates cannot define variables, import modules, or execute side effects. They only read pre-computed data from the handler.

What the renderer does NOT guarantee:

  • No context awareness outside attribute values. = output and #{} interpolation in body text get uniform html.EscapeString. :javascript and :css filter blocks interpolate without escaping and without a trust type; that is a known gap.
  • Attribute rules key off the attribute name, not the value. The renderer will not tell a well-formed http URL from one pointing somewhere you did not intend, and it does not parse CSS or JS that carries a trust type.
  • SafeString, SafeJS, and SafeCSS are trust assertions, not proofs. Wrap only renderer output, or content the producer sanitized or built itself.

Transforms

Rich text renders from source text through app-registered transforms (see Transform), invoked as = name(field). The engine ships zero built-ins; the app passes a name→Transform map to Parse. Each transform evaluates the field, sanitizes it, and the renderer emits the result unescaped, so a Transform must return safe HTML. A transform's argument must be exactly one field-access path — no literals, interpolation, or nesting — so template-side content assembly is impossible. Handlers pass source text (markdown, Slack mrkdwn, ts_headline output), never pre-built HTML. One project, for example, registers markdown, slack, and search_highlight in its own richtext package.

Calls

name(args) is a call, and means the same thing in output position and in an attribute value. A registered transform name resolves to that transform, under the rules above. Any other name resolves to an allowlisted helper func injected as a local, takes as many arguments as it likes, and has its result escaped like any other = output.

Resolving a helper needs the locals, which arrive per render, so an unknown name is a render error rather than a parse error. That is the one place the parser stops being the linter: a misspelled transform name reads as a helper the app did not inject.

Value semantics

The engine evaluates expressions with a small, fixed set of coercion rules. They are documented here so the language has one specification rather than behavior discovered per template.

Conditions (- if, - else if, !): the value must be a bool. Anything else is an error naming the view, the line, the condition, and the type — at Parse when the expression settles it (- if "x", - if title || "Untitled"), at Render otherwise. There is no implicit truthiness to fall back on, because presence is not a property the engine can read off an untyped value: "" and 0 are present, a nil pointer is not, and which of those a field is depends on how the handler built it. Go knows the type, so the decision is made there — as a comparison the template states (- if s != "", - if n > 0, - if p == nil), or as a bool the handler computed (- if co.HasWebsite).

See package viewcover to find views with a condition that no test renders.

! is held to the same rule, because it is a conditional written backwards: were it exempt, - if title would be an error and - if !title the same guess with the branches swapped.

Truthiness (&&, ||): nil, the boolean false, and a boxed nil (a nil pointer, slice, map, chan, func, or interface) are falsy. Everything else is truthy — including the empty string "", the number 0, and an empty but non-nil slice. These two keep it because they are also the default-value idiom below; a conditional does not.

Absence (== nil, != nil): a nil pointer, slice, map, chan, func, or interface is nil, the same values && and || read as falsy. Go's own == would say otherwise for a typed nil inside an interface, which would make - if p == nil answer backwards for the pointer the doc above tells an author to test that way.

Equality (==, !=): values compare equal only within the same type; there is no cross-type coercion, with one exception — the numeric types int, int64, and float64 compare by numeric value, so an int64 column and an integer literal compare as expected. This numeric case exists to absorb the untyped ingest boundary (pgx yields int64, literals are int), not as a general coercion feature.

Ordering (<, >, <=, >=): defined only for the numeric types above. Comparing a non-number is an error, not a coercion.

&& and || return an operand, not a bool: a && b yields a when a is falsy, else b; a || b yields a when a is truthy, else b. This is the JS/Ruby-style default-value idiom, so title || "Untitled" works in an output or an attribute.

The two rules meet in a condition. hasX || hasY is fine: both operands are bools, so the operand returned is one. title || "Untitled" is a type error there, because the operand returned is a string.

Stringification (= output, #{} interpolation): nil renders as the empty string; numbers and bools render in their Go form. Rendering never panics on a nil value.

Field access (a.b.c): the first segment resolves through the context chain. Each later segment reads a map[string]any key; on any other value the engine falls back to reflection over a struct (pointers are dereferenced), matching an exported field by its json tag, its db tag, or its name case-insensitively. Unexported fields are invisible to a template. A missing key or field is a render error, not an empty value, so a typo fails loudly instead of rendering blank. Because missing locals fail at render time, tests must render each view to check fields. See package viewcover.

Output shape

Each node is written on its own line, so the output reads as the tree it came from. One exception, because whitespace inside an element is not always free: a tag whose only child is a run of text holds it on the tag's line, as <a href="/">home</a>. Left on its own line, the newline before the closing tag is whitespace inside the element, which HTML collapses to a space -- usually nothing to look at, but inside an anchor it is a space the underline runs through, past the end of the word.

One child only. Two lines of text are two the author separated, and joining them would close a gap that is in the source on purpose. Elements, partials, and conditionals bring their own lines, and a tag holding them stays a block. The newline between siblings never goes, so words in a row are still words in a row.

A run of text that renders as several lines -- markdown of two paragraphs, say -- stays a block. The space those lines would have collapsed to falls between blocks, where nothing sees it.

A pre keeps everything: its whitespace is its content, which is why a diff hunk or a terminal transcript renders inside one.

Secure usage

  • Handlers own all data formatting. Templates receive pre-computed, pre-formatted values and render markup from data.
  • Use = (escaped output) everywhere. Render rich text through the transform builtins. Reserve SafeString for renderer output and producer-sanitized HTML; never wrap raw user input.
  • Pre-build URLs in handlers, where you can validate them. The renderer enforces the scheme allowlist but not the destination.
  • Write on* and style values as literals, in the template or in a partial's arguments. When a value must be built from data, build it in Go and mark it SafeJS or SafeCSS, or pass the data through a data- attribute and read it from JavaScript. Interpolating data into one in the template is the case the policy refuses.
  • :javascript filter blocks pass through without escaping. Don't interpolate user-controlled values into JS. Use data- attributes on HTML elements instead, and read them from JS.

Subset grammar

Allowed:

%tag, .class, #id, { key: value } attributes
= field                         escaped output (field access only;
                                SafeString values pass unescaped)
= name(field)                   app-registered rich-text transform
                                (render + sanitize app-side;
                                argument is one field access)
= helper(a, b)                  allowlisted helper call, in output or
                                in an attribute value
- if expr / - else if / - else  conditionals
- for item in items             loops (optional index: for i, item)
= render "name", key: val       partials
:javascript / :css              filter blocks
-#                              comments (omitted from output)
static text with #{field}       text interpolation (escaped)

Banned: != raw output, method calls on data (including predicates like .nil?), hash access, ternaries, case/when, variable assignment, string interpolation with logic, content_for, yield, raw(), sanitize().

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Context

type Context struct {
	// contains filtered or unexported fields
}

Context holds template locals as a layered lookup chain. Each partial or loop iteration overlays a small vars map on its parent rather than copying the parent's entries, so a top-level name resolves by reading through the chain (nearest overlay first, then parent, up to the root). This trades an O(parent width) copy per partial for an O(chain depth) walk, which is a win because partial chains are shallow while parent locals maps are wide. Field access within a value resolves dot-separated names against nested maps and structs.

func NewContext

func NewContext(vars map[string]any) *Context

NewContext builds a root context from a locals map. A nil map is treated as empty.

func (*Context) Child

func (c *Context) Child(vars map[string]any) *Context

Child overlays vars on the receiver without copying the parent. A nil overlay is treated as empty. Child bindings shadow parent bindings.

type PartialFunc

type PartialFunc func(name string, ctx *Context) (string, error)

PartialFunc resolves `= render "name", key: val` calls. It receives the partial name and a child Context: the render args layered on the caller's context, so the partial inherits the caller's locals without a copy. It renders the named partial (typically via Template.RenderContext) and returns HTML.

type PartialWriter added in v0.5.0

type PartialWriter func(name string, ctx *Context, w *strings.Builder) error

PartialWriter is PartialFunc with the caller's buffer: the partial writes into w rather than returning a string the caller copies. A page that renders a partial per row saves a buffer, a copy, and the garbage of both, per row.

type SafeCSS added in v0.2.0

type SafeCSS string

SafeCSS marks a string the handler asserts is CSS declarations safe to place in a style attribute. Without it, a dynamic style value is a render error.

type SafeJS added in v0.2.0

type SafeJS string

SafeJS marks a string the handler asserts is JavaScript source safe to place in an on* event-handler attribute. Without it, a dynamic on* value is a render error: the renderer cannot tell data from code.

type SafeString

type SafeString string

SafeString marks trusted HTML that should not be escaped when rendered with escaped output syntax (= expr), matching ViewHelper::SafeString behavior.

type Template

type Template struct {
	// contains filtered or unexported fields
}

Template is a parsed hml template.

func Parse

func Parse(source, path string, transforms map[string]Transform) (*Template, error)

Parse parses hml source into a Template. transforms is the app-registered set of rich-text builtins (see Transform). A name the map does not hold compiles to an ordinary call on a helper func the app injects as a local, so a misspelled transform name is a render error rather than a Parse error. A nil map registers no transforms.

func (*Template) HasCondition added in v0.6.0

func (t *Template) HasCondition() bool

HasCondition reports whether the template holds an `- if` or an `- else if`. Parse checks a condition's syntax and nothing about its type: the type is the handler's to know, so a wrong one is a render error. A template with a condition therefore needs a test that renders it, and this is how a caller finds the ones that need one. See package viewcover.

func (*Template) Names added in v0.2.0

func (t *Template) Names() []string

Names returns the free top-level identifiers this template reads, sorted and deduplicated, so a caller can check its locals before serving a request. Loop variables are bound, not free, and are excluded; the collection they iterate is included. Partials are not followed, because a PartialFunc resolves them app-side where hml cannot see: use Renders to walk that graph.

func (*Template) Render

func (t *Template) Render(locals map[string]any, partialFn PartialFunc) (string, error)

Render executes the template with the given locals. It is a convenience wrapper that seeds a root Context; callers rendering partials thread a Context through PartialFunc via RenderContext instead.

func (*Template) RenderContext

func (t *Template) RenderContext(ctx *Context, partialFn PartialFunc) (string, error)

RenderContext executes the template against an existing Context. Partial rendering uses this to render a partial against the child context handed to PartialFunc, avoiding a per-partial copy of the caller's locals.

func (*Template) RenderContextTo added in v0.5.0

func (t *Template) RenderContextTo(w *strings.Builder, ctx *Context, partialFn PartialWriter) error

RenderContextTo executes the template against ctx and writes the HTML into w. A PartialWriter that calls RenderContextTo on the same w renders a whole page into one buffer.

func (*Template) Renders added in v0.2.0

func (t *Template) Renders() []string

Renders returns the partial names this template renders with a literal `= render "name"`, sorted and deduplicated, so a caller can walk its own graph. A computed name is not resolvable here and is omitted.

type Transform

type Transform func(string) string

Transform is an app-registered rich-text builtin available to templates as `= name(field)`. It takes the field value stringified and returns HTML the renderer emits unescaped, so a Transform must sanitize its output. The engine ships zero built-ins; apps register their own.

Directories

Path Synopsis
cmd
viewcover command
Command viewcover reports the views that hold a condition and that no test rendered.
Command viewcover reports the views that hold a condition and that no test rendered.
Package viewcover finds the views that hold a condition and that no test renders.
Package viewcover finds the views that hold a condition and that no test renders.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL