feat(html): add RFC block-path rendering
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
0c19ccf7cc
commit
f6bdc0959e
6 changed files with 243 additions and 75 deletions
3
go.work
Normal file
3
go.work
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
go 1.26.0
|
||||
|
||||
use .
|
||||
70
layout.go
70
layout.go
|
|
@ -5,7 +5,10 @@ package html
|
|||
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
|
||||
// The stdlib errors package is safe for WASM.
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Layout)(nil)
|
||||
|
|
@ -49,51 +52,7 @@ func renderWithLayoutPath(node Node, ctx *Context, path string) string {
|
|||
return renderer.renderWithLayoutPath(ctx, path)
|
||||
}
|
||||
|
||||
switch t := node.(type) {
|
||||
case *Layout:
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
clone := *t
|
||||
clone.path = path
|
||||
return clone.Render(ctx)
|
||||
case *ifNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *unlessNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *entitledNode:
|
||||
if t == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
case *switchNode:
|
||||
if t == nil || t.selector == nil || t.cases == nil {
|
||||
return ""
|
||||
}
|
||||
key := t.selector(ctx)
|
||||
node, ok := t.cases[key]
|
||||
if !ok || node == nil {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(node, ctx, path)
|
||||
default:
|
||||
return node.Render(ctx)
|
||||
}
|
||||
return node.Render(ctx)
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
|
|
@ -237,11 +196,11 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
b.WriteString(escapeAttr(bid))
|
||||
b.WriteString(`">`)
|
||||
|
||||
for _, child := range children {
|
||||
for i, child := range children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, bid+"."+strconv.Itoa(i)))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
|
|
@ -263,3 +222,18 @@ func (e *layoutVariantError) Error() string {
|
|||
func (e *layoutVariantError) Unwrap() error {
|
||||
return ErrInvalidLayoutVariant
|
||||
}
|
||||
|
||||
func (l *Layout) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
clone := *l
|
||||
base := trimBlockPath(path)
|
||||
if base != "" {
|
||||
clone.path = base + "-"
|
||||
} else {
|
||||
clone.path = ""
|
||||
}
|
||||
return clone.Render(ctx)
|
||||
}
|
||||
|
|
|
|||
95
node.go
95
node.go
|
|
@ -71,6 +71,10 @@ func (n *rawNode) Render(_ *Context) string {
|
|||
return n.content
|
||||
}
|
||||
|
||||
func (n *rawNode) renderWithLayoutPath(_ *Context, _ string) string {
|
||||
return n.Render(nil)
|
||||
}
|
||||
|
||||
// --- elNode ---
|
||||
|
||||
type elNode struct {
|
||||
|
|
@ -147,23 +151,39 @@ func Role(n Node, role string) Node {
|
|||
}
|
||||
|
||||
func (n *elNode) Render(ctx *Context) string {
|
||||
return n.render(ctx, "")
|
||||
}
|
||||
|
||||
func (n *elNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
return n.render(ctx, path)
|
||||
}
|
||||
|
||||
func (n *elNode) render(ctx *Context, path string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
attrs := n.attrs
|
||||
if path != "" {
|
||||
attrs = make(map[string]string, len(n.attrs)+1)
|
||||
for key, value := range n.attrs {
|
||||
attrs[key] = value
|
||||
}
|
||||
attrs["data-block"] = path
|
||||
}
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
|
||||
// Sort attribute keys for deterministic output.
|
||||
keys := slices.Collect(maps.Keys(n.attrs))
|
||||
keys := slices.Collect(maps.Keys(attrs))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(escapeHTML(key))
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeAttr(n.attrs[key]))
|
||||
b.WriteString(escapeAttr(attrs[key]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +194,15 @@ func (n *elNode) Render(ctx *Context) string {
|
|||
}
|
||||
|
||||
for i := range len(n.children) {
|
||||
if n.children[i] == nil {
|
||||
child := n.children[i]
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(n.children[i].Render(ctx))
|
||||
if path == "" {
|
||||
b.WriteString(child.Render(ctx))
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path+"."+strconv.Itoa(i)))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
|
|
@ -215,6 +240,10 @@ func (n *textNode) Render(ctx *Context) string {
|
|||
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||
}
|
||||
|
||||
func (n *textNode) renderWithLayoutPath(ctx *Context, _ string) string {
|
||||
return n.Render(ctx)
|
||||
}
|
||||
|
||||
// --- ifNode ---
|
||||
|
||||
type ifNode struct {
|
||||
|
|
@ -238,6 +267,16 @@ func (n *ifNode) Render(ctx *Context) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (n *ifNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if n.cond(ctx) {
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- unlessNode ---
|
||||
|
||||
type unlessNode struct {
|
||||
|
|
@ -261,6 +300,16 @@ func (n *unlessNode) Render(ctx *Context) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (n *unlessNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !n.cond(ctx) {
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- entitledNode ---
|
||||
|
||||
type entitledNode struct {
|
||||
|
|
@ -285,6 +334,16 @@ func (n *entitledNode) Render(ctx *Context) string {
|
|||
return n.node.Render(ctx)
|
||||
}
|
||||
|
||||
func (n *entitledNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
|
||||
// --- switchNode ---
|
||||
|
||||
type switchNode struct {
|
||||
|
|
@ -315,6 +374,21 @@ func (n *switchNode) Render(ctx *Context) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (n *switchNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.selector == nil {
|
||||
return ""
|
||||
}
|
||||
key := n.selector(ctx)
|
||||
if n.cases == nil {
|
||||
return ""
|
||||
}
|
||||
node, ok := n.cases[key]
|
||||
if !ok || node == nil {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(node, ctx, path)
|
||||
}
|
||||
|
||||
// --- eachNode ---
|
||||
|
||||
type eachNode[T any] struct {
|
||||
|
|
@ -359,12 +433,25 @@ func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
|||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
idx := 0
|
||||
for item := range n.items {
|
||||
child := n.fn(item)
|
||||
if child == nil {
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
if path != "" {
|
||||
if _, ok := child.(*elNode); ok {
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path+"."+strconv.Itoa(idx)))
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
||||
idx++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
25
node_test.go
25
node_test.go
|
|
@ -36,6 +36,31 @@ func TestElNode_Nested_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_DirectElementBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
got := NewLayout("C").C(El("div", Raw("content"))).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0.0"`) {
|
||||
t.Fatalf("direct element inside layout should receive a block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EachElementBlockPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
got := NewLayout("C").C(
|
||||
Each([]string{"a", "b"}, func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
}),
|
||||
).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0.0.0"`) {
|
||||
t.Fatalf("first Each item should receive a block path, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C-0.0.1"`) {
|
||||
t.Fatalf("second Each item should receive a block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleChildren_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("a"), Raw("b"))
|
||||
|
|
|
|||
122
path.go
122
path.go
|
|
@ -9,38 +9,114 @@ import "strings"
|
|||
|
||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
// Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
// "L-0-C-0" → ['L', 'C']
|
||||
// Supports both the current slot-path form ("L-0-C-0") and dotted child
|
||||
// coordinates ("C-0.1", "C.2.1").
|
||||
func ParseBlockID(id string) []byte {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept both the current "{slot}-0" path format and the dot notation
|
||||
// used in the RFC prose examples. A plain single-slot ID such as "H" is
|
||||
// also valid.
|
||||
normalized := strings.ReplaceAll(id, ".", "-")
|
||||
if !strings.Contains(normalized, "-") {
|
||||
if len(normalized) == 1 {
|
||||
if _, ok := slotRegistry[normalized[0]]; ok {
|
||||
return []byte{normalized[0]}
|
||||
}
|
||||
tokens := make([]string, 0, 4)
|
||||
seps := make([]byte, 0, 4)
|
||||
|
||||
for i := 0; i < len(id); {
|
||||
start := i
|
||||
for i < len(id) && id[i] != '.' && id[i] != '-' {
|
||||
i++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Valid IDs are exact sequences of "{slot}-0" segments, e.g.
|
||||
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID.
|
||||
parts := strings.Split(normalized, "-")
|
||||
if len(parts)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(parts)/2)
|
||||
for i := 0; i < len(parts); i += 2 {
|
||||
if len(parts[i]) != 1 || parts[i+1] != "0" {
|
||||
token := id[start:i]
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
slots = append(slots, parts[i][0])
|
||||
tokens = append(tokens, token)
|
||||
|
||||
if i == len(id) {
|
||||
seps = append(seps, 0)
|
||||
break
|
||||
}
|
||||
|
||||
seps = append(seps, id[i])
|
||||
i++
|
||||
if i == len(id) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(tokens))
|
||||
if len(tokens) > 1 {
|
||||
last := tokens[len(tokens)-1]
|
||||
if len(last) == 1 {
|
||||
if _, ok := slotRegistry[last[0]]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, token := range tokens {
|
||||
if len(token) == 1 {
|
||||
if _, ok := slotRegistry[token[0]]; ok {
|
||||
slots = append(slots, token[0])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !allDigits(token) {
|
||||
return nil
|
||||
}
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
switch seps[i-1] {
|
||||
case '-':
|
||||
if token != "0" {
|
||||
return nil
|
||||
}
|
||||
case '.':
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// trimBlockPath removes the trailing child coordinate from a block path when
|
||||
// the final segment is numeric. It keeps slot roots like "C-0" intact while
|
||||
// trimming nested coordinates such as "C-0.1" or "C-0.1.2" back to the parent
|
||||
// path.
|
||||
func trimBlockPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lastDot := strings.LastIndexByte(path, '.')
|
||||
if lastDot < 0 || lastDot == len(path)-1 {
|
||||
return path
|
||||
}
|
||||
|
||||
for i := lastDot + 1; i < len(path); i++ {
|
||||
ch := path[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return path[:lastDot]
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
|||
}{
|
||||
{"L-0-C-0", []byte{'L', 'C'}},
|
||||
{"L.0.C.0", []byte{'L', 'C'}},
|
||||
{"C-0.0", []byte{'C'}},
|
||||
{"C.2.1", []byte{'C'}},
|
||||
{"C-0.1.2", []byte{'C'}},
|
||||
{"H", []byte{'H'}},
|
||||
{"H-0", []byte{'H'}},
|
||||
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue