diff --git a/bench_test.go b/bench_test.go index 211a795..7440203 100644 --- a/bench_test.go +++ b/bench_test.go @@ -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() diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go index 701a095..046f470 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -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 +} diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go index 79bac23..8187bd9 100644 --- a/cmd/wasm/size_test.go +++ b/cmd/wasm/size_test.go @@ -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) diff --git a/codegen/codegen.go b/codegen/codegen.go index 4963db4..2f895b3 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -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] { diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index de3f643..480eba6 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -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 } diff --git a/edge_test.go b/edge_test.go index 8d1261d..1f745a4 100644 --- a/edge_test.go +++ b/edge_test.go @@ -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
tags - if count := strings.Count(got, " 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, " 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, "
  • "); count != 1000 { + if count := countText(got, "
  • "); count != 1000 { t.Errorf("Each with 1000 items: expected 1000
  • , got %d", count) } - if !strings.Contains(got, "
  • 0
  • ") { + if !containsText(got, "
  • 0
  • ") { t.Error("Each with 1000 items: missing first item") } - if !strings.Contains(got, "
  • 999
  • ") { + if !containsText(got, "
  • 999
  • ") { 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, ""); count != 5000 { + if count := countText(got, ""); count != 5000 { t.Errorf("Each with 5000 items: expected 5000 , 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, ""); count != 3 { + if count := countText(got, ""); count != 3 { t.Errorf("nested Each: expected 3 , got %d", count) } - if count := strings.Count(got, ""); count != 9 { + if count := countText(got, ""); count != 9 { t.Errorf("nested Each: expected 9 , 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, "&<>"'") { + if !containsText(got, "&<>"'") { 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) } } diff --git a/go.mod b/go.mod index af2ee05..64c6ba9 100644 --- a/go.mod +++ b/go.mod @@ -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 -) diff --git a/go.sum b/go.sum index 899aa9b..bb601bc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/layout.go b/layout.go index adf9b88..873b1d0 100644 --- a/layout.go +++ b/layout.go @@ -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] diff --git a/layout_test.go b/layout_test.go index 53532f4..0b575a8 100644 --- a/layout_test.go +++ b/layout_test.go @@ -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{"alert('xss')") got := node.Render(ctx) - if strings.Contains(got, "