From 8a3f28aff357ae827d28db0ac797e0c4f4b8740f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 11:15:24 +0000 Subject: [PATCH 01/40] 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 02/40] 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 03/40] 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, "") got := node.Render(ctx) @@ -76,7 +76,7 @@ func TestTextNode_Escapes(t *testing.T) { } } -func TestIfNode_True(t *testing.T) { +func TestIfNode_True_Good(t *testing.T) { ctx := NewContext() node := If(func(*Context) bool { return true }, Raw("visible")) got := node.Render(ctx) @@ -85,7 +85,7 @@ func TestIfNode_True(t *testing.T) { } } -func TestIfNode_False(t *testing.T) { +func TestIfNode_False_Good(t *testing.T) { ctx := NewContext() node := If(func(*Context) bool { return false }, Raw("hidden")) got := node.Render(ctx) @@ -94,7 +94,7 @@ func TestIfNode_False(t *testing.T) { } } -func TestUnlessNode(t *testing.T) { +func TestUnlessNode_Good(t *testing.T) { ctx := NewContext() node := Unless(func(*Context) bool { return false }, Raw("visible")) got := node.Render(ctx) @@ -103,7 +103,7 @@ func TestUnlessNode(t *testing.T) { } } -func TestEntitledNode_Granted(t *testing.T) { +func TestEntitledNode_Granted_Good(t *testing.T) { ctx := NewContext() ctx.Entitlements = func(feature string) bool { return feature == "premium" } node := Entitled("premium", Raw("premium content")) @@ -113,7 +113,7 @@ func TestEntitledNode_Granted(t *testing.T) { } } -func TestEntitledNode_Denied(t *testing.T) { +func TestEntitledNode_Denied_Bad(t *testing.T) { ctx := NewContext() ctx.Entitlements = func(feature string) bool { return false } node := Entitled("premium", Raw("premium content")) @@ -123,7 +123,7 @@ func TestEntitledNode_Denied(t *testing.T) { } } -func TestEntitledNode_NoFunc(t *testing.T) { +func TestEntitledNode_NoFunc_Bad(t *testing.T) { ctx := NewContext() node := Entitled("premium", Raw("premium content")) got := node.Render(ctx) @@ -132,7 +132,7 @@ func TestEntitledNode_NoFunc(t *testing.T) { } } -func TestEachNode(t *testing.T) { +func TestEachNode_Good(t *testing.T) { ctx := NewContext() items := []string{"a", "b", "c"} node := Each(items, func(item string) Node { @@ -145,7 +145,7 @@ func TestEachNode(t *testing.T) { } } -func TestEachNode_Empty(t *testing.T) { +func TestEachNode_Empty_Good(t *testing.T) { ctx := NewContext() node := Each([]string{}, func(item string) Node { return El("li", Raw(item)) @@ -156,7 +156,7 @@ func TestEachNode_Empty(t *testing.T) { } } -func TestElNode_Attr(t *testing.T) { +func TestElNode_Attr_Good(t *testing.T) { ctx := NewContext() node := Attr(El("div", Raw("content")), "class", "container") got := node.Render(ctx) @@ -166,7 +166,7 @@ func TestElNode_Attr(t *testing.T) { } } -func TestElNode_AttrEscaping(t *testing.T) { +func TestElNode_AttrEscaping_Good(t *testing.T) { ctx := NewContext() node := Attr(El("img"), "alt", `he said "hello"`) got := node.Render(ctx) @@ -175,7 +175,7 @@ func TestElNode_AttrEscaping(t *testing.T) { } } -func TestElNode_MultipleAttrs(t *testing.T) { +func TestElNode_MultipleAttrs_Good(t *testing.T) { ctx := NewContext() node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav") got := node.Render(ctx) @@ -184,7 +184,7 @@ func TestElNode_MultipleAttrs(t *testing.T) { } } -func TestAttr_NonElement(t *testing.T) { +func TestAttr_NonElement_Ugly(t *testing.T) { node := Attr(Raw("text"), "class", "x") got := node.Render(NewContext()) if got != "text" { @@ -192,7 +192,7 @@ func TestAttr_NonElement(t *testing.T) { } } -func TestUnlessNode_True(t *testing.T) { +func TestUnlessNode_True_Good(t *testing.T) { ctx := NewContext() node := Unless(func(*Context) bool { return true }, Raw("hidden")) got := node.Render(ctx) @@ -201,7 +201,7 @@ func TestUnlessNode_True(t *testing.T) { } } -func TestAttr_ThroughIfNode(t *testing.T) { +func TestAttr_ThroughIfNode_Good(t *testing.T) { ctx := NewContext() inner := El("div", Raw("content")) node := If(func(*Context) bool { return true }, inner) @@ -213,7 +213,7 @@ func TestAttr_ThroughIfNode(t *testing.T) { } } -func TestAttr_ThroughUnlessNode(t *testing.T) { +func TestAttr_ThroughUnlessNode_Good(t *testing.T) { ctx := NewContext() inner := El("div", Raw("content")) node := Unless(func(*Context) bool { return false }, inner) @@ -225,7 +225,7 @@ func TestAttr_ThroughUnlessNode(t *testing.T) { } } -func TestAttr_ThroughEntitledNode(t *testing.T) { +func TestAttr_ThroughEntitledNode_Good(t *testing.T) { ctx := NewContext() ctx.Entitlements = func(string) bool { return true } inner := El("div", Raw("content")) @@ -238,7 +238,7 @@ func TestAttr_ThroughEntitledNode(t *testing.T) { } } -func TestTextNode_WithService(t *testing.T) { +func TestTextNode_WithService_Good(t *testing.T) { svc, _ := i18n.New() ctx := NewContextWithService(svc) node := Text("hello") @@ -248,7 +248,7 @@ func TestTextNode_WithService(t *testing.T) { } } -func TestSwitchNode(t *testing.T) { +func TestSwitchNode_Good(t *testing.T) { ctx := NewContext() cases := map[string]Node{ "dark": Raw("dark theme"), diff --git a/path.go b/path.go index 509970c..dda1d7a 100644 --- a/path.go +++ b/path.go @@ -1,6 +1,7 @@ package html // ParseBlockID extracts the slot sequence from a data-block ID. +// Usage example: slots := ParseBlockID("L-0-C-0") // "L-0-C-0" → ['L', 'C'] func ParseBlockID(id string) []byte { if id == "" { diff --git a/path_test.go b/path_test.go index 0ddfa9c..f8b484b 100644 --- a/path_test.go +++ b/path_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestNestedLayout_PathChain(t *testing.T) { +func TestNestedLayout_PathChain_Good(t *testing.T) { inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f")) outer := NewLayout("HLCRF"). H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer")) @@ -25,7 +25,7 @@ func TestNestedLayout_PathChain(t *testing.T) { } } -func TestNestedLayout_DeepNesting(t *testing.T) { +func TestNestedLayout_DeepNesting_Ugly(t *testing.T) { deepest := NewLayout("C").C(Raw("deep")) middle := NewLayout("C").C(deepest) outer := NewLayout("C").C(middle) @@ -38,7 +38,7 @@ func TestNestedLayout_DeepNesting(t *testing.T) { } } -func TestBlockID(t *testing.T) { +func TestBlockID_Good(t *testing.T) { tests := []struct { path string slot byte @@ -59,7 +59,7 @@ func TestBlockID(t *testing.T) { } } -func TestParseBlockID(t *testing.T) { +func TestParseBlockID_Good(t *testing.T) { tests := []struct { id string want []byte diff --git a/pipeline.go b/pipeline.go index 183e6a8..dd33593 100644 --- a/pipeline.go +++ b/pipeline.go @@ -9,6 +9,7 @@ import ( ) // StripTags removes HTML tags from rendered output, returning plain text. +// Usage example: text := StripTags("
    Hello world
    ") // 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 { @@ -45,6 +46,7 @@ func StripTags(html string) string { // Imprint renders a node tree to HTML, strips tags, tokenises the text, // and returns a GrammarImprint — the full render-reverse pipeline. +// Usage example: imp := Imprint(Text("welcome"), NewContext()) func Imprint(node Node, ctx *Context) reversal.GrammarImprint { if ctx == nil { ctx = NewContext() @@ -58,6 +60,7 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint { // CompareVariants runs the imprint pipeline on each responsive variant independently // and returns pairwise similarity scores. Key format: "name1:name2". +// Usage example: scores := CompareVariants(NewResponsive(), NewContext()) func CompareVariants(r *Responsive, ctx *Context) map[string]float64 { if ctx == nil { ctx = NewContext() diff --git a/pipeline_test.go b/pipeline_test.go index c896859..570a4bf 100644 --- a/pipeline_test.go +++ b/pipeline_test.go @@ -8,7 +8,7 @@ import ( i18n "dappco.re/go/core/i18n" ) -func TestStripTags_Simple(t *testing.T) { +func TestStripTags_Simple_Good(t *testing.T) { got := StripTags(`
    hello
    `) want := "hello" if got != want { @@ -16,7 +16,7 @@ func TestStripTags_Simple(t *testing.T) { } } -func TestStripTags_Nested(t *testing.T) { +func TestStripTags_Nested_Good(t *testing.T) { got := StripTags(`

    Title

    `) want := "Title" if got != want { @@ -24,7 +24,7 @@ func TestStripTags_Nested(t *testing.T) { } } -func TestStripTags_MultipleRegions(t *testing.T) { +func TestStripTags_MultipleRegions_Good(t *testing.T) { got := StripTags(`
    Head
    Body
    Foot
    `) want := "Head Body Foot" if got != want { @@ -32,21 +32,21 @@ func TestStripTags_MultipleRegions(t *testing.T) { } } -func TestStripTags_Empty(t *testing.T) { +func TestStripTags_Empty_Ugly(t *testing.T) { got := StripTags("") if got != "" { t.Errorf("StripTags(\"\") = %q, want empty", got) } } -func TestStripTags_NoTags(t *testing.T) { +func TestStripTags_NoTags_Good(t *testing.T) { got := StripTags("plain text") if got != "plain text" { t.Errorf("StripTags(plain) = %q, want %q", got, "plain text") } } -func TestStripTags_Entities(t *testing.T) { +func TestStripTags_Entities_Good(t *testing.T) { got := StripTags(`<script>`) want := "<script>" if got != want { @@ -54,7 +54,7 @@ func TestStripTags_Entities(t *testing.T) { } } -func TestImprint_FromNode(t *testing.T) { +func TestImprint_FromNode_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() @@ -74,7 +74,7 @@ func TestImprint_FromNode(t *testing.T) { } } -func TestImprint_SimilarPages(t *testing.T) { +func TestImprint_SimilarPages_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() @@ -102,7 +102,7 @@ func TestImprint_SimilarPages(t *testing.T) { } } -func TestCompareVariants(t *testing.T) { +func TestCompareVariants_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() diff --git a/render.go b/render.go index 3d3a7e3..728ca7f 100644 --- a/render.go +++ b/render.go @@ -1,6 +1,7 @@ package html // Render is a convenience function that renders a node tree to HTML. +// Usage example: html := Render(El("main", Text("welcome")), NewContext()) func Render(node Node, ctx *Context) string { if ctx == nil { ctx = NewContext() diff --git a/render_test.go b/render_test.go index d8c8fcc..2b71858 100644 --- a/render_test.go +++ b/render_test.go @@ -6,7 +6,7 @@ import ( i18n "dappco.re/go/core/i18n" ) -func TestRender_FullPage(t *testing.T) { +func TestRender_FullPage_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() @@ -49,7 +49,7 @@ func TestRender_FullPage(t *testing.T) { } } -func TestRender_EntitlementGating(t *testing.T) { +func TestRender_EntitlementGating_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() @@ -77,7 +77,7 @@ func TestRender_EntitlementGating(t *testing.T) { } } -func TestRender_XSSPrevention(t *testing.T) { +func TestRender_XSSPrevention_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() diff --git a/responsive.go b/responsive.go index 2be5f2e..fa2c479 100644 --- a/responsive.go +++ b/responsive.go @@ -1,6 +1,7 @@ package html // Responsive wraps multiple Layout variants for breakpoint-aware rendering. +// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C")) // Each variant is rendered inside a container with data-variant for CSS targeting. type Responsive struct { variants []responsiveVariant @@ -12,11 +13,13 @@ type responsiveVariant struct { } // NewResponsive creates a new multi-variant responsive compositor. +// Usage example: r := NewResponsive() func NewResponsive() *Responsive { return &Responsive{} } // Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile"). +// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF")) // Variants render in insertion order. func (r *Responsive) Variant(name string, layout *Layout) *Responsive { r.variants = append(r.variants, responsiveVariant{name: name, layout: layout}) @@ -24,6 +27,7 @@ func (r *Responsive) Variant(name string, layout *Layout) *Responsive { } // Render produces HTML with each variant in a data-variant container. +// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext()) func (r *Responsive) Render(ctx *Context) string { b := newTextBuilder() for _, v := range r.variants { diff --git a/responsive_test.go b/responsive_test.go index a0821f8..62f3255 100644 --- a/responsive_test.go +++ b/responsive_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestResponsive_SingleVariant(t *testing.T) { +func TestResponsive_SingleVariant_Good(t *testing.T) { ctx := NewContext() r := NewResponsive(). Variant("desktop", NewLayout("HLCRF"). @@ -19,7 +19,7 @@ func TestResponsive_SingleVariant(t *testing.T) { } } -func TestResponsive_MultiVariant(t *testing.T) { +func TestResponsive_MultiVariant_Good(t *testing.T) { ctx := NewContext() r := NewResponsive(). Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))). @@ -35,7 +35,7 @@ func TestResponsive_MultiVariant(t *testing.T) { } } -func TestResponsive_VariantOrder(t *testing.T) { +func TestResponsive_VariantOrder_Good(t *testing.T) { ctx := NewContext() r := NewResponsive(). Variant("desktop", NewLayout("HLCRF").C(Raw("d"))). @@ -53,7 +53,7 @@ func TestResponsive_VariantOrder(t *testing.T) { } } -func TestResponsive_NestedPaths(t *testing.T) { +func TestResponsive_NestedPaths_Good(t *testing.T) { ctx := NewContext() inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if")) r := NewResponsive(). @@ -69,7 +69,7 @@ func TestResponsive_NestedPaths(t *testing.T) { } } -func TestResponsive_VariantsIndependent(t *testing.T) { +func TestResponsive_VariantsIndependent_Good(t *testing.T) { ctx := NewContext() r := NewResponsive(). Variant("a", NewLayout("HLCRF").C(Raw("content-a"))). @@ -83,6 +83,6 @@ func TestResponsive_VariantsIndependent(t *testing.T) { } } -func TestResponsive_ImplementsNode(t *testing.T) { +func TestResponsive_ImplementsNode_Ugly(t *testing.T) { var _ Node = NewResponsive() } From df5035c3c46aa7c5725a11de304de7964c03f47c Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 03:16:36 +0000 Subject: [PATCH 05/40] chore: verification pass Co-Authored-By: Virgil From 11f18a24d2c411f028a26614f6e513d2472565d8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 04:55:58 +0000 Subject: [PATCH 06/40] fix(tests): complete ax naming compliance Co-Authored-By: Virgil --- edge_test.go | 14 +++++++------- node_test.go | 6 +++--- path_test.go | 4 ++-- pipeline_test.go | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/edge_test.go b/edge_test.go index a4e8859..1409bfb 100644 --- a/edge_test.go +++ b/edge_test.go @@ -173,7 +173,7 @@ func TestAttr_UnicodeValue_Ugly(t *testing.T) { // --- Deep nesting stress tests --- -func TestLayout_DeepNesting_10Levels_Ugly(t *testing.T) { +func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) { ctx := NewContext() // Build 10 levels of nested layouts @@ -204,7 +204,7 @@ func TestLayout_DeepNesting_10Levels_Ugly(t *testing.T) { } } -func TestLayout_DeepNesting_20Levels_Ugly(t *testing.T) { +func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) { ctx := NewContext() current := NewLayout("C").C(Raw("bottom")) @@ -222,7 +222,7 @@ func TestLayout_DeepNesting_20Levels_Ugly(t *testing.T) { } } -func TestLayout_DeepNesting_MixedSlots_Ugly(t *testing.T) { +func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) { ctx := NewContext() // Alternate slot types at each level: C -> L -> C -> L -> ... @@ -241,7 +241,7 @@ func TestLayout_DeepNesting_MixedSlots_Ugly(t *testing.T) { } } -func TestEach_LargeIteration_1000_Ugly(t *testing.T) { +func TestEach_LargeIteration1000_Ugly(t *testing.T) { ctx := NewContext() items := make([]int, 1000) for i := range items { @@ -265,7 +265,7 @@ func TestEach_LargeIteration_1000_Ugly(t *testing.T) { } } -func TestEach_LargeIteration_5000_Ugly(t *testing.T) { +func TestEach_LargeIteration5000_Ugly(t *testing.T) { ctx := NewContext() items := make([]int, 5000) for i := range items { @@ -309,7 +309,7 @@ func TestEach_NestedEach_Ugly(t *testing.T) { // --- Layout variant validation --- -func TestLayout_InvalidVariant_Chars_Bad(t *testing.T) { +func TestLayout_InvalidVariantChars_Bad(t *testing.T) { ctx := NewContext() tests := []struct { @@ -341,7 +341,7 @@ func TestLayout_InvalidVariant_Chars_Bad(t *testing.T) { } } -func TestLayout_InvalidVariant_MixedValidInvalid_Bad(t *testing.T) { +func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) { ctx := NewContext() // "HXC" — H and C are valid, X is not. Only H and C should render. diff --git a/node_test.go b/node_test.go index 9aa1f33..50e1555 100644 --- a/node_test.go +++ b/node_test.go @@ -94,7 +94,7 @@ func TestIfNode_False_Good(t *testing.T) { } } -func TestUnlessNode_Good(t *testing.T) { +func TestUnlessNode_False_Good(t *testing.T) { ctx := NewContext() node := Unless(func(*Context) bool { return false }, Raw("visible")) got := node.Render(ctx) @@ -132,7 +132,7 @@ func TestEntitledNode_NoFunc_Bad(t *testing.T) { } } -func TestEachNode_Good(t *testing.T) { +func TestEachNode_Render_Good(t *testing.T) { ctx := NewContext() items := []string{"a", "b", "c"} node := Each(items, func(item string) Node { @@ -248,7 +248,7 @@ func TestTextNode_WithService_Good(t *testing.T) { } } -func TestSwitchNode_Good(t *testing.T) { +func TestSwitchNode_SelectsMatch_Good(t *testing.T) { ctx := NewContext() cases := map[string]Node{ "dark": Raw("dark theme"), diff --git a/path_test.go b/path_test.go index f8b484b..c22f55a 100644 --- a/path_test.go +++ b/path_test.go @@ -38,7 +38,7 @@ func TestNestedLayout_DeepNesting_Ugly(t *testing.T) { } } -func TestBlockID_Good(t *testing.T) { +func TestBlockID_BuildsPath_Good(t *testing.T) { tests := []struct { path string slot byte @@ -59,7 +59,7 @@ func TestBlockID_Good(t *testing.T) { } } -func TestParseBlockID_Good(t *testing.T) { +func TestParseBlockID_ExtractsSlots_Good(t *testing.T) { tests := []struct { id string want []byte diff --git a/pipeline_test.go b/pipeline_test.go index 570a4bf..9e556d2 100644 --- a/pipeline_test.go +++ b/pipeline_test.go @@ -102,7 +102,7 @@ func TestImprint_SimilarPages_Good(t *testing.T) { } } -func TestCompareVariants_Good(t *testing.T) { +func TestCompareVariants_SameContent_Good(t *testing.T) { svc, _ := i18n.New() i18n.SetDefault(svc) ctx := NewContext() From adcb98ee2fb485a16c7f63d327cfb25d2d20f9fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 07:26:33 +0000 Subject: [PATCH 07/40] =?UTF-8?q?chore:=20bump=20i18n=20v0.2.0=20=E2=86=92?= =?UTF-8?q?=20v0.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9497a5f..ec55646 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/i18n v0.2.0 + dappco.re/go/core/i18n v0.2.1 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 dappco.re/go/core/process v0.3.0 diff --git a/go.sum b/go.sum index 7b72efe..2e70fad 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo= +dappco.re/go/core/i18n v0.2.1/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= From f21562c5558938796454e492bc76c6a5f4fb1639 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 19:56:29 +0000 Subject: [PATCH 08/40] docs: add generated package specs Co-Authored-By: Virgil --- specs/cmd/codegen.md | 11 +++ specs/cmd/wasm.md | 11 +++ specs/codegen.md | 34 +++++++ specs/root.md | 225 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 specs/cmd/codegen.md create mode 100644 specs/cmd/wasm.md create mode 100644 specs/codegen.md create mode 100644 specs/root.md diff --git a/specs/cmd/codegen.md b/specs/cmd/codegen.md new file mode 100644 index 0000000..2c91894 --- /dev/null +++ b/specs/cmd/codegen.md @@ -0,0 +1,11 @@ +# main +**Import:** `dappco.re/go/core/html/cmd/codegen` +**Files:** 1 + +## Types + +None. + +## Functions + +None. diff --git a/specs/cmd/wasm.md b/specs/cmd/wasm.md new file mode 100644 index 0000000..6c6b299 --- /dev/null +++ b/specs/cmd/wasm.md @@ -0,0 +1,11 @@ +# main +**Import:** `dappco.re/go/core/html/cmd/wasm` +**Files:** 2 + +## Types + +None. + +## Functions + +None. diff --git a/specs/codegen.md b/specs/codegen.md new file mode 100644 index 0000000..dfd1114 --- /dev/null +++ b/specs/codegen.md @@ -0,0 +1,34 @@ +# codegen +**Import:** `dappco.re/go/core/html/codegen` +**Files:** 2 + +## Types + +None. + +## Functions + +### `GenerateBundle` +`func GenerateBundle(slots map[string]string) (string, error)` + +GenerateBundle produces all WC class definitions and registrations +for a set of HLCRF slot assignments. +Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"}) + +### `GenerateClass` +`func GenerateClass(tag, slot string) (string, error)` + +GenerateClass produces a JS class definition for a custom element. +Usage example: js, err := GenerateClass("nav-bar", "H") + +### `GenerateRegistration` +`func GenerateRegistration(tag, className string) string` + +GenerateRegistration produces the customElements.define() call. +Usage example: js := GenerateRegistration("nav-bar", "NavBar") + +### `TagToClassName` +`func TagToClassName(tag string) string` + +TagToClassName converts a kebab-case tag to PascalCase class name. +Usage example: className := TagToClassName("nav-bar") diff --git a/specs/root.md b/specs/root.md new file mode 100644 index 0000000..6360930 --- /dev/null +++ b/specs/root.md @@ -0,0 +1,225 @@ +# html +**Import:** `dappco.re/go/core/html` +**Files:** 13 + +## Types + +### `Context` +`type Context struct` + +Context carries rendering state through the node tree. +Usage example: ctx := NewContext() + +Fields: +- `Identity string` +- `Locale string` +- `Entitlements func(feature string) bool` +- `Data map[string]any` +- Unexported fields are present. + +Methods: +None. + +### `Layout` +`type Layout struct` + +Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions +with deterministic path-based IDs. +Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body")) + +Fields: +- No exported fields. +- Unexported fields are present. + +Methods: +- `func (l *Layout) C(nodes ...Node) *Layout` + C appends nodes to the Content (main) slot. + Usage example: NewLayout("C").C(Text("body")) +- `func (l *Layout) F(nodes ...Node) *Layout` + F appends nodes to the Footer slot. + Usage example: NewLayout("CF").F(Text("footer")) +- `func (l *Layout) H(nodes ...Node) *Layout` + H appends nodes to the Header slot. + Usage example: NewLayout("HCF").H(Text("title")) +- `func (l *Layout) L(nodes ...Node) *Layout` + L appends nodes to the Left aside slot. + Usage example: NewLayout("LC").L(Text("nav")) +- `func (l *Layout) R(nodes ...Node) *Layout` + R appends nodes to the Right aside slot. + Usage example: NewLayout("CR").R(Text("ads")) +- `func (l *Layout) Render(ctx *Context) string` + Render produces the semantic HTML for this layout. + Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext()) + Only slots present in the variant string are rendered. + +### `Node` +`type Node interface` + +Node is anything renderable. +Usage example: var n Node = El("div", Text("welcome")) + +Members: +- `Render(ctx *Context) string` + +Methods: +None. + +### `Responsive` +`type Responsive struct` + +Responsive wraps multiple Layout variants for breakpoint-aware rendering. +Usage example: r := NewResponsive().Variant("mobile", NewLayout("C")) +Each variant is rendered inside a container with data-variant for CSS targeting. + +Fields: +- No exported fields. +- Unexported fields are present. + +Methods: +- `func (r *Responsive) Render(ctx *Context) string` + Render produces HTML with each variant in a data-variant container. + Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext()) +- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive` + Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile"). + Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF")) + Variants render in insertion order. + +### `Translator` +`type Translator interface` + +Translator provides Text() lookups for a rendering context. +Usage example: ctx := NewContextWithService(myTranslator) + +The default server build uses go-i18n. Alternate builds, including WASM, +can provide any implementation with the same T() method. + +Members: +- `T(key string, args ...any) string` + +Methods: +None. + +## Functions + +### `Attr` +`func Attr(n Node, key, value string) Node` + +Attr sets an attribute on an El node. Returns the node for chaining. +Usage example: Attr(El("a", Text("docs")), "href", "/docs") +It recursively traverses through wrappers like If, Unless, and Entitled. + +### `CompareVariants` +`func CompareVariants(r *Responsive, ctx *Context) map[string]float64` + +CompareVariants runs the imprint pipeline on each responsive variant independently +and returns pairwise similarity scores. Key format: "name1:name2". +Usage example: scores := CompareVariants(NewResponsive(), NewContext()) + +### `Each` +`func Each[T any](items []T, fn func(T) Node) Node` + +Each iterates items and renders each via fn. +Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) }) + +### `EachSeq` +`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node` + +EachSeq iterates an iter.Seq and renders each via fn. +Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) }) + +### `El` +`func El(tag string, children ...Node) Node` + +El creates an HTML element node with children. +Usage example: El("section", Text("welcome")) + +### `Entitled` +`func Entitled(feature string, node Node) Node` + +Entitled renders child only when entitlement is granted. Absent, not hidden. +Usage example: Entitled("beta", Text("preview")) +If no entitlement function is set on the context, access is denied by default. + +### `If` +`func If(cond func(*Context) bool, node Node) Node` + +If renders child only when condition is true. +Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi")) + +### `Imprint` +`func Imprint(node Node, ctx *Context) reversal.GrammarImprint` + +Imprint renders a node tree to HTML, strips tags, tokenises the text, +and returns a GrammarImprint — the full render-reverse pipeline. +Usage example: imp := Imprint(Text("welcome"), NewContext()) + +### `NewContext` +`func NewContext() *Context` + +NewContext creates a new rendering context with sensible defaults. +Usage example: html := Render(Text("welcome"), NewContext()) + +### `NewContextWithService` +`func NewContextWithService(svc Translator) *Context` + +NewContextWithService creates a rendering context backed by a specific translator. +Usage example: ctx := NewContextWithService(myTranslator) + +### `NewLayout` +`func NewLayout(variant string) *Layout` + +NewLayout creates a new Layout with the given variant string. +Usage example: page := NewLayout("HLCRF") +The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C"). + +### `NewResponsive` +`func NewResponsive() *Responsive` + +NewResponsive creates a new multi-variant responsive compositor. +Usage example: r := NewResponsive() + +### `ParseBlockID` +`func ParseBlockID(id string) []byte` + +ParseBlockID extracts the slot sequence from a data-block ID. +Usage example: slots := ParseBlockID("L-0-C-0") +"L-0-C-0" → ['L', 'C'] + +### `Raw` +`func Raw(content string) Node` + +Raw creates a node that renders without escaping (escape hatch for trusted content). +Usage example: Raw("trusted") + +### `Render` +`func Render(node Node, ctx *Context) string` + +Render is a convenience function that renders a node tree to HTML. +Usage example: html := Render(El("main", Text("welcome")), NewContext()) + +### `StripTags` +`func StripTags(html string) string` + +StripTags removes HTML tags from rendered output, returning plain text. +Usage example: text := StripTags("
    Hello world
    ") +Tag boundaries are collapsed into single spaces; result is trimmed. +Does not handle script/style element content (go-html does not generate these). + +### `Switch` +`func Switch(selector func(*Context) string, cases map[string]Node) Node` + +Switch renders based on runtime selector value. +Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")}) + +### `Text` +`func Text(key string, args ...any) Node` + +Text creates a node that renders through the go-i18n grammar pipeline. +Usage example: Text("welcome", "Ada") +Output is HTML-escaped by default. Safe-by-default path. + +### `Unless` +`func Unless(cond func(*Context) bool, node Node) Node` + +Unless renders child only when condition is false. +Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome")) From 33d9e0c5166b34dd935eb7d7a4e40556e3d174e1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 21:50:33 +0000 Subject: [PATCH 09/40] docs(specs): add codegen RFC Co-Authored-By: Virgil --- specs/codegen/RFC.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 specs/codegen/RFC.md diff --git a/specs/codegen/RFC.md b/specs/codegen/RFC.md new file mode 100644 index 0000000..dfd1114 --- /dev/null +++ b/specs/codegen/RFC.md @@ -0,0 +1,34 @@ +# codegen +**Import:** `dappco.re/go/core/html/codegen` +**Files:** 2 + +## Types + +None. + +## Functions + +### `GenerateBundle` +`func GenerateBundle(slots map[string]string) (string, error)` + +GenerateBundle produces all WC class definitions and registrations +for a set of HLCRF slot assignments. +Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"}) + +### `GenerateClass` +`func GenerateClass(tag, slot string) (string, error)` + +GenerateClass produces a JS class definition for a custom element. +Usage example: js, err := GenerateClass("nav-bar", "H") + +### `GenerateRegistration` +`func GenerateRegistration(tag, className string) string` + +GenerateRegistration produces the customElements.define() call. +Usage example: js := GenerateRegistration("nav-bar", "NavBar") + +### `TagToClassName` +`func TagToClassName(tag string) string` + +TagToClassName converts a kebab-case tag to PascalCase class name. +Usage example: className := TagToClassName("nav-bar") From 0318d73a123533c8918708a5f4bf1aa1dd64c65d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 29 Mar 2026 23:10:48 +0000 Subject: [PATCH 10/40] fix(core): harden nil-safe rendering paths - guard nil receivers and nodes in core render flows - make Render() safe for nil input - add compile-time Node contract for Responsive Co-Authored-By: Virgil --- layout.go | 10 +++++++++- node.go | 32 +++++++++++++++++++++++++++++++- render.go | 3 +++ responsive.go | 11 +++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/layout.go b/layout.go index c156739..12792b0 100644 --- a/layout.go +++ b/layout.go @@ -81,6 +81,10 @@ func (l *Layout) blockID(slot byte) string { // Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext()) // Only slots present in the variant string are rendered. func (l *Layout) Render(ctx *Context) string { + if l == nil { + return "" + } + b := newTextBuilder() for i := range len(l.variant) { @@ -106,8 +110,12 @@ func (l *Layout) Render(ctx *Context) string { b.WriteString(`">`) for _, child := range children { + if child == nil { + continue + } + // Clone nested layouts before setting path (thread-safe). - if inner, ok := child.(*Layout); ok { + if inner, ok := child.(*Layout); ok && inner != nil { clone := *inner clone.path = bid + "-" b.WriteString(clone.Render(ctx)) diff --git a/node.go b/node.go index 921c53c..b9e6bac 100644 --- a/node.go +++ b/node.go @@ -85,6 +85,10 @@ func El(tag string, children ...Node) Node { // Usage example: Attr(El("a", Text("docs")), "href", "/docs") // It recursively traverses through wrappers like If, Unless, and Entitled. func Attr(n Node, key, value string) Node { + if n == nil { + return n + } + switch t := n.(type) { case *elNode: t.attrs[key] = value @@ -171,6 +175,9 @@ func If(cond func(*Context) bool, node Node) Node { } func (n *ifNode) Render(ctx *Context) string { + if n == nil || n.cond == nil || n.node == nil { + return "" + } if n.cond(ctx) { return n.node.Render(ctx) } @@ -191,6 +198,9 @@ func Unless(cond func(*Context) bool, node Node) Node { } func (n *unlessNode) Render(ctx *Context) string { + if n == nil || n.cond == nil || n.node == nil { + return "" + } if !n.cond(ctx) { return n.node.Render(ctx) } @@ -212,6 +222,9 @@ func Entitled(feature string, node Node) Node { } func (n *entitledNode) Render(ctx *Context) string { + if n == nil || n.node == nil { + return "" + } if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) { return "" } @@ -232,8 +245,17 @@ func Switch(selector func(*Context) string, cases map[string]Node) Node { } func (n *switchNode) Render(ctx *Context) string { + if n == nil || n.selector == nil { + return "" + } key := n.selector(ctx) + if n.cases == nil { + return "" + } if node, ok := n.cases[key]; ok { + if node == nil { + return "" + } return node.Render(ctx) } return "" @@ -259,9 +281,17 @@ func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node { } func (n *eachNode[T]) Render(ctx *Context) string { + if n == nil || n.fn == nil || n.items == nil { + return "" + } + b := newTextBuilder() for item := range n.items { - b.WriteString(n.fn(item).Render(ctx)) + child := n.fn(item) + if child == nil { + continue + } + b.WriteString(child.Render(ctx)) } return b.String() } diff --git a/render.go b/render.go index 728ca7f..ad14109 100644 --- a/render.go +++ b/render.go @@ -3,6 +3,9 @@ package html // Render is a convenience function that renders a node tree to HTML. // Usage example: html := Render(El("main", Text("welcome")), NewContext()) func Render(node Node, ctx *Context) string { + if node == nil { + return "" + } if ctx == nil { ctx = NewContext() } diff --git a/responsive.go b/responsive.go index fa2c479..b6fca16 100644 --- a/responsive.go +++ b/responsive.go @@ -1,5 +1,8 @@ package html +// Compile-time interface check. +var _ Node = (*Responsive)(nil) + // Responsive wraps multiple Layout variants for breakpoint-aware rendering. // Usage example: r := NewResponsive().Variant("mobile", NewLayout("C")) // Each variant is rendered inside a container with data-variant for CSS targeting. @@ -29,8 +32,16 @@ func (r *Responsive) Variant(name string, layout *Layout) *Responsive { // Render produces HTML with each variant in a data-variant container. // Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext()) func (r *Responsive) Render(ctx *Context) string { + if r == nil { + return "" + } + b := newTextBuilder() for _, v := range r.variants { + if v.layout == nil { + continue + } + b.WriteString(`
    `) From cae46f9c61439936e275d43875b43d2783a6e9c0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 29 Mar 2026 23:45:41 +0000 Subject: [PATCH 11/40] chore(codegen): remove panic exits from cli path Co-Authored-By: Virgil --- cmd/codegen/main.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index 523f781..7f26357 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -10,6 +10,7 @@ package main import ( goio "io" + "os" core "dappco.re/go/core" "dappco.re/go/core/html/codegen" @@ -44,12 +45,15 @@ func run(r goio.Reader, w goio.Writer) error { func main() { stdin, err := coreio.Local.Open("/dev/stdin") if err != nil { - panic(log.E("codegen.main", "open stdin", err)) + log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err)) + os.Exit(1) } stdout, err := coreio.Local.Create("/dev/stdout") if err != nil { - panic(log.E("codegen.main", "open stdout", err)) + _ = stdin.Close() + log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err)) + os.Exit(1) } defer func() { _ = stdin.Close() @@ -57,7 +61,7 @@ func main() { }() if err := run(stdin, stdout); err != nil { - log.Error("codegen failed", "err", err) - panic(err) + log.Error("codegen failed", "scope", "codegen.main", "err", err) + os.Exit(1) } } From c6fd13523943ecc40ce4c033ef5ac7a9bfb102aa Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 00:09:50 +0000 Subject: [PATCH 12/40] fix(core): harden remaining nil-safe rendering paths Co-Authored-By: Virgil --- node.go | 13 +++++++++++++ pipeline.go | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/node.go b/node.go index b9e6bac..cdb3056 100644 --- a/node.go +++ b/node.go @@ -60,6 +60,9 @@ func Raw(content string) Node { } func (n *rawNode) Render(_ *Context) string { + if n == nil { + return "" + } return n.content } @@ -103,6 +106,10 @@ func Attr(n Node, key, value string) Node { } func (n *elNode) Render(ctx *Context) string { + if n == nil { + return "" + } + b := newTextBuilder() b.WriteByte('<') @@ -126,6 +133,9 @@ func (n *elNode) Render(ctx *Context) string { } for i := range len(n.children) { + if n.children[i] == nil { + continue + } b.WriteString(n.children[i].Render(ctx)) } @@ -158,6 +168,9 @@ func Text(key string, args ...any) Node { } func (n *textNode) Render(ctx *Context) string { + if n == nil { + return "" + } return escapeHTML(translateText(ctx, n.key, n.args...)) } diff --git a/pipeline.go b/pipeline.go index dd33593..0e50703 100644 --- a/pipeline.go +++ b/pipeline.go @@ -51,7 +51,10 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint { if ctx == nil { ctx = NewContext() } - rendered := node.Render(ctx) + rendered := "" + if node != nil { + rendered = node.Render(ctx) + } text := StripTags(rendered) tok := reversal.NewTokeniser() tokens := tok.Tokenise(text) @@ -65,6 +68,9 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 { if ctx == nil { ctx = NewContext() } + if r == nil { + return make(map[string]float64) + } type named struct { name string @@ -73,6 +79,9 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 { var imprints []named for _, v := range r.variants { + if v.layout == nil { + continue + } imp := Imprint(v.layout, ctx) imprints = append(imprints, named{name: v.name, imp: imp}) } From 911071d2b037e764ae4b0323b749410ed4e994c7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 00:28:03 +0000 Subject: [PATCH 13/40] fix(core): harden layout and responsive nil chains Co-Authored-By: Virgil --- layout.go | 35 ++++++++++++++++++++++++++++++----- layout_test.go | 24 ++++++++++++++++++++++++ responsive.go | 3 +++ responsive_test.go | 13 +++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/layout.go b/layout.go index 12792b0..25dbc4f 100644 --- a/layout.go +++ b/layout.go @@ -37,38 +37,63 @@ func NewLayout(variant string) *Layout { } } +func (l *Layout) slotsForSlot(slot byte) []Node { + if l == nil { + return nil + } + if l.slots == nil { + l.slots = make(map[byte][]Node) + } + return l.slots[slot] +} + // H appends nodes to the Header slot. // Usage example: NewLayout("HCF").H(Text("title")) func (l *Layout) H(nodes ...Node) *Layout { - l.slots['H'] = append(l.slots['H'], nodes...) + if l == nil { + return nil + } + l.slots['H'] = append(l.slotsForSlot('H'), nodes...) return l } // L appends nodes to the Left aside slot. // Usage example: NewLayout("LC").L(Text("nav")) func (l *Layout) L(nodes ...Node) *Layout { - l.slots['L'] = append(l.slots['L'], nodes...) + if l == nil { + return nil + } + l.slots['L'] = append(l.slotsForSlot('L'), nodes...) return l } // C appends nodes to the Content (main) slot. // Usage example: NewLayout("C").C(Text("body")) func (l *Layout) C(nodes ...Node) *Layout { - l.slots['C'] = append(l.slots['C'], nodes...) + if l == nil { + return nil + } + l.slots['C'] = append(l.slotsForSlot('C'), nodes...) return l } // R appends nodes to the Right aside slot. // Usage example: NewLayout("CR").R(Text("ads")) func (l *Layout) R(nodes ...Node) *Layout { - l.slots['R'] = append(l.slots['R'], nodes...) + if l == nil { + return nil + } + l.slots['R'] = append(l.slotsForSlot('R'), nodes...) return l } // F appends nodes to the Footer slot. // Usage example: NewLayout("CF").F(Text("footer")) func (l *Layout) F(nodes ...Node) *Layout { - l.slots['F'] = append(l.slots['F'], nodes...) + if l == nil { + return nil + } + l.slots['F'] = append(l.slotsForSlot('F'), nodes...) return l } diff --git a/layout_test.go b/layout_test.go index c492d4c..e43c9e8 100644 --- a/layout_test.go +++ b/layout_test.go @@ -113,3 +113,27 @@ func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) { t.Errorf("C variant should ignore R slot content, got:\n%s", got) } } + +func TestLayout_Methods_NilLayout_Ugly(t *testing.T) { + var layout *Layout + + if layout.H(Raw("h")) != nil { + t.Fatal("expected nil layout from H on nil receiver") + } + if layout.L(Raw("l")) != nil { + t.Fatal("expected nil layout from L on nil receiver") + } + if layout.C(Raw("c")) != nil { + t.Fatal("expected nil layout from C on nil receiver") + } + if layout.R(Raw("r")) != nil { + t.Fatal("expected nil layout from R on nil receiver") + } + if layout.F(Raw("f")) != nil { + t.Fatal("expected nil layout from F on nil receiver") + } + + if got := layout.Render(NewContext()); got != "" { + t.Fatalf("nil layout render should be empty, got %q", got) + } +} diff --git a/responsive.go b/responsive.go index b6fca16..b892e8d 100644 --- a/responsive.go +++ b/responsive.go @@ -25,6 +25,9 @@ func NewResponsive() *Responsive { // Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF")) // Variants render in insertion order. func (r *Responsive) Variant(name string, layout *Layout) *Responsive { + if r == nil { + r = NewResponsive() + } r.variants = append(r.variants, responsiveVariant{name: name, layout: layout}) return r } diff --git a/responsive_test.go b/responsive_test.go index 62f3255..0d9aa41 100644 --- a/responsive_test.go +++ b/responsive_test.go @@ -86,3 +86,16 @@ func TestResponsive_VariantsIndependent_Good(t *testing.T) { func TestResponsive_ImplementsNode_Ugly(t *testing.T) { var _ Node = NewResponsive() } + +func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) { + var r *Responsive + + got := r.Variant("mobile", NewLayout("C").C(Raw("content"))) + if got == nil { + t.Fatal("expected non-nil responsive from Variant on nil receiver") + } + + if output := got.Render(NewContext()); output != `
    content
    ` { + t.Fatalf("unexpected output from nil receiver Variant path: %q", output) + } +} From 714d7adc90d1a116e2262f860a95104c4dd75e5c Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 01:18:03 +0000 Subject: [PATCH 14/40] test(responsive): align nil variant output with semantic roles Co-Authored-By: Virgil --- responsive_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/responsive_test.go b/responsive_test.go index 0d9aa41..b93fd46 100644 --- a/responsive_test.go +++ b/responsive_test.go @@ -95,7 +95,7 @@ func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) { t.Fatal("expected non-nil responsive from Variant on nil receiver") } - if output := got.Render(NewContext()); output != `
    content
    ` { + if output := got.Render(NewContext()); output != `
    content
    ` { t.Fatalf("unexpected output from nil receiver Variant path: %q", output) } } From f9f0aa197b2b9ac4669b78120f6a78a5db740681 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 05:34:47 +0000 Subject: [PATCH 15/40] fix(codegen): make bundle generation deterministic Co-Authored-By: Virgil --- codegen/codegen.go | 9 ++++++++- codegen/codegen_test.go | 24 ++++++++++++++++++++++++ docs/development.md | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/codegen/codegen.go b/codegen/codegen.go index e0183c5..1c26320 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -3,6 +3,7 @@ package codegen import ( + "sort" "text/template" core "dappco.re/go/core" @@ -76,8 +77,14 @@ func TagToClassName(tag string) string { func GenerateBundle(slots map[string]string) (string, error) { seen := make(map[string]bool) b := core.NewBuilder() + keys := make([]string, 0, len(slots)) + for slot := range slots { + keys = append(keys, slot) + } + sort.Strings(keys) - for slot, tag := range slots { + for _, slot := range keys { + tag := slots[slot] if seen[tag] { continue } diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 66359b9..98c9e75 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -3,6 +3,7 @@ package codegen import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -56,6 +57,29 @@ func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) { assert.Equal(t, 2, countSubstr(js, "customElements.define")) } +func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) { + slots := map[string]string{ + "Z": "zed-panel", + "A": "alpha-panel", + "M": "main-content", + } + + js, err := GenerateBundle(slots) + require.NoError(t, err) + + alpha := strings.Index(js, "class AlphaPanel") + main := strings.Index(js, "class MainContent") + zed := strings.Index(js, "class ZedPanel") + + assert.NotEqual(t, -1, alpha) + assert.NotEqual(t, -1, main) + assert.NotEqual(t, -1, zed) + assert.Less(t, alpha, main) + assert.Less(t, main, zed) + assert.Equal(t, 3, countSubstr(js, "extends HTMLElement")) + assert.Equal(t, 3, countSubstr(js, "customElements.define")) +} + func countSubstr(s, substr string) int { if substr == "" { return len(s) + 1 diff --git a/docs/development.md b/docs/development.md index 55af80b..7ee9fec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -293,4 +293,4 @@ func TestGenerateClass_ValidTag(t *testing.T) { - `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 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. +- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs. From 65c0dd3e27c3bafc2ae18093c18f4aaeca156da6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 19:22:02 +0000 Subject: [PATCH 16/40] fix(html): default nil render contexts Co-Authored-By: Virgil --- layout.go | 3 +++ layout_test.go | 10 ++++++++++ responsive.go | 3 +++ responsive_test.go | 11 +++++++++++ 4 files changed, 27 insertions(+) diff --git a/layout.go b/layout.go index 25dbc4f..67faeec 100644 --- a/layout.go +++ b/layout.go @@ -109,6 +109,9 @@ func (l *Layout) Render(ctx *Context) string { if l == nil { return "" } + if ctx == nil { + ctx = NewContext() + } b := newTextBuilder() diff --git a/layout_test.go b/layout_test.go index e43c9e8..704b52f 100644 --- a/layout_test.go +++ b/layout_test.go @@ -137,3 +137,13 @@ func TestLayout_Methods_NilLayout_Ugly(t *testing.T) { t.Fatalf("nil layout render should be empty, got %q", got) } } + +func TestLayout_Render_NilContext_Good(t *testing.T) { + layout := NewLayout("C").C(Raw("content")) + + got := layout.Render(nil) + want := `
    content
    ` + if got != want { + t.Fatalf("layout.Render(nil) = %q, want %q", got, want) + } +} diff --git a/responsive.go b/responsive.go index b892e8d..1f0dd06 100644 --- a/responsive.go +++ b/responsive.go @@ -38,6 +38,9 @@ func (r *Responsive) Render(ctx *Context) string { if r == nil { return "" } + if ctx == nil { + ctx = NewContext() + } b := newTextBuilder() for _, v := range r.variants { diff --git a/responsive_test.go b/responsive_test.go index b93fd46..58f67c9 100644 --- a/responsive_test.go +++ b/responsive_test.go @@ -99,3 +99,14 @@ func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) { t.Fatalf("unexpected output from nil receiver Variant path: %q", output) } } + +func TestResponsive_Render_NilContext_Good(t *testing.T) { + r := NewResponsive(). + Variant("mobile", NewLayout("C").C(Raw("content"))) + + got := r.Render(nil) + want := `
    content
    ` + if got != want { + t.Fatalf("responsive.Render(nil) = %q, want %q", got, want) + } +} From 4ae93ce36f765470d7053f1fc8184953e6b6ad71 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 19:28:53 +0000 Subject: [PATCH 17/40] feat(html): add accessibility attribute helpers Co-Authored-By: Virgil --- README.md | 2 +- docs/architecture.md | 4 +++- docs/index.md | 4 ++-- node.go | 12 ++++++++++++ node_test.go | 18 ++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4d7d8f7..507bc63 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # go-html -HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. +HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. **Module**: `forge.lthn.ai/core/go-html` **Licence**: EUPL-1.2 diff --git a/docs/architecture.md b/docs/architecture.md index 7ecd934..d4dfd71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,12 +17,14 @@ type Node interface { } ``` -All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers: +All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, two accessibility helpers, plus the `Attr()` and `Render()` helpers: | Constructor | Behaviour | |-------------|-----------| | `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. | +| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. | +| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. | | `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. | diff --git a/docs/index.md b/docs/index.md index c7b12c4..367e50a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,7 +39,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`
    ` (H), `