264 lines
6.7 KiB
Go
264 lines
6.7 KiB
Go
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.
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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.
|
|
func (l *Layout) blockID(slot byte) string {
|
|
return l.path + string(slot) + "-0"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
|
|
for i := range len(l.variant) {
|
|
slot := l.variant[i]
|
|
children := l.slots[slot]
|
|
|
|
meta, ok := slotRegistry[slot]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
bid := l.blockID(slot)
|
|
|
|
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("</")
|
|
b.WriteString(meta.tag)
|
|
b.WriteByte('>')
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
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.
|
|
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.
|
|
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
|
|
}
|