feat(html): add RFC block-path rendering
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-15 00:43:07 +01:00
parent 0c19ccf7cc
commit f6bdc0959e
6 changed files with 243 additions and 75 deletions

3
go.work Normal file
View file

@ -0,0 +1,3 @@
go 1.26.0
use .

View file

@ -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
View file

@ -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()
}

View file

@ -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
View file

@ -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
}

View file

@ -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'}},