fix(html): normalise nil render context
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 18:05:06 +00:00
parent aa00f27db4
commit ae286563fd
8 changed files with 56 additions and 18 deletions

View file

@ -33,6 +33,15 @@ func applyLocaleToService(svc Translator, locale string) {
}
}
// normaliseContext ensures render paths always have a usable context.
// A nil input is replaced with a fresh default context.
func normaliseContext(ctx *Context) *Context {
if ctx != nil {
return ctx
}
return NewContext()
}
// context.go: NewContext creates a new rendering context with sensible defaults.
// Example: ctx := NewContext("en-GB").
// An optional locale may be provided as the first argument.

View file

@ -133,6 +133,7 @@ func (l *Layout) Render(ctx *Context) string {
if l == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder

View file

@ -226,3 +226,15 @@ func TestLayout_RenderNilReceiver(t *testing.T) {
t.Fatalf("nil Layout should render empty string, got %q", got)
}
}
func TestLayout_RenderNilContext(t *testing.T) {
layout := NewLayout("C").C(Raw("content"))
got := layout.Render(nil)
if !strings.Contains(got, `data-block="C-0"`) {
t.Fatalf("Layout.Render(nil) should still render the block ID, got:\n%s", got)
}
if !strings.Contains(got, "content") {
t.Fatalf("Layout.Render(nil) should still render content, got:\n%s", got)
}
}

25
node.go
View file

@ -34,7 +34,7 @@ func renderNode(n Node, ctx *Context) string {
if n == nil {
return ""
}
return n.Render(ctx)
return n.Render(normaliseContext(ctx))
}
// renderNodeWithPath renders a node while preserving layout path prefixes for
@ -43,6 +43,7 @@ func renderNodeWithPath(n Node, ctx *Context, path string) string {
if n == nil {
return ""
}
ctx = normaliseContext(ctx)
switch t := n.(type) {
case *Layout:
@ -246,7 +247,7 @@ func (n *elNode) Render(ctx *Context) string {
return ""
}
return n.renderWithPath(ctx, "")
return n.renderWithPath(normaliseContext(ctx), "")
}
func (n *elNode) renderWithPath(ctx *Context, path string) string {
@ -312,9 +313,10 @@ func (n *textNode) Render(ctx *Context) string {
if n == nil {
return ""
}
ctx = normaliseContext(ctx)
var text string
if ctx != nil && ctx.service != nil {
if ctx.service != nil {
text = ctx.service.T(n.key, n.args...)
} else {
text = i18n.T(n.key, n.args...)
@ -339,9 +341,10 @@ func (n *ifNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
ctx = normaliseContext(ctx)
if n.cond(ctx) {
return n.node.Render(ctx)
return renderNodeWithPath(n.node, ctx, "")
}
return ""
}
@ -363,9 +366,10 @@ func (n *unlessNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
ctx = normaliseContext(ctx)
if !n.cond(ctx) {
return n.node.Render(ctx)
return renderNodeWithPath(n.node, ctx, "")
}
return ""
}
@ -389,11 +393,12 @@ func (n *entitledNode) Render(ctx *Context) string {
if n == nil || n.node == nil {
return ""
}
ctx = normaliseContext(ctx)
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
if ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return ""
}
return n.node.Render(ctx)
return renderNodeWithPath(n.node, ctx, "")
}
// --- switchNode ---
@ -413,10 +418,11 @@ func (n *switchNode) Render(ctx *Context) string {
if n == nil || n.selector == nil || n.cases == nil {
return ""
}
ctx = normaliseContext(ctx)
key := n.selector(ctx)
if node, ok := n.cases[key]; ok {
return renderNode(node, ctx)
return renderNodeWithPath(node, ctx, "")
}
return ""
}
@ -444,10 +450,11 @@ func (n *eachNode[T]) Render(ctx *Context) string {
if n == nil || n.items == nil || n.fn == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
for item := range n.items {
b.WriteString(renderNode(n.fn(item), ctx))
b.WriteString(renderNodeWithPath(n.fn(item), ctx, ""))
}
return b.String()
}

View file

@ -48,9 +48,7 @@ func StripTags(html string) string {
// and returns a GrammarImprint — the full render-reverse pipeline.
// Example: Imprint(NewLayout("C").C(Text("page.body")), NewContext()).
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
if ctx == nil {
ctx = NewContext()
}
ctx = normaliseContext(ctx)
rendered := Render(node, ctx)
text := StripTags(rendered)
tok := reversal.NewTokeniser()
@ -62,9 +60,7 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
// and returns pairwise similarity scores. Key format: "name1:name2".
// Example: CompareVariants(NewResponsive().Variant("desktop", NewLayout("C")), NewContext()).
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if ctx == nil {
ctx = NewContext()
}
ctx = normaliseContext(ctx)
if r == nil {
return map[string]float64{}
}

View file

@ -3,9 +3,7 @@ package html
// render.go: Render is a convenience function that renders a node tree to HTML.
// Example: Render(NewLayout("C").C(Raw("body")), NewContext()).
func Render(node Node, ctx *Context) string {
if ctx == nil {
ctx = NewContext()
}
ctx = normaliseContext(ctx)
if node == nil {
return ""
}

View file

@ -106,6 +106,7 @@ func (r *Responsive) Render(ctx *Context) string {
if r == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
for _, v := range r.variants {

View file

@ -96,6 +96,20 @@ func TestResponsive_RenderNilReceiver(t *testing.T) {
}
}
func TestResponsive_RenderNilContext(t *testing.T) {
r := NewResponsive().
Variant("desktop", NewLayout("C").C(Raw("main")))
got := r.Render(nil)
if !strings.Contains(got, `data-variant="desktop"`) {
t.Fatalf("Responsive.Render(nil) should still render the variant wrapper, got:\n%s", got)
}
if !strings.Contains(got, `data-block="C-0"`) {
t.Fatalf("Responsive.Render(nil) should still render the layout block, got:\n%s", got)
}
}
func TestResponsive_NilLayoutVariant(t *testing.T) {
ctx := NewContext()
r := NewResponsive().