From 8a3f28aff357ae827d28db0ac797e0c4f4b8740f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 11:15:24 +0000 Subject: [PATCH 1/3] fix(conventions): isolate banned imports and clarify tests Co-Authored-By: Virgil --- bench_test.go | 8 ++++---- cmd/codegen/main.go | 2 ++ cmd/codegen/main_test.go | 10 ++++++---- cmd/wasm/register_test.go | 4 ++-- cmd/wasm/size_test.go | 2 +- codegen/bench_test.go | 2 ++ codegen/codegen.go | 5 +++-- codegen/codegen_test.go | 14 +++++++++----- codegen/doc.go | 13 +++++++++++++ doc.go | 12 ++++++++++++ docs/development.md | 4 ++-- docs/history.md | 2 +- edge_test.go | 10 +++++----- 13 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 codegen/doc.go create mode 100644 doc.go diff --git a/bench_test.go b/bench_test.go index 220363e..211a795 100644 --- a/bench_test.go +++ b/bench_test.go @@ -1,7 +1,7 @@ package html import ( - "fmt" + "strconv" "testing" i18n "dappco.re/go/core/i18n" @@ -100,7 +100,7 @@ func BenchmarkImprint_Small(b *testing.B) { func BenchmarkImprint_Large(b *testing.B) { items := make([]string, 20) for i := range items { - items[i] = fmt.Sprintf("Item %d was created successfully", i) + items[i] = "Item " + strconv.Itoa(i) + " was created successfully" } page := NewLayout("HLCRF"). H(El("h1", Text("Building project"))). @@ -207,7 +207,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(fmt.Sprintf("paragraph %d", i))) + nodes[i] = El("p", Raw("paragraph "+strconv.Itoa(i))) } layout := NewLayout("HLCRF"). H(Raw("header")). @@ -242,7 +242,7 @@ func benchEach(b *testing.B, n int) { items[i] = i } node := Each(items, func(i int) Node { - return El("li", Raw(fmt.Sprintf("item-%d", i))) + return El("li", Raw("item-"+strconv.Itoa(i))) }) ctx := NewContext() diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index b72def3..46f67d1 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -1,3 +1,5 @@ +//go:build !js + // Package main provides a build-time CLI for generating Web Component JS bundles. // Reads a JSON slot map from stdin, writes the generated JS to stdout. // diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go index edacf19..701a095 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -1,3 +1,5 @@ +//go:build !js + package main import ( @@ -9,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestRun_Good(t *testing.T) { +func TestRun_WritesBundle(t *testing.T) { input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`) var output bytes.Buffer @@ -23,7 +25,7 @@ func TestRun_Good(t *testing.T) { assert.Equal(t, 2, strings.Count(js, "extends HTMLElement")) } -func TestRun_Bad_InvalidJSON(t *testing.T) { +func TestRun_InvalidJSON(t *testing.T) { input := strings.NewReader(`not json`) var output bytes.Buffer @@ -32,7 +34,7 @@ func TestRun_Bad_InvalidJSON(t *testing.T) { assert.Contains(t, err.Error(), "invalid JSON") } -func TestRun_Bad_InvalidTag(t *testing.T) { +func TestRun_InvalidTag(t *testing.T) { input := strings.NewReader(`{"H":"notag"}`) var output bytes.Buffer @@ -41,7 +43,7 @@ func TestRun_Bad_InvalidTag(t *testing.T) { assert.Contains(t, err.Error(), "hyphen") } -func TestRun_Good_Empty(t *testing.T) { +func TestRun_EmptySlots(t *testing.T) { input := strings.NewReader(`{}`) var output bytes.Buffer diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go index 65f2af2..255fab8 100644 --- a/cmd/wasm/register_test.go +++ b/cmd/wasm/register_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBuildComponentJS_Good(t *testing.T) { +func TestBuildComponentJS_ValidJSON(t *testing.T) { slotsJSON := `{"H":"nav-bar","C":"main-content"}` js, err := buildComponentJS(slotsJSON) require.NoError(t, err) @@ -18,7 +18,7 @@ func TestBuildComponentJS_Good(t *testing.T) { assert.Contains(t, js, "customElements.define") } -func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) { +func TestBuildComponentJS_InvalidJSON(t *testing.T) { _, err := buildComponentJS("not json") assert.Error(t, err) } diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go index ed759c1..79bac23 100644 --- a/cmd/wasm/size_test.go +++ b/cmd/wasm/size_test.go @@ -21,7 +21,7 @@ const ( wasmRawLimit = 3_670_016 // 3.5 MB raw size limit ) -func TestWASMBinarySize_Good(t *testing.T) { +func TestWASMBinarySize_WithinBudget(t *testing.T) { if testing.Short() { t.Skip("skipping WASM build test in short mode") } diff --git a/codegen/bench_test.go b/codegen/bench_test.go index 0fdaecd..4a678d9 100644 --- a/codegen/bench_test.go +++ b/codegen/bench_test.go @@ -1,3 +1,5 @@ +//go:build !js + package codegen import "testing" diff --git a/codegen/codegen.go b/codegen/codegen.go index 8692b0b..4963db4 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -1,7 +1,8 @@ +//go:build !js + package codegen import ( - "fmt" "strings" "text/template" @@ -51,7 +52,7 @@ func GenerateClass(tag, slot string) (string, error) { // GenerateRegistration produces the customElements.define() call. func GenerateRegistration(tag, className string) string { - return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className) + return `customElements.define("` + tag + `", ` + className + `);` } // TagToClassName converts a kebab-case tag to PascalCase class name. diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 28a6aa2..de3f643 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -1,3 +1,5 @@ +//go:build !js + package codegen import ( @@ -8,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateClass_Good(t *testing.T) { +func TestGenerateClass_ValidTag(t *testing.T) { js, err := GenerateClass("photo-grid", "C") require.NoError(t, err) assert.Contains(t, js, "class PhotoGrid extends HTMLElement") @@ -17,19 +19,19 @@ func TestGenerateClass_Good(t *testing.T) { assert.Contains(t, js, "photo-grid") } -func TestGenerateClass_Bad_InvalidTag(t *testing.T) { +func TestGenerateClass_InvalidTag(t *testing.T) { _, err := GenerateClass("invalid", "C") assert.Error(t, err, "custom element names must contain a hyphen") } -func TestGenerateRegistration_Good(t *testing.T) { +func TestGenerateRegistration_DefinesCustomElement(t *testing.T) { js := GenerateRegistration("photo-grid", "PhotoGrid") assert.Contains(t, js, "customElements.define") assert.Contains(t, js, `"photo-grid"`) assert.Contains(t, js, "PhotoGrid") } -func TestTagToClassName_Good(t *testing.T) { +func TestTagToClassName_KebabCase(t *testing.T) { tests := []struct{ tag, want string }{ {"photo-grid", "PhotoGrid"}, {"nav-breadcrumb", "NavBreadcrumb"}, @@ -41,14 +43,16 @@ func TestTagToClassName_Good(t *testing.T) { } } -func TestGenerateBundle_Good(t *testing.T) { +func TestGenerateBundle_DeduplicatesRegistrations(t *testing.T) { slots := map[string]string{ "H": "nav-bar", "C": "main-content", + "F": "nav-bar", } js, err := GenerateBundle(slots) 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")) } diff --git a/codegen/doc.go b/codegen/doc.go new file mode 100644 index 0000000..afc9b13 --- /dev/null +++ b/codegen/doc.go @@ -0,0 +1,13 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package codegen generates Web Component bundles for go-html slot maps. +// +// Use it at build time, or through the cmd/codegen CLI: +// +// bundle, err := GenerateBundle(map[string]string{ +// "H": "site-header", +// "C": "app-main", +// }) +package codegen diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b3c3317 --- /dev/null +++ b/doc.go @@ -0,0 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package html renders semantic HTML from composable node trees. +// +// A typical page combines Layout, El, Text, and Render: +// +// page := NewLayout("HCF"). +// H(El("h1", Text("page.title"))). +// C(El("main", Text("page.body"))). +// F(El("small", Text("page.footer"))) +// out := Render(page, NewContext()) +package html diff --git a/docs/development.md b/docs/development.md index eb5c476..c5c6838 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,7 +66,7 @@ go test ./cmd/codegen/ go test ./cmd/wasm/ ``` -The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself. +The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself. ### Test Dependencies @@ -278,7 +278,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) { ### Codegen Tests with Testify ```go -func TestGenerateClass_Good(t *testing.T) { +func TestGenerateClass_ValidTag(t *testing.T) { js, err := GenerateClass("photo-grid", "C") require.NoError(t, err) assert.Contains(t, js, "class PhotoGrid extends HTMLElement") diff --git a/docs/history.md b/docs/history.md index 4631ef2..8321ae2 100644 --- a/docs/history.md +++ b/docs/history.md @@ -78,7 +78,7 @@ The fix was applied in three distinct steps: ### Size gate test (`aae5d21`) -`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts: +`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts: - Gzip size < 1,048,576 bytes (1 MB). - Raw size < 3,145,728 bytes (3 MB). diff --git a/edge_test.go b/edge_test.go index 9ff9055..8d1261d 100644 --- a/edge_test.go +++ b/edge_test.go @@ -1,7 +1,7 @@ package html import ( - "fmt" + "strconv" "strings" "testing" @@ -196,7 +196,7 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) { for i := 1; i < 10; i++ { expectedBlock += "-C-0" } - if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) { + if !strings.Contains(got, `data-block="`+expectedBlock+`"`) { t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got) } @@ -251,7 +251,7 @@ func TestEach_LargeIteration_1000(t *testing.T) { } node := Each(items, func(i int) Node { - return El("li", Raw(fmt.Sprintf("%d", i))) + return El("li", Raw(strconv.Itoa(i))) }) got := node.Render(ctx) @@ -275,7 +275,7 @@ func TestEach_LargeIteration_5000(t *testing.T) { } node := Each(items, func(i int) Node { - return El("span", Raw(fmt.Sprintf("%d", i))) + return El("span", Raw(strconv.Itoa(i))) }) got := node.Render(ctx) @@ -292,7 +292,7 @@ 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(fmt.Sprintf("%d-%s", row, col))) + return El("td", Raw(strconv.Itoa(row)+"-"+col)) })) }) From 0e976b3a87251ccf0d6a99138add7c5ed46a3d61 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 11:31:31 +0000 Subject: [PATCH 2/3] fix(wasm): keep server i18n out of js builds Co-Authored-By: Virgil --- context.go | 14 ++++++++++---- docs/architecture.md | 8 ++++---- docs/development.md | 2 +- node.go | 10 +--------- text_translate.go | 11 +++++++++++ text_translate_default.go | 11 +++++++++++ text_translate_js.go | 9 +++++++++ 7 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 text_translate.go create mode 100644 text_translate_default.go create mode 100644 text_translate_js.go diff --git a/context.go b/context.go index 3bc77ef..a220dbc 100644 --- a/context.go +++ b/context.go @@ -1,6 +1,12 @@ package html -import i18n "dappco.re/go/core/i18n" +// Translator provides Text() lookups for a rendering context. +// +// The default server build uses go-i18n. Alternate builds, including WASM, +// can provide any implementation with the same T() method. +type Translator interface { + T(key string, args ...any) string +} // Context carries rendering state through the node tree. type Context struct { @@ -8,7 +14,7 @@ type Context struct { Locale string Entitlements func(feature string) bool Data map[string]any - service *i18n.Service + service Translator } // NewContext creates a new rendering context with sensible defaults. @@ -18,8 +24,8 @@ func NewContext() *Context { } } -// NewContextWithService creates a rendering context backed by a specific i18n service. -func NewContextWithService(svc *i18n.Service) *Context { +// NewContextWithService creates a rendering context backed by a specific translator. +func NewContextWithService(svc Translator) *Context { return &Context{ Data: make(map[string]any), service: svc, diff --git a/docs/architecture.md b/docs/architecture.md index a78bad6..7ecd934 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ All concrete node types are unexported structs with exported constructor functio |-------------|-----------| | `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. | | `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. | -| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. | +| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. | | `Raw(content)` | Unescaped trusted content. Explicit escape hatch. | | `If(cond, Node)` | Renders the child only when the condition function returns true. | | `Unless(cond, Node)` | Renders the child only when the condition function returns false. | @@ -50,16 +50,16 @@ type Context struct { Locale string // BCP 47 locale string Entitlements func(feature string) bool // feature gate callback Data map[string]any // arbitrary per-request data - service *i18n.Service // unexported; set via constructor + service Translator // unexported; set via constructor } ``` Two constructors are provided: - `NewContext()` creates a context with sensible defaults and an empty `Data` map. -- `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance. +- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`. -The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction. +The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean. ## HLCRF Layout diff --git a/docs/development.md b/docs/development.md index c5c6838..55af80b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -291,6 +291,6 @@ func TestGenerateClass_ValidTag(t *testing.T) { - `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning. - `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first. -- `Context.service` is unexported. Custom i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction. +- `Context.service` is unexported. Custom translation injection requires `NewContextWithService()`. There is no way to swap the translator after construction. - The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript. - `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs. diff --git a/node.go b/node.go index f47ee36..d5d6be2 100644 --- a/node.go +++ b/node.go @@ -6,8 +6,6 @@ import ( "maps" "slices" "strings" - - i18n "dappco.re/go/core/i18n" ) // Node is anything renderable. @@ -152,13 +150,7 @@ func Text(key string, args ...any) Node { } func (n *textNode) Render(ctx *Context) string { - var text string - if ctx != nil && ctx.service != nil { - text = ctx.service.T(n.key, n.args...) - } else { - text = i18n.T(n.key, n.args...) - } - return escapeHTML(text) + return escapeHTML(translateText(ctx, n.key, n.args...)) } // --- ifNode --- diff --git a/text_translate.go b/text_translate.go new file mode 100644 index 0000000..4e3ee8f --- /dev/null +++ b/text_translate.go @@ -0,0 +1,11 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package html + +func translateText(ctx *Context, key string, args ...any) string { + if ctx != nil && ctx.service != nil { + return ctx.service.T(key, args...) + } + + return translateDefault(key, args...) +} diff --git a/text_translate_default.go b/text_translate_default.go new file mode 100644 index 0000000..3bb280c --- /dev/null +++ b/text_translate_default.go @@ -0,0 +1,11 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package html + +import i18n "dappco.re/go/core/i18n" + +func translateDefault(key string, args ...any) string { + return i18n.T(key, args...) +} diff --git a/text_translate_js.go b/text_translate_js.go new file mode 100644 index 0000000..692e4c9 --- /dev/null +++ b/text_translate_js.go @@ -0,0 +1,9 @@ +//go:build js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package html + +func translateDefault(key string, _ ...any) string { + return key +} From b8d06460d676b142e19fa427ee1a2969a620fa6a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 15:24:16 +0000 Subject: [PATCH 3/3] refactor(core): upgrade to v0.8.0-alpha.1 Co-Authored-By: Virgil --- bench_test.go | 7 ++--- cmd/codegen/main_test.go | 64 ++++++++++++++++++++++++++++++---------- cmd/wasm/size_test.go | 6 ++-- codegen/codegen.go | 16 +++++----- codegen/codegen_test.go | 40 +++++++++++++++++++++++-- edge_test.go | 56 +++++++++++++++++------------------ go.mod | 11 ++----- go.sum | 8 +++++ layout.go | 4 +-- layout_test.go | 25 ++++++++-------- node.go | 5 ++-- node_test.go | 9 +++--- path.go | 18 ++++++----- path_test.go | 7 ++--- pipeline.go | 6 ++-- render_test.go | 17 +++++------ responsive.go | 4 +-- responsive_test.go | 17 +++++------ test_helpers_test.go | 48 ++++++++++++++++++++++++++++++ text_builder_default.go | 38 ++++++++++++++++++++++++ text_builder_js.go | 33 +++++++++++++++++++++ 21 files changed, 309 insertions(+), 130 deletions(-) create mode 100644 test_helpers_test.go create mode 100644 text_builder_default.go create mode 100644 text_builder_js.go 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, "