go-html/responsive.go
Virgil fde2f9b884
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
fix(html): reserve responsive variant attribute
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:05:04 +00:00

232 lines
5.3 KiB
Go

package html
import (
"maps"
"slices"
"strconv"
"strings"
)
// responsive.go: Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Example: NewResponsive().Variant("desktop", NewLayout("C").C(Raw("main"))).
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
variants []responsiveVariant
attrs map[string]string
}
type responsiveVariant struct {
name string
layout *Layout
}
// responsive.go: NewResponsive creates a new multi-variant responsive compositor.
// Example: r := NewResponsive().
func NewResponsive() *Responsive {
return &Responsive{
attrs: make(map[string]string),
}
}
func (r *Responsive) setAttr(key, value string) {
if r == nil {
return
}
if r.attrs == nil {
r.attrs = make(map[string]string)
}
r.attrs[key] = value
}
// escapeCSSString escapes a string for safe use inside a double-quoted CSS
// attribute selector.
func escapeCSSString(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\', '"':
b.WriteByte('\\')
b.WriteRune(r)
case '\n':
b.WriteString(`\a `)
case '\r':
b.WriteString(`\d `)
case '\f':
b.WriteString(`\c `)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
b.WriteString(strings.ToLower(strconv.FormatInt(int64(r), 16)))
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}
// responsive.go: VariantSelector returns a CSS attribute selector for a named responsive variant.
// Example: VariantSelector("desktop") returns [data-variant="desktop"].
func VariantSelector(name string) string {
return `[data-variant="` + escapeCSSString(name) + `"]`
}
// responsive.go: ScopeVariant prefixes a selector so it only matches elements inside the
// named responsive variant.
// Example: ScopeVariant("desktop", ".nav").
func ScopeVariant(name, selector string) string {
scope := VariantSelector(name)
if selector == "" {
return scope
}
parts := splitSelectorList(selector)
scoped := make([]string, 0, len(parts))
for i := range parts {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
scoped = append(scoped, scope+" "+part)
}
if len(scoped) == 0 {
return scope
}
return strings.Join(scoped, ", ")
}
// splitSelectorList splits a CSS selector list on top-level commas only.
// Commas inside brackets, parentheses, braces, or quoted strings are preserved.
func splitSelectorList(selector string) []string {
if selector == "" {
return nil
}
parts := make([]string, 0, 1)
var b strings.Builder
var quote rune
depthParen := 0
depthBracket := 0
depthBrace := 0
for _, r := range selector {
switch {
case quote != 0:
b.WriteRune(r)
if r == quote {
quote = 0
} else if r == '\\' {
// Keep escaped characters inside quoted strings intact.
continue
}
case r == '"' || r == '\'':
quote = r
b.WriteRune(r)
case r == '(':
depthParen++
b.WriteRune(r)
case r == ')':
if depthParen > 0 {
depthParen--
}
b.WriteRune(r)
case r == '[':
depthBracket++
b.WriteRune(r)
case r == ']':
if depthBracket > 0 {
depthBracket--
}
b.WriteRune(r)
case r == '{':
depthBrace++
b.WriteRune(r)
case r == '}':
if depthBrace > 0 {
depthBrace--
}
b.WriteRune(r)
case r == ',' && depthParen == 0 && depthBracket == 0 && depthBrace == 0:
parts = append(parts, b.String())
b.Reset()
default:
b.WriteRune(r)
}
}
parts = append(parts, b.String())
return parts
}
// responsive.go: Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Example: r.Variant("mobile", NewLayout("C").C(Raw("body"))).
// Variants render in insertion order.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
if r == nil {
return nil
}
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
return r
}
// responsive.go: Render produces HTML with each variant in a data-variant container.
// Example: NewResponsive().Variant("desktop", NewLayout("C")).Render(NewContext()).
func (r *Responsive) Render(ctx *Context) string {
return r.renderWithPath(ctx, "")
}
func (r *Responsive) cloneNode() Node {
if r == nil {
return (*Responsive)(nil)
}
clone := *r
if r.attrs != nil {
clone.attrs = maps.Clone(r.attrs)
}
if r.variants != nil {
clone.variants = make([]responsiveVariant, len(r.variants))
for i := range r.variants {
clone.variants[i] = r.variants[i]
if r.variants[i].layout != nil {
if layout, ok := cloneNode(r.variants[i].layout).(*Layout); ok {
clone.variants[i].layout = layout
}
}
}
}
return &clone
}
func (r *Responsive) renderWithPath(ctx *Context, path string) string {
if r == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
for _, v := range r.variants {
b.WriteString(`<div`)
if len(r.attrs) > 0 {
keys := slices.Collect(maps.Keys(r.attrs))
slices.Sort(keys)
for _, key := range keys {
if key == "data-variant" {
continue
}
b.WriteByte(' ')
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
b.WriteString(escapeAttr(r.attrs[key]))
b.WriteByte('"')
}
}
b.WriteString(` data-variant="`)
b.WriteString(escapeAttr(v.name))
b.WriteString(`">`)
b.WriteString(renderNodeWithPath(v.layout, ctx, path))
b.WriteString(`</div>`)
}
return b.String()
}