From d75988a99ed4c1bd65f2e9282efa7b8396b411d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 23:40:40 +0000 Subject: [PATCH] feat: add nested layout path chains Co-Authored-By: Claude Opus 4.6 --- layout.go | 4 +++ path.go | 22 ++++++++++++++ path_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 path.go create mode 100644 path_test.go diff --git a/layout.go b/layout.go index 70ef3ac..5d59fa1 100644 --- a/layout.go +++ b/layout.go @@ -100,6 +100,10 @@ func (l *Layout) Render(ctx *Context) string { b.WriteString(`">`) for _, child := range children { + // Propagate path to nested layouts. + if inner, ok := child.(*Layout); ok { + inner.path = bid + "-" + } b.WriteString(child.Render(ctx)) } diff --git a/path.go b/path.go new file mode 100644 index 0000000..0bad936 --- /dev/null +++ b/path.go @@ -0,0 +1,22 @@ +package html + +import "strings" + +// ParseBlockID extracts the slot sequence from a data-block ID. +// "L-0-C-0" → ['L', 'C'] +func ParseBlockID(id string) []byte { + if id == "" { + return nil + } + + // Split on "-" and take every other element (the slot letters). + // Format: "X-0" or "X-0-Y-0-Z-0" + parts := strings.Split(id, "-") + var slots []byte + for i := 0; i < len(parts); i += 2 { + if len(parts[i]) == 1 { + slots = append(slots, parts[i][0]) + } + } + return slots +} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..90ac739 --- /dev/null +++ b/path_test.go @@ -0,0 +1,86 @@ +package html + +import ( + "strings" + "testing" +) + +func TestNestedLayout_PathChain(t *testing.T) { + inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f")) + outer := NewLayout("HLCRF"). + H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer")) + got := outer.Render(NewContext()) + + // Inner layout paths must be prefixed with parent block ID + for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} { + if !strings.Contains(got, want) { + t.Errorf("nested layout missing %q in:\n%s", want, got) + } + } + + // Outer layout must still have root-level paths + for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} { + if !strings.Contains(got, want) { + t.Errorf("outer layout missing %q in:\n%s", want, got) + } + } +} + +func TestNestedLayout_DeepNesting(t *testing.T) { + deepest := NewLayout("C").C(Raw("deep")) + middle := NewLayout("C").C(deepest) + outer := NewLayout("C").C(middle) + got := outer.Render(NewContext()) + + for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} { + if !strings.Contains(got, want) { + t.Errorf("deep nesting missing %q in:\n%s", want, got) + } + } +} + +func TestBlockID(t *testing.T) { + tests := []struct { + path string + slot byte + want string + }{ + {"", 'H', "H-0"}, + {"L-0-", 'C', "L-0-C-0"}, + {"C-0-C-0-", 'C', "C-0-C-0-C-0"}, + {"", 'F', "F-0"}, + } + + for _, tt := range tests { + l := &Layout{path: tt.path} + got := l.blockID(tt.slot) + if got != tt.want { + t.Errorf("blockID(%q, %c) = %q, want %q", tt.path, tt.slot, got, tt.want) + } + } +} + +func TestParseBlockID(t *testing.T) { + tests := []struct { + id string + want []byte + }{ + {"L-0-C-0", []byte{'L', 'C'}}, + {"H-0", []byte{'H'}}, + {"C-0-C-0-C-0", []byte{'C', 'C', 'C'}}, + {"", nil}, + } + + for _, tt := range tests { + got := ParseBlockID(tt.id) + if len(got) != len(tt.want) { + t.Errorf("ParseBlockID(%q) = %v, want %v", tt.id, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ParseBlockID(%q)[%d] = %c, want %c", tt.id, i, got[i], tt.want[i]) + } + } + } +}