package html import ( "errors" "maps" "slices" "strconv" "strings" ) // Compile-time interface check. var _ Node = (*Layout)(nil) // ErrInvalidLayoutVariant reports that a layout variant string contains at // least one unrecognised slot character. // Example: errors.Is(ValidateLayoutVariant("HXC"), ErrInvalidLayoutVariant). var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant") // 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.go: Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions // with deterministic path-based IDs. // Example: NewLayout("HCF").H(Raw("head")).C(Raw("body")).F(Raw("foot")). 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 attrs map[string]string variantErr error } // layout.go: NewLayout creates a new Layout with the given variant string. // Example: page := NewLayout("HCF"). // The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C"). func NewLayout(variant string) *Layout { l := &Layout{ variant: variant, slots: make(map[byte][]Node), attrs: make(map[string]string), } l.variantErr = ValidateLayoutVariant(variant) return l } // layout.go: ValidateLayoutVariant reports whether a layout variant string contains only // recognised slot characters. // Example: ValidateLayoutVariant("HCF"). func ValidateLayoutVariant(variant string) error { var invalidSlots []byte var invalidPositions []int for i := range len(variant) { if _, ok := slotRegistry[variant[i]]; ok { continue } invalidSlots = append(invalidSlots, variant[i]) invalidPositions = append(invalidPositions, i) } if len(invalidSlots) == 0 { return nil } return &LayoutVariantError{ variant: variant, invalidSlots: invalidSlots, invalidPositions: invalidPositions, } } // layout.go: H appends nodes to the Header slot. // Example: NewLayout("HCF").H(Raw("head")). func (l *Layout) H(nodes ...Node) *Layout { if l == nil { return nil } l.slots['H'] = append(l.slots['H'], nodes...) return l } // layout.go: L appends nodes to the Left aside slot. // Example: NewLayout("HLCRF").L(Raw("nav")). func (l *Layout) L(nodes ...Node) *Layout { if l == nil { return nil } l.slots['L'] = append(l.slots['L'], nodes...) return l } // layout.go: C appends nodes to the Content (main) slot. // Example: NewLayout("C").C(Raw("body")). func (l *Layout) C(nodes ...Node) *Layout { if l == nil { return nil } l.slots['C'] = append(l.slots['C'], nodes...) return l } // layout.go: R appends nodes to the Right aside slot. // Example: NewLayout("HLCRF").R(Raw("aside")). func (l *Layout) R(nodes ...Node) *Layout { if l == nil { return nil } l.slots['R'] = append(l.slots['R'], nodes...) return l } // layout.go: F appends nodes to the Footer slot. // Example: NewLayout("HCF").F(Raw("foot")). func (l *Layout) F(nodes ...Node) *Layout { if l == nil { return nil } l.slots['F'] = append(l.slots['F'], nodes...) return l } func (l *Layout) setAttr(key, value string) { if l == nil { return } if l.attrs == nil { l.attrs = make(map[string]string) } l.attrs[key] = value } // 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 // slot characters when the layout was constructed. // Example: NewLayout("HXC").VariantError(). func (l *Layout) VariantError() error { if l == nil { return nil } return l.variantErr } // layout.go: VariantValid reports whether the layout variant string contains // only recognised slot characters. // Example: NewLayout("HCF").VariantValid(). func (l *Layout) VariantValid() bool { return l == nil || l.variantErr == nil } func (l *Layout) cloneNode() Node { if l == nil { return (*Layout)(nil) } clone := *l if l.attrs != nil { clone.attrs = maps.Clone(l.attrs) } if l.slots != nil { clone.slots = make(map[byte][]Node, len(l.slots)) for slot, children := range l.slots { clonedChildren := make([]Node, len(children)) for i := range children { clonedChildren[i] = cloneNode(children[i]) } clone.slots[slot] = clonedChildren } } return &clone } // layout.go: Render produces the semantic HTML for this layout. // Example: NewLayout("C").C(Raw("body")).Render(NewContext()). // Only slots present in the variant string are rendered. func (l *Layout) Render(ctx *Context) string { if l == nil { return "" } ctx = normaliseContext(ctx) var b strings.Builder slotCounts := make(map[byte]int) for i := range len(l.variant) { slot := l.variant[i] children := l.slots[slot] meta, ok := slotRegistry[slot] if !ok { continue } index := slotCounts[slot] slotCounts[slot] = index + 1 bid := l.blockID(slot, index) b.WriteByte('<') b.WriteString(escapeHTML(meta.tag)) if len(l.attrs) > 0 { keys := slices.Collect(maps.Keys(l.attrs)) slices.Sort(keys) for _, key := range keys { if key == "role" || key == "data-block" { continue } b.WriteByte(' ') b.WriteString(escapeHTML(key)) b.WriteString(`="`) b.WriteString(escapeAttr(l.attrs[key])) b.WriteByte('"') } } b.WriteString(` role="`) b.WriteString(escapeAttr(meta.role)) b.WriteString(`" data-block="`) b.WriteString(escapeAttr(bid)) b.WriteString(`">`) for _, child := range children { b.WriteString(renderNodeWithPath(child, ctx, bid+"-")) } b.WriteString("') } return b.String() } // LayoutVariantError describes the invalid characters found in a layout // variant string. // Example: var variantErr *LayoutVariantError // // if errors.As(err, &variantErr) { // _ = variantErr.InvalidSlots() // } type LayoutVariantError struct { variant string invalidSlots []byte invalidPositions []int } func (e *LayoutVariantError) Error() string { if e == nil { return ErrInvalidLayoutVariant.Error() } var b strings.Builder b.WriteString("html: invalid layout variant ") b.WriteString(e.variant) if len(e.invalidSlots) == 0 { return b.String() } b.WriteString(" (invalid slot") if len(e.invalidSlots) > 1 { b.WriteString("s") } b.WriteString(": ") for i, slot := range e.invalidSlots { if i > 0 { b.WriteString(", ") } b.WriteString(strconv.QuoteRuneToASCII(rune(slot))) if i < len(e.invalidPositions) { b.WriteString(" at position ") b.WriteString(strconv.Itoa(e.invalidPositions[i] + 1)) } } b.WriteByte(')') return b.String() } func (e *LayoutVariantError) Unwrap() error { return ErrInvalidLayoutVariant } // InvalidSlots returns a copy of the invalid slot characters that were present // in the original variant string. // Example: string(variantErr.InvalidSlots()) // "1X?" func (e *LayoutVariantError) InvalidSlots() []byte { if e == nil || len(e.invalidSlots) == 0 { return nil } return append([]byte(nil), e.invalidSlots...) } // InvalidPositions returns a copy of the 1-based positions of the invalid slot // characters in the original variant string. // Example: variantErr.InvalidPositions() // []int{2, 3, 4} func (e *LayoutVariantError) InvalidPositions() []int { if e == nil || len(e.invalidPositions) == 0 { return nil } positions := make([]int, len(e.invalidPositions)) for i, pos := range e.invalidPositions { positions[i] = pos + 1 } return positions }