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 ¶
- type Context
- type PartialFunc
- type PartialWriter
- type SafeCSS
- type SafeJS
- type SafeString
- type Template
- func (t *Template) HasCondition() bool
- func (t *Template) Names() []string
- func (t *Template) Render(locals map[string]any, partialFn PartialFunc) (string, error)
- func (t *Template) RenderContext(ctx *Context, partialFn PartialFunc) (string, error)
- func (t *Template) RenderContextTo(w *strings.Builder, ctx *Context, partialFn PartialWriter) error
- func (t *Template) Renders() []string
- type Transform
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 ¶
NewContext builds a root context from a locals map. A nil map is treated as empty.
type PartialFunc ¶
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
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 ¶
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
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
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 ¶
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
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.
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. |