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
177 lines
6.3 KiB
Go
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
|
|
}
|