thermograph/frontend/server/internal/content/funcmap.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

177 lines
6.3 KiB
Go

package content
import (
"fmt"
"html/template"
"strings"
"unicode/utf16"
"thermograph/frontend/internal/config"
"thermograph/frontend/internal/format"
)
// FuncMap builds the template helper set (the Jinja globals/filters
// content.py registered on its Environment). Pass it to render.New at boot —
// html/template resolves names at parse time.
//
// Unit-dependent helpers (temp, temp_bare) take the active unit as their
// FIRST argument: the Python scoped it in a ContextVar, but html/template
// FuncMaps are engine-global, so per-request state must arrive through the
// call. Every page context carries the active unit under the "unit" key
// (same value as "unit_default"), so template call sites are
// {{temp $.unit .high_f}} where Jinja had {{ temp(m.high_f) }}.
func FuncMap(cfg config.Config) template.FuncMap {
return template.FuncMap{
// temp / temp_bare / temp_class accept the loosely-typed values the
// templates hand them: nullable *float64 payload fields, plain
// float64s, and int literals (Jinja's {{ temp(-10) }}).
"temp": func(unit, f any) template.HTML {
return format.Temp(unitArg(unit), floatArg(f))
},
"temp_bare": func(unit, f any) template.HTML {
return format.TempBare(unitArg(unit), floatArg(f))
},
"temp_class": func(f any) string {
return format.TempClass(floatArg(f))
},
// The `ordinal` filter: {{ c.percentile|ordinal }} -> {{ordinal .percentile}}.
"ordinal": format.PctOrdinal,
// Search-engine ownership-verification <meta> tags, from env (empty
// when unset). No backend dependency — the same two variables the
// Python read, resolved once into config.
"head_verify": func() template.HTML {
return headVerifyHTML(cfg.GoogleVerify, cfg.BingVerify)
},
// Jinja's |tojson (glossary_term.html.j2's inline JSON-LD): JSON with
// the HTML-unsafe characters escaped so it can sit inside <script>.
"tojson": toJSON,
// A visible HTML comment that survives html/template's parser. A
// LITERAL <!-- --> in a .tmpl file does NOT reach the output --
// html/template strips real HTML comments while parsing (verified:
// a template consisting of only `<p>a</p><!-- x --><p>b</p>` renders
// as `<p>a</p><p>b</p>`). Marking the string template.HTML makes it a
// trusted content INSERTION rather than markup the parser interprets,
// so it passes through untouched. Only ever called with developer-
// authored literals in the templates themselves, never request data.
"comment": func(s string) template.HTML {
return template.HTML("<!-- " + s + " -->")
},
// The same problem as `comment`, one layer down: html/template's
// contextual escaper ALSO strips JavaScript `//`/`/* */` comments from
// <script> bodies while parsing (verified the same way: an inline
// script containing only a `// comment` line and a `var` statement
// loses the comment line entirely on render, with zero {{ }} actions
// anywhere nearby). Needs template.JS specifically, not template.HTML
// -- inside a <script> context html/template treats template.HTML as
// untrusted and re-escapes it as a quoted JS value (the same bug
// class the JSONLDStr sites hit); template.JS is what means "emit
// this JS source as-is".
"jscomment": func(s string) template.JS {
return template.JS("// " + s)
},
}
}
// unitArg coerces the template-side unit value ("" / "C" / "F", as string or
// format.Unit) — the context stores it as a plain string for rendering the
// data-unit-default attribute.
func unitArg(v any) format.Unit {
switch u := v.(type) {
case format.Unit:
return u
case string:
return format.Unit(u)
case nil:
return ""
}
return ""
}
// floatArg coerces template arguments to the nullable float the format
// helpers take: nil / nil *float64 mean "missing" (rendered as the em-dash),
// numeric literals are Go ints inside templates.
func floatArg(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// headVerifyHTML is the port of content.py's head_verify_html: one <meta>
// per configured verification token, joined with the newline + two-space
// indent the Jinja Markup join produced. Token values are attribute-escaped
// exactly like markupsafe's Markup.format did.
func headVerifyHTML(google, bing string) template.HTML {
var metas []string
if google != "" {
metas = append(metas, fmt.Sprintf(`<meta name="google-site-verification" content="%s">`,
template.HTMLEscapeString(google)))
}
if bing != "" {
metas = append(metas, fmt.Sprintf(`<meta name="msvalidate.01" content="%s">`,
template.HTMLEscapeString(bing)))
}
return template.HTML(strings.Join(metas, "\n "))
}
// toJSON mirrors Jinja's |tojson filter (jinja2.utils.htmlsafe_json_dumps):
// json.dumps with ensure_ascii (every non-ASCII rune as a backslash-uXXXX
// escape, surrogate pairs beyond the BMP), then the HTML-unsafe characters
// <, >, & and ' replaced with their backslash-u00XX escapes so the result is
// safe inside a <script> block. Byte-for-byte the same output for the string
// values the templates feed it.
func toJSON(v any) (template.HTML, error) {
s, ok := v.(string)
if !ok {
// The templates only |tojson strings (term, page_description). Fail
// loudly if that changes rather than guessing at dict key order.
return "", fmt.Errorf("tojson: unsupported type %T", v)
}
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '<', '>', '&', '\'':
// Jinja's htmlsafe_json_dumps post-replaces these four with
// their backslash-u00XX escapes so the JSON can sit inside a
// <script> block without ever closing it.
fmt.Fprintf(&b, `\u%04x`, r)
default:
switch {
case r < 0x20:
fmt.Fprintf(&b, `\u%04x`, r)
case r < 0x80: // ASCII (incl. DEL) passes through, like json.dumps
b.WriteRune(r)
case r <= 0xffff:
fmt.Fprintf(&b, `\u%04x`, r)
default:
hi, lo := utf16.EncodeRune(r)
fmt.Fprintf(&b, `\u%04x\u%04x`, hi, lo)
}
}
}
b.WriteByte('"')
return template.HTML(b.String()), nil
}