hml

package module
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 9 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.

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())
	},
}

Unregistered names are parse errors, so the parser stays the linter. The engine itself is stdlib-only.

This repo is a mirror

Development happens on cibot, a self-hosted review and CI server, which holds the branches. GitHub receives main and the tags, so go get works and a commit hash is browsable, and pull requests are closed because there is nothing here to merge into.

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.
  • 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-aware escaping. Unlike Go's html/template, this renderer does not distinguish URL context (href), JavaScript context (onclick), or CSS context (style) from body text. All = output gets uniform html.EscapeString. This means it will not catch javascript:alert(1) in an href — it only escapes &<>".
  • SafeString is a trust assertion, not a proof. Wrap only renderer output or HTML sanitized by the producer.

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.

Truthiness (- if, - elsif, &&, ||, !): 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. This is why templates that want "non-empty string" must write - if s != "" rather than - if s.

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. Only ! always yields a bool.

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 a field by its json tag, its db tag, or its name case-insensitively. A missing key or field is a render error, not an empty value, so a typo fails loudly instead of rendering blank.

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. Don't interpolate user input into href attributes — build the full URL string in Go where you can validate it.
  • :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 / - elsif / - else    conditionals
- for item in items             loops (optional index: for i, item)
= render "name", key: val       partials
= helper arg, key: val          allowlisted helper calls (Go funcs
                                injected as locals, e.g. do_react,
                                avatar_src, status_description)
: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 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); an unknown `= name(field)` transform name is a Parse error, so the parser stays the linter. A nil map registers no transforms.

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.

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.

Jump to

Keyboard shortcuts

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