feat: add HLCRF Layout type with semantic elements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c7240943d7
commit
946ea8d0aa
2 changed files with 228 additions and 0 deletions
112
layout.go
Normal file
112
layout.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
|
||||
type slotMeta struct {
|
||||
tag string
|
||||
role string
|
||||
}
|
||||
|
||||
// slotRegistry maps slot letters to their semantic HTML elements and ARIA roles.
|
||||
var slotRegistry = map[byte]slotMeta{
|
||||
'H': {tag: "header", role: "banner"},
|
||||
'L': {tag: "aside", role: "complementary"},
|
||||
'C': {tag: "main", role: "main"},
|
||||
'R': {tag: "aside", role: "complementary"},
|
||||
'F': {tag: "footer", role: "contentinfo"},
|
||||
}
|
||||
|
||||
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
// with deterministic path-based IDs.
|
||||
type Layout struct {
|
||||
variant string // "HLCRF", "HCF", "C", etc.
|
||||
path string // "" for root, "L-0-" for nested
|
||||
slots map[byte][]Node // H, L, C, R, F → children
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
func NewLayout(variant string) *Layout {
|
||||
return &Layout{
|
||||
variant: variant,
|
||||
slots: make(map[byte][]Node),
|
||||
}
|
||||
}
|
||||
|
||||
// H appends nodes to the Header slot.
|
||||
func (l *Layout) H(nodes ...Node) *Layout {
|
||||
l.slots['H'] = append(l.slots['H'], nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// L appends nodes to the Left aside slot.
|
||||
func (l *Layout) L(nodes ...Node) *Layout {
|
||||
l.slots['L'] = append(l.slots['L'], nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// C appends nodes to the Content (main) slot.
|
||||
func (l *Layout) C(nodes ...Node) *Layout {
|
||||
l.slots['C'] = append(l.slots['C'], nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// R appends nodes to the Right aside slot.
|
||||
func (l *Layout) R(nodes ...Node) *Layout {
|
||||
l.slots['R'] = append(l.slots['R'], nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// F appends nodes to the Footer slot.
|
||||
func (l *Layout) F(nodes ...Node) *Layout {
|
||||
l.slots['F'] = append(l.slots['F'], nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// blockID returns the deterministic data-block attribute value for a slot.
|
||||
func (l *Layout) blockID(slot byte) string {
|
||||
return fmt.Sprintf("%s%c-0", l.path, slot)
|
||||
}
|
||||
|
||||
// Render produces the semantic HTML for this layout.
|
||||
// Only slots present in the variant string are rendered.
|
||||
func (l *Layout) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
|
||||
for i := 0; i < len(l.variant); i++ {
|
||||
slot := l.variant[i]
|
||||
children := l.slots[slot]
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
meta, ok := slotRegistry[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
bid := l.blockID(slot)
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(meta.tag)
|
||||
b.WriteString(` role="`)
|
||||
b.WriteString(meta.role)
|
||||
b.WriteString(`" data-block="`)
|
||||
b.WriteString(bid)
|
||||
b.WriteString(`">`)
|
||||
|
||||
for _, child := range children {
|
||||
b.WriteString(child.Render(ctx))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
b.WriteString(meta.tag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
116
layout_test.go
Normal file
116
layout_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayout_HLCRF(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Must contain semantic elements
|
||||
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain ARIA roles
|
||||
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain data-block IDs
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain content
|
||||
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_HCF(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// HCF should have header, main, footer
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// HCF must NOT have L or R slots
|
||||
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_ContentOnly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("C").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Only C slot should render
|
||||
if !strings.Contains(got, `data-block="C-0"`) {
|
||||
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<main") {
|
||||
t.Errorf("C layout missing <main in:\n%s", got)
|
||||
}
|
||||
|
||||
// No other slots
|
||||
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_FluentAPI(t *testing.T) {
|
||||
layout := NewLayout("HLCRF")
|
||||
|
||||
// Fluent methods should return the same layout for chaining
|
||||
result := layout.H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))
|
||||
if result != layout {
|
||||
t.Error("fluent methods must return the same *Layout for chaining")
|
||||
}
|
||||
|
||||
got := layout.Render(NewContext())
|
||||
if got == "" {
|
||||
t.Error("fluent chain should produce non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
// "C" variant: populating L and R should have no effect
|
||||
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "main") {
|
||||
t.Errorf("C variant should render main content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "left") {
|
||||
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "right") {
|
||||
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue