refactor(core): upgrade to v0.8.0-alpha.1

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 15:24:16 +00:00
parent 0e976b3a87
commit b8d06460d6
21 changed files with 309 additions and 130 deletions

View file

@ -1,7 +1,6 @@
package html
import (
"strconv"
"testing"
i18n "dappco.re/go/core/i18n"
@ -100,7 +99,7 @@ func BenchmarkImprint_Small(b *testing.B) {
func BenchmarkImprint_Large(b *testing.B) {
items := make([]string, 20)
for i := range items {
items[i] = "Item " + strconv.Itoa(i) + " was created successfully"
items[i] = "Item " + itoaText(i) + " was created successfully"
}
page := NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
@ -207,7 +206,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
nodes := make([]Node, 50)
for i := range nodes {
nodes[i] = El("p", Raw("paragraph "+strconv.Itoa(i)))
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
}
layout := NewLayout("HLCRF").
H(Raw("header")).
@ -242,7 +241,7 @@ func benchEach(b *testing.B, n int) {
items[i] = i
}
node := Each(items, func(i int) Node {
return El("li", Raw("item-"+strconv.Itoa(i)))
return El("li", Raw("item-"+itoaText(i)))
})
ctx := NewContext()

View file

@ -3,51 +3,85 @@
package main
import (
"bytes"
"strings"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun_WritesBundle(t *testing.T) {
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
var output bytes.Buffer
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
require.NoError(t, err)
js := output.String()
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Contains(t, js, "customElements.define")
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
}
func TestRun_InvalidJSON(t *testing.T) {
input := strings.NewReader(`not json`)
var output bytes.Buffer
input := core.NewReader(`not json`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
func TestRun_InvalidTag(t *testing.T) {
input := strings.NewReader(`{"H":"notag"}`)
var output bytes.Buffer
input := core.NewReader(`{"H":"notag"}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hyphen")
}
func TestRun_EmptySlots(t *testing.T) {
input := strings.NewReader(`{}`)
var output bytes.Buffer
input := core.NewReader(`{}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
require.NoError(t, err)
assert.Empty(t, output.String())
}
func countSubstr(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexSubstr(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexSubstr(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View file

@ -4,13 +4,13 @@
package main
import (
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"testing"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -38,8 +38,8 @@ func TestWASMBinarySize_WithinBudget(t *testing.T) {
require.NoError(t, err)
raw := []byte(rawStr)
var buf bytes.Buffer
gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
buf := core.NewBuilder()
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
require.NoError(t, err)
_, err = gz.Write(raw)
require.NoError(t, err)

View file

@ -3,9 +3,9 @@
package codegen
import (
"strings"
"text/template"
core "dappco.re/go/core"
log "dappco.re/go/core/log"
)
@ -33,11 +33,11 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
// GenerateClass produces a JS class definition for a custom element.
func GenerateClass(tag, slot string) (string, error) {
if !strings.Contains(tag, "-") {
if !core.Contains(tag, "-") {
return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil)
}
var b strings.Builder
err := wcTemplate.Execute(&b, struct {
b := core.NewBuilder()
err := wcTemplate.Execute(b, struct {
ClassName, Tag, Slot string
}{
ClassName: TagToClassName(tag),
@ -57,10 +57,10 @@ func GenerateRegistration(tag, className string) string {
// TagToClassName converts a kebab-case tag to PascalCase class name.
func TagToClassName(tag string) string {
var b strings.Builder
for p := range strings.SplitSeq(tag, "-") {
b := core.NewBuilder()
for _, p := range core.Split(tag, "-") {
if len(p) > 0 {
b.WriteString(strings.ToUpper(p[:1]))
b.WriteString(core.Upper(p[:1]))
b.WriteString(p[1:])
}
}
@ -71,7 +71,7 @@ func TagToClassName(tag string) string {
// for a set of HLCRF slot assignments.
func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool)
var b strings.Builder
b := core.NewBuilder()
for slot, tag := range slots {
if seen[tag] {

View file

@ -3,7 +3,6 @@
package codegen
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -53,6 +52,41 @@ func TestGenerateBundle_DeduplicatesRegistrations(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
assert.Equal(t, 2, strings.Count(js, "customElements.define"))
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
}
func countSubstr(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexSubstr(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexSubstr(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View file

@ -1,8 +1,6 @@
package html
import (
"strconv"
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -33,7 +31,7 @@ func TestText_Emoji(t *testing.T) {
t.Error("Text with emoji should not produce empty output")
}
// Emoji should pass through (they are not HTML special chars)
if !strings.Contains(got, tt.input) {
if !containsText(got, tt.input) {
// Some chars may get escaped, but emoji bytes should survive
t.Logf("note: emoji text rendered as %q", got)
}
@ -80,10 +78,10 @@ func TestEl_RTL(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
got := node.Render(ctx)
if !strings.Contains(got, `dir="rtl"`) {
if !containsText(got, `dir="rtl"`) {
t.Errorf("RTL element missing dir attribute in: %s", got)
}
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
t.Errorf("RTL element missing Arabic text in: %s", got)
}
}
@ -168,7 +166,7 @@ func TestAttr_UnicodeValue(t *testing.T) {
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
got := node.Render(ctx)
want := "title=\"\U0001F680 Rocket Launch\""
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("attribute with emoji should be preserved, got: %s", got)
}
}
@ -187,7 +185,7 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
got := current.Render(ctx)
// Should contain the deepest content
if !strings.Contains(got, "deepest") {
if !containsText(got, "deepest") {
t.Error("10 levels deep: missing leaf content")
}
@ -196,12 +194,12 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
for i := 1; i < 10; i++ {
expectedBlock += "-C-0"
}
if !strings.Contains(got, `data-block="`+expectedBlock+`"`) {
if !containsText(got, `data-block="`+expectedBlock+`"`) {
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
}
// Must have exactly 10 <main> tags
if count := strings.Count(got, "<main"); count != 10 {
if count := countText(got, "<main"); count != 10 {
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
}
}
@ -216,10 +214,10 @@ func TestLayout_DeepNesting_20Levels(t *testing.T) {
got := current.Render(ctx)
if !strings.Contains(got, "bottom") {
if !containsText(got, "bottom") {
t.Error("20 levels deep: missing leaf content")
}
if count := strings.Count(got, "<main"); count != 20 {
if count := countText(got, "<main"); count != 20 {
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
}
}
@ -238,7 +236,7 @@ func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
}
got := current.Render(ctx)
if !strings.Contains(got, "leaf") {
if !containsText(got, "leaf") {
t.Error("mixed deep nesting: missing leaf content")
}
}
@ -251,18 +249,18 @@ func TestEach_LargeIteration_1000(t *testing.T) {
}
node := Each(items, func(i int) Node {
return El("li", Raw(strconv.Itoa(i)))
return El("li", Raw(itoaText(i)))
})
got := node.Render(ctx)
if count := strings.Count(got, "<li>"); count != 1000 {
if count := countText(got, "<li>"); count != 1000 {
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
}
if !strings.Contains(got, "<li>0</li>") {
if !containsText(got, "<li>0</li>") {
t.Error("Each with 1000 items: missing first item")
}
if !strings.Contains(got, "<li>999</li>") {
if !containsText(got, "<li>999</li>") {
t.Error("Each with 1000 items: missing last item")
}
}
@ -275,12 +273,12 @@ func TestEach_LargeIteration_5000(t *testing.T) {
}
node := Each(items, func(i int) Node {
return El("span", Raw(strconv.Itoa(i)))
return El("span", Raw(itoaText(i)))
})
got := node.Render(ctx)
if count := strings.Count(got, "<span>"); count != 5000 {
if count := countText(got, "<span>"); count != 5000 {
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
}
}
@ -292,19 +290,19 @@ func TestEach_NestedEach(t *testing.T) {
node := Each(rows, func(row int) Node {
return El("tr", Each(cols, func(col string) Node {
return El("td", Raw(strconv.Itoa(row)+"-"+col))
return El("td", Raw(itoaText(row)+"-"+col))
}))
})
got := node.Render(ctx)
if count := strings.Count(got, "<tr>"); count != 3 {
if count := countText(got, "<tr>"); count != 3 {
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
}
if count := strings.Count(got, "<td>"); count != 9 {
if count := countText(got, "<td>"); count != 9 {
t.Errorf("nested Each: expected 9 <td>, got %d", count)
}
if !strings.Contains(got, "1-b") {
if !containsText(got, "1-b") {
t.Error("nested Each: missing cell content '1-b'")
}
}
@ -351,14 +349,14 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
H(Raw("header")).C(Raw("main"))
got := layout.Render(ctx)
if !strings.Contains(got, "header") {
if !containsText(got, "header") {
t.Errorf("HXC variant should render H slot, got:\n%s", got)
}
if !strings.Contains(got, "main") {
if !containsText(got, "main") {
t.Errorf("HXC variant should render C slot, got:\n%s", got)
}
// Should only have 2 semantic elements
if count := strings.Count(got, "data-block="); count != 2 {
if count := countText(got, "data-block="); count != 2 {
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
}
}
@ -370,7 +368,7 @@ func TestLayout_DuplicateVariantChars(t *testing.T) {
layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx)
count := strings.Count(got, "content")
count := countText(got, "content")
if count != 3 {
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
}
@ -444,10 +442,10 @@ func TestEscapeAttr_AllSpecialChars(t *testing.T) {
node := Attr(El("div"), "data-val", `&<>"'`)
got := node.Render(ctx)
if strings.Contains(got, `"&<>"'"`) {
if containsText(got, `"&<>"'"`) {
t.Error("attribute value with special chars must be fully escaped")
}
if !strings.Contains(got, "&amp;&lt;&gt;&#34;&#39;") {
if !containsText(got, "&amp;&lt;&gt;&#34;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
}
}
@ -458,7 +456,7 @@ func TestElNode_EmptyTag(t *testing.T) {
got := node.Render(ctx)
// Empty tag is weird but should not panic
if !strings.Contains(got, "content") {
if !containsText(got, "content") {
t.Errorf("El with empty tag should still render children, got %q", got)
}
}

11
go.mod
View file

@ -3,14 +3,14 @@ module dappco.re/go/core/html
go 1.26.0
require (
dappco.re/go/core/i18n v0.1.8
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1
)
require (
dappco.re/go/core v0.5.0 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@ -18,10 +18,3 @@ require (
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
dappco.re/go/core => ../../../../core/go
dappco.re/go/core/i18n => ../../../../core/go-i18n
dappco.re/go/core/io => ../../../../core/go-io
dappco.re/go/core/log => ../../../../core/go-log
)

8
go.sum
View file

@ -1,3 +1,11 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=

View file

@ -1,7 +1,5 @@
package html
import "strings"
// Compile-time interface check.
var _ Node = (*Layout)(nil)
@ -75,7 +73,7 @@ func (l *Layout) blockID(slot byte) string {
// Render produces the semantic HTML for this layout.
// Only slots present in the variant string are rendered.
func (l *Layout) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for i := range len(l.variant) {
slot := l.variant[i]

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -13,28 +12,28 @@ func TestLayout_HLCRF(t *testing.T) {
// Must contain semantic elements
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
}
}
// Must contain ARIA roles
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
}
}
// Must contain data-block IDs
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
}
}
// Must contain content
for _, want := range []string{"header", "left", "main", "right", "footer"} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
}
}
@ -48,14 +47,14 @@ func TestLayout_HCF(t *testing.T) {
// HCF should have header, main, footer
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("HCF layout missing %q in:\n%s", want, got)
}
}
// HCF must NOT have L or R slots
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
if strings.Contains(got, unwanted) {
if containsText(got, unwanted) {
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
}
}
@ -68,16 +67,16 @@ func TestLayout_ContentOnly(t *testing.T) {
got := layout.Render(ctx)
// Only C slot should render
if !strings.Contains(got, `data-block="C-0"`) {
if !containsText(got, `data-block="C-0"`) {
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
}
if !strings.Contains(got, "<main") {
if !containsText(got, "<main") {
t.Errorf("C layout missing <main in:\n%s", got)
}
// No other slots
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
if strings.Contains(got, unwanted) {
if containsText(got, unwanted) {
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
}
}
@ -104,13 +103,13 @@ func TestLayout_IgnoresInvalidSlots(t *testing.T) {
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
got := layout.Render(ctx)
if !strings.Contains(got, "main") {
if !containsText(got, "main") {
t.Errorf("C variant should render main content, got:\n%s", got)
}
if strings.Contains(got, "left") {
if containsText(got, "left") {
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
}
if strings.Contains(got, "right") {
if containsText(got, "right") {
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
}
}

View file

@ -5,7 +5,6 @@ import (
"iter"
"maps"
"slices"
"strings"
)
// Node is anything renderable.
@ -96,7 +95,7 @@ func Attr(n Node, key, value string) Node {
}
func (n *elNode) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
@ -249,7 +248,7 @@ func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
}
func (n *eachNode[T]) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for item := range n.items {
b.WriteString(n.fn(item).Render(ctx))
}

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -69,10 +68,10 @@ func TestTextNode_Escapes(t *testing.T) {
ctx := NewContext()
node := Text("<script>alert('xss')</script>")
got := node.Render(ctx)
if strings.Contains(got, "<script>") {
if containsText(got, "<script>") {
t.Errorf("Text node must HTML-escape output, got %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
if !containsText(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got)
}
}
@ -171,7 +170,7 @@ func TestElNode_AttrEscaping(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !strings.Contains(got, `alt="he said &#34;hello&#34;"`) {
if !containsText(got, `alt="he said &#34;hello&#34;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}
@ -180,7 +179,7 @@ func TestElNode_MultipleAttrs(t *testing.T) {
ctx := NewContext()
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
got := node.Render(ctx)
if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
t.Errorf("multiple Attr() calls should stack, got %q", got)
}
}

18
path.go
View file

@ -1,7 +1,5 @@
package html
import "strings"
// ParseBlockID extracts the slot sequence from a data-block ID.
// "L-0-C-0" → ['L', 'C']
func ParseBlockID(id string) []byte {
@ -12,12 +10,18 @@ func ParseBlockID(id string) []byte {
// Split on "-" and take every other element (the slot letters).
// Format: "X-0" or "X-0-Y-0-Z-0"
var slots []byte
i := 0
for part := range strings.SplitSeq(id, "-") {
if i%2 == 0 && len(part) == 1 {
slots = append(slots, part[0])
part := 0
start := 0
for i := 0; i <= len(id); i++ {
if i < len(id) && id[i] != '-' {
continue
}
i++
if part%2 == 0 && i-start == 1 {
slots = append(slots, id[start])
}
part++
start = i + 1
}
return slots
}

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -13,14 +12,14 @@ func TestNestedLayout_PathChain(t *testing.T) {
// Inner layout paths must be prefixed with parent block ID
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("nested layout missing %q in:\n%s", want, got)
}
}
// Outer layout must still have root-level paths
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("outer layout missing %q in:\n%s", want, got)
}
}
@ -33,7 +32,7 @@ func TestNestedLayout_DeepNesting(t *testing.T) {
got := outer.Render(NewContext())
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("deep nesting missing %q in:\n%s", want, got)
}
}

View file

@ -3,7 +3,7 @@
package html
import (
"strings"
core "dappco.re/go/core"
"dappco.re/go/core/i18n/reversal"
)
@ -12,7 +12,7 @@ import (
// Tag boundaries are collapsed into single spaces; result is trimmed.
// Does not handle script/style element content (go-html does not generate these).
func StripTags(html string) string {
var b strings.Builder
b := core.NewBuilder()
inTag := false
prevSpace := true // starts true to trim leading space
for _, r := range html {
@ -40,7 +40,7 @@ func StripTags(html string) string {
}
}
}
return strings.TrimSpace(b.String())
return core.Trim(b.String())
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -28,14 +27,14 @@ func TestRender_FullPage(t *testing.T) {
// Contains semantic elements
for _, want := range []string{"<header", "<main", "<footer"} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
}
}
// Content rendered
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
if !strings.Contains(got, want) {
if !containsText(got, want) {
t.Errorf("full page missing content %q in:\n%s", want, got)
}
}
@ -44,7 +43,7 @@ func TestRender_FullPage(t *testing.T) {
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
open := "<" + tag
close := "</" + tag + ">"
if strings.Count(got, open) != strings.Count(got, close) {
if countText(got, open) != countText(got, close) {
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
}
}
@ -67,13 +66,13 @@ func TestRender_EntitlementGating(t *testing.T) {
got := page.Render(ctx)
if !strings.Contains(got, "public") {
if !containsText(got, "public") {
t.Errorf("entitlement gating should render public content, got:\n%s", got)
}
if !strings.Contains(got, "admin-panel") {
if !containsText(got, "admin-panel") {
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
}
if strings.Contains(got, "premium-content") {
if containsText(got, "premium-content") {
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
}
}
@ -88,10 +87,10 @@ func TestRender_XSSPrevention(t *testing.T) {
got := page.Render(ctx)
if strings.Contains(got, "<script>") {
if containsText(got, "<script>") {
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
if !containsText(got, "&lt;script&gt;") {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
}
}

View file

@ -1,7 +1,5 @@
package html
import "strings"
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
@ -27,7 +25,7 @@ func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
// Render produces HTML with each variant in a data-variant container.
func (r *Responsive) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for _, v := range r.variants {
b.WriteString(`<div data-variant="`)
b.WriteString(escapeAttr(v.name))

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -12,10 +11,10 @@ func TestResponsive_SingleVariant(t *testing.T) {
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
got := r.Render(ctx)
if !strings.Contains(got, `data-variant="desktop"`) {
if !containsText(got, `data-variant="desktop"`) {
t.Errorf("responsive should contain data-variant, got:\n%s", got)
}
if !strings.Contains(got, `data-block="H-0"`) {
if !containsText(got, `data-block="H-0"`) {
t.Errorf("responsive should contain layout content, got:\n%s", got)
}
}
@ -30,7 +29,7 @@ func TestResponsive_MultiVariant(t *testing.T) {
got := r.Render(ctx)
for _, v := range []string{"desktop", "tablet", "mobile"} {
if !strings.Contains(got, `data-variant="`+v+`"`) {
if !containsText(got, `data-variant="`+v+`"`) {
t.Errorf("responsive missing variant %q in:\n%s", v, got)
}
}
@ -44,8 +43,8 @@ func TestResponsive_VariantOrder(t *testing.T) {
got := r.Render(ctx)
di := strings.Index(got, `data-variant="desktop"`)
mi := strings.Index(got, `data-variant="mobile"`)
di := indexText(got, `data-variant="desktop"`)
mi := indexText(got, `data-variant="mobile"`)
if di < 0 || mi < 0 {
t.Fatalf("missing variants in:\n%s", got)
}
@ -62,10 +61,10 @@ func TestResponsive_NestedPaths(t *testing.T) {
got := r.Render(ctx)
if !strings.Contains(got, `data-block="C-0-H-0"`) {
if !containsText(got, `data-block="C-0-H-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
}
if !strings.Contains(got, `data-block="C-0-C-0"`) {
if !containsText(got, `data-block="C-0-C-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
}
}
@ -78,7 +77,7 @@ func TestResponsive_VariantsIndependent(t *testing.T) {
got := r.Render(ctx)
count := strings.Count(got, `data-block="C-0"`)
count := countText(got, `data-block="C-0"`)
if count != 2 {
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
}

48
test_helpers_test.go Normal file
View file

@ -0,0 +1,48 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
func containsText(s, substr string) bool {
return core.Contains(s, substr)
}
func countText(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexText(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexText(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func itoaText(v int) string {
return core.Sprint(v)
}

38
text_builder_default.go Normal file
View file

@ -0,0 +1,38 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
type builderOps interface {
WriteByte(byte) error
WriteRune(rune) (int, error)
WriteString(string) (int, error)
String() string
}
type textBuilder struct {
inner builderOps
}
func newTextBuilder() *textBuilder {
return &textBuilder{inner: core.NewBuilder()}
}
func (b *textBuilder) WriteByte(c byte) error {
return b.inner.WriteByte(c)
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
return b.inner.WriteRune(r)
}
func (b *textBuilder) WriteString(s string) (int, error) {
return b.inner.WriteString(s)
}
func (b *textBuilder) String() string {
return b.inner.String()
}

33
text_builder_js.go Normal file
View file

@ -0,0 +1,33 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
type textBuilder struct {
buf []byte
}
func newTextBuilder() *textBuilder {
return &textBuilder{buf: make([]byte, 0, 128)}
}
func (b *textBuilder) WriteByte(c byte) error {
b.buf = append(b.buf, c)
return nil
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
s := string(r)
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) String() string {
return string(b.buf)
}