Documentation
¶
Overview ¶
Package view — ranges 0.2.27.* (ADR 0058 core/app/view block) and 0.3.57.* (ADR 0058 service/app/view block, declared here since ADR 0160).
Package view — the engine-neutral construction parameters.
Package view — declares the sentinel *errs.Error outcomes of the domain: the port's, and the html/template engine's construction failures. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.
No Public string in this package names a template, a path, a line, a data key or a fragment of template source. A Public is written to third parties, and everything a template engine handles is either the application's internal structure or somebody's data. What an operator needs travels in Fields and in Private; what a browser gets is one sentence with nothing in it.
Package view — the plug-in contract an engine implements to enter the registry.
Package view — holds the process-wide Factory registry. Engine packages register themselves through package-level var initialisers when imported (no init()), mirroring core/data/codec and core/observe/logger/writer.
Package view — the one trust type this domain admits, and the only spelling the SDK offers for it.
Package view declares the SDK's server-side rendering DOMAIN: the Renderer port that turns a named template plus a data value into bytes, the Engine registry that resolves one implementation by name, and the single trust type through which a caller can deliberately bypass escaping. A core sibling admitted by ADR 0058.
The domain is HTML, and that is a security boundary rather than a scope note ¶
html/template's entire value is that it escapes ACCORDING TO CONTEXT: the same value is escaped one way between two tags, another way inside an attribute, another inside a URL, and another inside a <script> block. text/template does none of that — it is string concatenation with a syntax — and the two packages are API-compatible, so swapping one import for the other compiles, passes every test, and ships stored XSS.
This domain therefore has no text/template representation at all. Not an Engine name, not a Factory, not a constructor, not an import: a named AST audit in internal/service/app/view fails the build if the identifier appears anywhere in the domain's three packages. The distinction is structural because a documented one is a distinction that survives exactly until the afternoon someone needs to render an email body.
Plain-text templating is out of scope, by name. If it is ever wanted it arrives as its own domain with its own port — never as a second Engine in this registry, because a registry exists to make its members interchangeable and an escaping engine is not interchangeable with a non-escaping one.
Rendering is all-or-nothing, and the signature is what enforces it ¶
Renderer.Render returns a []byte. It does NOT take an io.Writer, and that is the decision this port is shaped by.
Template execution writes incrementally and can fail halfway: a nil pointer in a field chain, a method returning an error, a range over the wrong type. Handed an http.ResponseWriter, html/template will have flushed the status line, the headers and part of the body before it reports the failure — measured at seventeen bytes of a valid-looking page for a template failing on its second action — and at that point 500 is no longer sendable and the browser renders a truncated document. A caller cannot avoid this by being careful; it can only avoid it by not being given the opportunity.
So the engine renders into a bounded buffer it owns and hands back the complete bytes or nothing at all. The caller's destination is never touched on a failed render, because the caller's destination is never passed in.
Nothing here writes anywhere, which is how ADR 0030 is satisfied ¶
ADR 0030 forbids an SDK default that writes to os.Stdout, because stdout may be the process's protocol channel. This domain does not satisfy that rule by choosing stderr; it satisfies it by having no destination in the port at all. A Renderer holds no writer, opens no file and knows no stream.
Where this domain stops ¶
The SDK ships the extension point and one implementation of it. It does not ship a template LANGUAGE: layouts, block inheritance, helper libraries, in-template i18n, asset pipelines and distributed compilation caches are all framework opinions, and a domain that held them would be a rendering framework wearing a port's name. Template composition is whatever the chosen engine's own syntax already provides — for html/template that is {{define}} and {{template}}, which this package neither extends nor conventionalises.
Index ¶
Constants ¶
const CodeDuplicateEngine errs.Code = 0x00_02_1B_08 // 0.2.27.8
CodeDuplicateEngine identifies two distinct factories claiming one Engine name. It panics for the same boot-time reason as CodeEngineInvalid, and it matters more here than in a codec registry: silently letting the second registration win could swap the engine that escapes for one that does not.
const CodeEngineInvalid errs.Code = 0x00_02_1B_07 // 0.2.27.7
CodeEngineInvalid identifies a boot-time Register of a nil Factory or one claiming the empty Engine name. It panics: a broken registration is a programming error that must be visible at import, not a value to thread through a call chain nobody is executing yet.
const CodeEngineUnknown errs.Code = 0x00_02_1B_06 // 0.2.27.6
CodeEngineUnknown identifies an Open for an Engine no factory has registered — usually a name resolved from configuration with a typo in it, or an engine whose package was never imported.
const CodeRenderFailed errs.Code = 0x00_02_1B_03 // 0.2.27.3
CodeRenderFailed identifies a template that started executing and stopped — a nil pointer in a field chain, a method that returned an error, a range over the wrong type.
The engine's diagnostic for this is the single most leak-prone string in the domain. html/template renders it as, verbatim, `template: /srv/app/web/admin/page.html:1:7: executing "…" at <.User.Name>: nil pointer evaluating interface {}.Name` — an absolute filesystem path, a line and column, a fragment of the template's own source, and a Go type name. All of it is reconnaissance, all of it is Private.
const CodeRenderTooLarge errs.Code = 0x00_02_1B_04 // 0.2.27.4
CodeRenderTooLarge identifies a render stopped at Config.MaxBytes.
It is a denial-of-service guard, not a formatting complaint. A {{range}} over a collection an attacker can grow has no recursion depth, so the stdlib's own 100000-frame limit never fires; the byte cap is the only thing between that template and the machine's memory. The partial output is discarded, never returned.
const CodeTemplateNotFound errs.Code = 0x00_02_1B_02 // 0.2.27.2
CodeTemplateNotFound identifies a Render for a name the engine does not hold.
It is a normal, expected verdict rather than a broken renderer: a Renderer built over an empty tree is legitimate and refuses everything by name (ADR 0031). The name asked for is a Field, never the Public — a template name is part of the application's routing surface.
const CodeTemplateParseFailed errs.Code = 0x00_03_39_02 // 0.3.57.2
CodeTemplateParseFailed identifies a template file html/template refused to parse, or one it could not resolve a contextual escaping plan for.
Both are construction-time on purpose. html/template resolves escaping lazily, at a template's first execution, so an {{if}} whose branches end in different contexts or a {{template}} naming something absent would otherwise surface as a 500 on whichever request first reached that page.
const CodeTemplateSourceFailed errs.Code = 0x00_03_39_01 // 0.3.57.1
CodeTemplateSourceFailed identifies a template tree that could not be READ: a walk that failed, a file that could not be opened.
It is kept distinct from a parse failure because the two have different operators and different fixes. A source failure is usually a packaging bug — an embed pattern that matched nothing, an os.DirFS pointed at a path that does not exist in the container — and it is answered by looking at the build, not at the template.
const CodeUnsafeValue errs.Code = 0x00_02_1B_05 // 0.2.27.5
CodeUnsafeValue identifies render data carrying one of the six html/template trust types the domain refuses: CSS, HTMLAttr, JS, JSStr, URL or Srcset.
Each of them disables contextual escaping for the value it wraps, and each has a working exploit that plain strings do not: measured, template.URL("javascript:alert(1)") reaches the browser as href="javascript:alert%281%29" and executes, while the same string unconverted is rewritten to the inert "#ZgotmplZ".
const CodeViewMisconfigured errs.Code = 0x00_02_1B_01 // 0.2.27.1
CodeViewMisconfigured identifies a Renderer refused AT CONSTRUCTION because its Config cannot be honoured — most importantly a nil FS.
Every permanent fault a template tree can carry is discovered here, on purpose: a tree that does not parse, or a template html/template cannot contextually escape, will fail identically on every request forever, and a deploy that refuses to come up is a cheaper way to learn that than a 500 on the one page nobody smoke-tests.
const ContentTypeHTML string = "text/html; charset=utf-8"
ContentTypeHTML is the value an HTML Renderer reports from ContentType.
The charset is not decoration. A response whose Content-Type carries no charset is sniffed by the browser, and a document sniffed as UTF-7 or as a legacy multi-byte encoding can smuggle markup past an escaper that judged the bytes as UTF-8.
const DefaultMaxBytes int = 8 << 20
DefaultMaxBytes is the ceiling Config.MaxBytes clamps to when it is not positive: 8 MiB.
A rendered HTML document larger than this is a defect or an attack. The value is generous by an order of magnitude for a page a browser is meant to parse, and it is a CLAMP rather than a refusal because — unlike a lock's TTL, whose two readings are opposites (ADR 0031, ADR 0052) — there is a defensible universal answer here, and refusing view.Config{FS: templates} would make the obvious spelling unusable for no safety gain.
const MaxPooledBytes int = 1 << 20
MaxPooledBytes is the largest scratch buffer the engine keeps for reuse.
A single 8 MiB render must not leave 8 MiB pinned in a pool for the life of the process; anything above this is returned to the collector instead.
Variables ¶
var ( // ViewMisconfigured is returned by a [Factory]'s New for a [Config] it // cannot honour: a nil FS, a template tree that does not parse, or a // template html/template cannot contextually escape. // // The escaping half is the one worth naming. html/template resolves a // template's escaping context lazily, at its FIRST execution, and reports // three classes of failure there — a template that ends inside an // unterminated tag, an {{if}} whose branches end in different contexts, and // a {{template}} naming something that does not exist. Left alone, all // three are 500s on a live request. The engine therefore forces escaping at // construction and turns them into this. ViewMisconfigured = errs.Static(CodeViewMisconfigured, "VIEW_MISCONFIGURED", "The view renderer cannot be built from the given configuration", "core/app/view: a Factory received a nil FS, a template that does not parse, or one html/template cannot contextually escape; the fields name the template and the private message carries the engine's own diagnostic", 0, 78) // TemplateNotFound is returned by Render for a name the engine does not // hold, including the empty name. // // It is deliberately not a construction-time concern: a Renderer over an // empty tree is legitimate and refuses everything by name. That is ADR // 0031's shape — the configuration nobody filled in produces a loud, // typed refusal on every call rather than an inert renderer that returns // empty pages. TemplateNotFound = errs.Static(CodeTemplateNotFound, "TEMPLATE_NOT_FOUND", "The requested view does not exist", "core/app/view: Render was called with a name absent from the parsed set; the fields carry the name asked for and the number of templates the renderer holds", 500, 0) // RenderFailed is returned when execution started and could not finish. // // Nothing is returned alongside it. The bytes produced before the failure // are discarded inside the engine, which is the whole reason Render returns // a slice instead of taking a writer: handed an http.ResponseWriter, the // same failure would have flushed the status line, the headers and a // plausible-looking prefix of the page before it was detectable — measured // at 17 bytes for a template that fails on its second action. RenderFailed = errs.Static(CodeRenderFailed, "RENDER_FAILED", "The page could not be rendered", "core/app/view: template execution failed after it began; the private message carries html/template's own diagnostic, which names the file path, the line, the column and a fragment of template source and must never be shown to a caller", 500, 70) // RenderTooLarge is returned when a render reached [Config.MaxBytes]. // // The engine stops at the ceiling rather than after it: the limit is // enforced by the writer html/template is executing into, so the bytes are // never accumulated in the first place and the process does not have to // survive the allocation in order to reject it. RenderTooLarge = errs.Static(CodeRenderTooLarge, "RENDER_TOO_LARGE", "The page could not be rendered", "core/app/view: rendering reached Config.MaxBytes and was stopped at the ceiling; the fields carry the limit and the template name", 500, 0) // UnsafeValue is returned when render data carries one of the six refused // html/template trust types. // // It is a programming error surfaced as a runtime verdict, and it is // refused BEFORE the template runs so no partial page exists to reason // about. The field naming the location is a path — data.Items[3].Body, // built from GO field names because that is the vocabulary a template // author reads and a developer greps — because "an unsafe value is // somewhere in your model" is not something a developer can act on at // three in the morning. UnsafeValue = errs.Static(CodeUnsafeValue, "UNSAFE_VALUE", "The page could not be rendered", "core/app/view: render data carries an html/template trust type that disables contextual escaping (CSS, HTMLAttr, JS, JSStr, URL or Srcset); the fields name the path and the type, and view.TrustHTML is the only trusted spelling the SDK offers", 500, 70) // EngineUnknown is returned by [Open] for a name no factory claims. // // It carries the registered names in a Field so the usual cause — an engine // package that was never imported, so its registration never ran — is // answerable from one log line. EngineUnknown = errs.Static(CodeEngineUnknown, "ENGINE_UNKNOWN", "No view engine is registered under that name", "core/app/view: Open received an Engine no Factory has registered; the fields carry the requested name and the registered ones, and the usual cause is an unimported engine package", 0, 78) // EngineInvalid is the boot-time panic for a nil Factory or an empty // [Engine] name. EngineInvalid = errs.Static(CodeEngineInvalid, "ENGINE_INVALID", "The view engine registration is not usable", "core/app/view: Register received a nil Factory or one claiming the empty Engine name; the empty name is what an uninitialised variable holds and is refused at boot so it can never reach Lookup", 0, 78) // DuplicateEngine is the boot-time panic for two distinct factories under // one [Engine] name. // // Re-registering the SAME factory is idempotent and silent; two different // ones are a hard conflict, because last-write-wins in THIS registry can // mean the engine that escapes was replaced by one that does not, at import // time, with no call site to blame. DuplicateEngine = errs.Static(CodeDuplicateEngine, "DUPLICATE_ENGINE", "Two view engines claim the same name", "core/app/view: a second, distinct Factory registered under an Engine name already taken; refused at boot rather than resolved, since one of the two would silently decide how every value in the program is escaped", 0, 78) // TemplateSourceFailed is returned by the constructor when the template FS // could not be read. TemplateSourceFailed = errs.Static(CodeTemplateSourceFailed, "TEMPLATE_SOURCE_FAILED", "The view templates could not be read", "service/app/view: walking or reading Config.FS failed; the fields carry the path that failed and the underlying error, and the usual cause is an embed pattern that matched nothing", 0, 78) // TemplateParseFailed is returned by the constructor for a file // html/template refused to parse or could not build an escaping plan for. TemplateParseFailed = errs.Static(CodeTemplateParseFailed, "TEMPLATE_PARSE_FAILED", "A view template is not valid", "service/app/view: html/template refused a template at parse time or could not resolve its contextual escaping context; the fields carry the template name and the engine's own diagnostic", 0, 78) )
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// FS is the tree the templates are read from. Required.
//
// It is an fs.FS rather than a directory path so that embed.FS, os.DirFS
// and fstest.MapFS are all first-class, and so that the domain never
// touches the filesystem API. It also removes path traversal from the
// problem statement: fs.ValidPath rejects "..", so no template name can
// address a file outside the tree the caller handed over.
//
// Every regular file under FS is parsed, and each template is named by its
// SLASH PATH relative to the root — "admin/page.html", not "page.html".
// See [Factory] for why that is not a cosmetic choice.
FS fs.FS
// Ext optionally restricts which files are parsed, by extension including
// the dot (".html", ".gohtml").
//
// Empty means every regular file in FS, which is the honest default: the
// caller already chose the tree, and second-guessing its contents is the
// SDK inventing a naming convention. Set it when templates share a
// directory with assets.
Ext []string
// MaxBytes caps a single render's output. Not positive clamps to
// [DefaultMaxBytes]; there is deliberately no spelling for "unlimited".
//
// The cap is the only bound that exists. text/template's Execute takes no
// context, so a render cannot be cancelled, and the stdlib's own guard is a
// recursion DEPTH limit (100000 frames) that says nothing about output
// size: a {{range}} over an attacker-influenced collection has no depth at
// all and will write until the machine stops. The same "no unbounded
// setting" reasoning applies here as to the WebSocket ceilings in ADR 0047.
MaxBytes int
}
Config parameterises every Factory. It is deliberately small: this domain ships an extension point, not a template language.
type Engine ¶
type Engine string
Engine names a registered template engine.
The zero value is the reserved invalid name: Register refuses it at boot, so it can never reach Lookup, Open or Available.
type Factory ¶
type Factory interface {
// Engine returns the registry key this factory claims. It must be stable
// and non-empty.
Engine() Engine
// New builds a Renderer from cfg, parsing the whole template tree eagerly.
//
// Eager parsing is the point: a template that cannot be parsed, or cannot
// be contextually escaped, will fail identically forever, so the failure
// belongs in the process's first second — a deploy that does not come up —
// rather than in the first request that happens to reach that page.
New(cfg Config) (Renderer, error)
}
Factory builds a Renderer of one engine from a Config.
Two methods, and the registry hands instances back to callers so each engine keeps its concrete type unexported — the stable contract is this interface.
What an engine undertakes by registering ¶
Registration is not merely a name binding; it is a promise the security model rests on. An engine in this registry MUST:
- Contextually escape every value it renders, for the media type its Renderer.ContentType reports. An engine that does not escape may not report an HTML content type.
- Render into its own buffer and return complete bytes or none.
- Honour Config.MaxBytes, clamping a non-positive value to DefaultMaxBytes.
- Refuse at construction a Config it cannot honour, and refuse at render time — by name, with TemplateNotFound — a template it does not hold.
- Name every template by its SLASH PATH within Config.FS.
That last one looks cosmetic and is not. html/template's own ParseFS names templates by filepath.Base, so a tree holding admin/page.html and user/page.html ends up with ONE template called "page.html" — measured, the second parse silently wins and every request for the admin page renders the user page. Full-path naming is the SDK refusing to inherit that.
func Lookup ¶
Lookup returns the Factory registered under name.
IFACE-PLUGIN: the registry hands plug-in Factory instances back to callers so each engine keeps its concrete type unexported; the stable contract is the Factory interface itself.
func Register ¶
Register inserts f under f.Engine() and returns it, so an engine package can bind the singleton to a typed package-level variable:
var Engine = view.Register(htmlFactory{})
It panics on a nil factory, on the empty Engine name, and when a DISTINCT factory already claims the name. Re-registering the same factory is a no-op.
IFACE-PLUGIN: the registry hands plug-in Factory instances back to callers so each engine keeps its concrete type unexported; the stable contract is the Factory interface itself.
"Nil" here means UNUSABLE, not only an untyped nil: a typed nil pointer and a plug-in whose type is not comparable both satisfy the port and neither can serve one call (see internal/kernel/plugin).
type Renderer ¶
type Renderer interface {
// Render executes the template registered under name against data and
// returns the complete document.
//
// It returns ([]byte, error) rather than writing to an io.Writer so that a
// failure mid-execution cannot leave a half-written response behind — see
// the package documentation. On any error the returned slice is nil.
//
// ctx is checked before the work starts and is otherwise not consulted:
// text/template's Execute takes no context and cannot be interrupted, so a
// runaway template is bounded by BYTES, not by time. That is the reason
// Config.MaxBytes exists and the reason it has no "unlimited" spelling.
//
// name is looked up exactly. A name the engine does not hold is
// [TemplateNotFound] — a Renderer built over an empty template set is a
// legitimate Renderer that refuses everything by name, which is a loud
// behaviour rather than an inert one (ADR 0031).
Render(ctx context.Context, name string, data any) ([]byte, error)
// ContentType returns the media type the bytes from Render carry, ready to
// be written as a Content-Type header — [ContentTypeHTML] for every engine
// registered today.
//
// It is a method rather than a package constant because it is the promise
// an engine makes about the escaping it performs. An engine that does not
// contextually escape HTML may not report an HTML content type, so a
// framework that writes this header verbatim (with X-Content-Type-Options:
// nosniff, which is the framework's job and not the SDK's) never labels
// unescaped output as a document the browser will parse as HTML.
ContentType() string
}
Renderer turns a named template and a data value into a complete document.
The method set is FROZEN at two. This interface is aliased by pkg/v1/app/view, Go interfaces are structural, and a third method would break every downstream implementation at compile time with no deprecation window (ADR 0039). New capabilities arrive as SIBLING interfaces reached by type assertion — the model is codec's Appender (ADR 0037).
func Open ¶
Open resolves name and builds a Renderer from cfg.
It is the config-driven entry point, and it fails loudly on a name nobody claims — EngineUnknown carrying the registered names — rather than falling back to a default. A fallback here would mean a typo in a configuration file silently choosing how every value in the program is escaped.
IFACE-PLUGIN: the engine's concrete Renderer stays unexported in internal/service/app/view; the stable contract is the Renderer interface.
type TrustedHTML ¶
TrustedHTML is an HTML fragment that the engine writes WITHOUT escaping.
It is deliberately an ALIAS of html/template.HTML rather than a defined type of its own. That is not a convenience: html/template recognises its trust types by an exact type switch, so a `type TrustedHTML string` would be escaped like any other string — measured, it renders <b>hi</b> as <b>hi</b> — and a trust type that does not actually confer trust is worse than none, because callers stop looking for the reason their markup disappeared.
The alias has a consequence that is stated rather than hidden: because TrustedHTML and html/template.HTML are the same type, the engine cannot tell a fragment marked through TrustHTML from one a caller converted directly. What the SDK buys is therefore precise, and it is worth naming exactly:
- Six of html/template's seven trust types — CSS, HTMLAttr, JS, JSStr, URL and Srcset — are REFUSED outright when they appear in render data, with the path that carries them named in the error. Those six have no SDK spelling at all.
- The seventh has one canonical, greppable spelling, TrustHTML, so "where does this codebase decide to trust markup?" is one grep with one answer rather than a hunt through an import list.
A build-time ban on the raw conversion in CONSUMER code belongs in tools/sdkguard (ADR 0033), which already reads consumer source for exactly this class of rule. It is not shipped here.
func TrustHTML ¶
func TrustHTML(s string) TrustedHTML
TrustHTML marks s as HTML that is already safe to write unescaped.
Every call is an assertion that s was produced by the server and sanitised — a Markdown renderer's output, a fragment from a template the same program authored. It is never correct to hand it a string that arrived from a request, a database field a user controls, or a third-party API.
The function exists because the conversion it performs needs a NAME. A reviewer scanning a diff for `template.HTML(` has to know that identifier matters; a reviewer scanning for `TrustHTML` is reading the word "trust" in the SDK's own vocabulary, at the call site where the decision was actually made.