go-html/layout.go
Snider ebbd050df4
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Stabilize repeated layout block IDs
2026-04-15 01:23:12 +01:00

249 lines
6 KiB
Go

package html
// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
// The stdlib errors package is safe for WASM.
import (
"errors"
"strconv"
)
// Compile-time interface check.
var _ Node = (*Layout)(nil)
// ErrInvalidLayoutVariant reports that a layout variant string contains at least
// one unrecognised slot character.
// Usage example: if errors.Is(err, html.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: "nav", role: "navigation"},
'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.
// Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
type Layout struct {
variant string // "HLCRF", "HCF", "C", etc.
path string // "" for root, "C.0" for nested
slots map[byte][]Node // H, L, C, R, F → children
variantErr error
}
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
if node == nil {
return ""
}
if renderer, ok := node.(layoutPathRenderer); ok {
return renderer.renderWithLayoutPath(ctx, path)
}
return node.Render(ctx)
}
// NewLayout creates a new Layout with the given variant string.
// Usage example: page := NewLayout("HLCRF")
// 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),
}
l.variantErr = ValidateLayoutVariant(variant)
return l
}
// ValidateLayoutVariant reports whether a layout variant string contains only
// recognised slot characters.
//
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
// layoutVariantError for invalid ones.
func ValidateLayoutVariant(variant string) error {
var invalid bool
for i := range len(variant) {
if _, ok := slotRegistry[variant[i]]; ok {
continue
}
invalid = true
break
}
if !invalid {
return nil
}
return &layoutVariantError{variant: variant}
}
func (l *Layout) slotsForSlot(slot byte) []Node {
if l == nil {
return nil
}
if l.slots == nil {
l.slots = make(map[byte][]Node)
}
return l.slots[slot]
}
// H appends nodes to the Header slot.
// Usage example: NewLayout("HCF").H(Text("title"))
func (l *Layout) H(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
return l
}
// L appends nodes to the Left navigation slot.
// Usage example: NewLayout("LC").L(Text("nav"))
func (l *Layout) L(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
return l
}
// C appends nodes to the Content (main) slot.
// Usage example: NewLayout("C").C(Text("body"))
func (l *Layout) C(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
return l
}
// R appends nodes to the Right aside slot.
// Usage example: NewLayout("CR").R(Text("ads"))
func (l *Layout) R(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
return l
}
// F appends nodes to the Footer slot.
// Usage example: NewLayout("CF").F(Text("footer"))
func (l *Layout) F(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
return l
}
// blockID returns the deterministic data-block coordinate for a rendered slot.
func (l *Layout) blockID(slot byte, rendered int) string {
if l.path == "" {
return string(slot)
}
if rendered == 0 {
return l.path
}
return l.path + "." + strconv.Itoa(rendered)
}
// VariantError reports whether the layout variant string contained any invalid
// slot characters when the layout was constructed.
func (l *Layout) VariantError() error {
if l == nil {
return nil
}
return l.variantErr
}
// Render produces the semantic HTML for this layout.
// Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
// Only slots present in the variant string are rendered.
func (l *Layout) Render(ctx *Context) string {
if l == nil {
return ""
}
if ctx == nil {
ctx = NewContext()
}
b := newTextBuilder()
rendered := 0
rootCounts := make(map[byte]int)
for i := range len(l.variant) {
slot := l.variant[i]
children := l.slots[slot]
if len(children) == 0 {
continue
}
meta, ok := slotRegistry[slot]
if !ok {
continue
}
bid := l.blockID(slot, rendered)
if l.path == "" {
if seen := rootCounts[slot]; seen > 0 {
bid = string(slot) + "." + strconv.Itoa(seen)
}
rootCounts[slot] = rootCounts[slot] + 1
}
b.WriteByte('<')
b.WriteString(escapeHTML(meta.tag))
b.WriteString(` role="`)
b.WriteString(escapeAttr(meta.role))
b.WriteString(`" data-block="`)
b.WriteString(escapeAttr(bid))
b.WriteString(`">`)
for i, child := range children {
if child == nil {
continue
}
b.WriteString(renderWithLayoutPath(child, ctx, bid+"."+strconv.Itoa(i)))
}
b.WriteString("</")
b.WriteString(meta.tag)
b.WriteByte('>')
rendered++
}
return b.String()
}
type layoutVariantError struct {
variant string
}
func (e *layoutVariantError) Error() string {
return "html: invalid layout variant " + e.variant
}
func (e *layoutVariantError) Unwrap() error {
return ErrInvalidLayoutVariant
}
func (l *Layout) renderWithLayoutPath(ctx *Context, path string) string {
if l == nil {
return ""
}
clone := *l
clone.path = path
return clone.Render(ctx)
}