feat(html): index repeated layout block ids
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 20:08:13 +00:00
parent c6bca226a9
commit 850dbdb0b6
4 changed files with 36 additions and 18 deletions

View file

@ -366,7 +366,7 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
func TestLayout_DuplicateVariantChars(t *testing.T) {
ctx := NewContext()
// "CCC" — C appears three times. Should render C slot content three times.
// "CCC" — C appears three times. Each occurrence should get its own block index.
layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx)
@ -374,6 +374,11 @@ func TestLayout_DuplicateVariantChars(t *testing.T) {
if count != 3 {
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
}
for _, want := range []string{`data-block="C-0"`, `data-block="C-1"`, `data-block="C-2"`} {
if !strings.Contains(got, want) {
t.Errorf("CCC variant should contain %q in:\n%s", want, got)
}
}
}
func TestLayout_EmptySlots(t *testing.T) {

View file

@ -138,9 +138,10 @@ func (l *Layout) setAttr(key, value string) {
l.attrs[key] = value
}
// blockID returns the deterministic data-block attribute value for a slot.
func (l *Layout) blockID(slot byte) string {
return l.path + string(slot) + "-0"
// blockID returns the deterministic data-block attribute value for a slot
// occurrence within this layout variant.
func (l *Layout) blockID(slot byte, index int) string {
return l.path + string(slot) + "-" + strconv.Itoa(index)
}
// layout.go: VariantError reports whether the layout variant string contained any invalid
@ -171,6 +172,7 @@ func (l *Layout) Render(ctx *Context) string {
var b strings.Builder
slotCounts := make(map[byte]int)
for i := range len(l.variant) {
slot := l.variant[i]
children := l.slots[slot]
@ -180,7 +182,9 @@ func (l *Layout) Render(ctx *Context) string {
continue
}
bid := l.blockID(slot)
index := slotCounts[slot]
slotCounts[slot] = index + 1
bid := l.blockID(slot, index)
b.WriteByte('<')
b.WriteString(escapeHTML(meta.tag))

13
path.go
View file

@ -10,7 +10,7 @@ func ParseBlockID(id string) []byte {
}
// Split on "-" and require the exact structural pattern:
// slot, 0, slot, 0, ...
// slot, numeric index, slot, numeric index, ...
var slots []byte
i := 0
for part := range strings.SplitSeq(id, "-") {
@ -22,8 +22,15 @@ func ParseBlockID(id string) []byte {
return nil
}
slots = append(slots, part[0])
} else if part != "0" {
return nil
} else {
if part == "" {
return nil
}
for j := range len(part) {
if part[j] < '0' || part[j] > '9' {
return nil
}
}
}
i++
}

View file

@ -149,21 +149,22 @@ func TestNestedLayout_NilChild(t *testing.T) {
func TestBlockID(t *testing.T) {
tests := []struct {
path string
slot byte
want string
path string
slot byte
index int
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"},
{"", 'H', 0, "H-0"},
{"L-0-", 'C', 0, "L-0-C-0"},
{"C-0-C-0-", 'C', 0, "C-0-C-0-C-0"},
{"", 'F', 2, "F-2"},
}
for _, tt := range tests {
l := &Layout{path: tt.path}
got := l.blockID(tt.slot)
got := l.blockID(tt.slot, tt.index)
if got != tt.want {
t.Errorf("blockID(%q, %c) = %q, want %q", tt.path, tt.slot, got, tt.want)
t.Errorf("blockID(%q, %c, %d) = %q, want %q", tt.path, tt.slot, tt.index, got, tt.want)
}
}
}
@ -174,10 +175,11 @@ func TestParseBlockID(t *testing.T) {
want []byte
}{
{"L-0-C-0", []byte{'L', 'C'}},
{"C-0-C-1", []byte{'C', 'C'}},
{"H-0", []byte{'H'}},
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
{"", nil},
{"L-1-C-0", nil},
{"L-1-C-0", []byte{'L', 'C'}},
{"L-0-C", nil},
{"LL-0", nil},
{"X-0", nil},