diff --git a/README.md b/README.md index a04c685..73639bb 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, 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()`. +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, TabIndex, AutoFocus, Role), 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 with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. **Module**: `dappco.re/go/core/html` **Licence**: EUPL-1.2 diff --git a/docs/architecture.md b/docs/architecture.md index f018689..794db60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,19 +93,19 @@ Slot letters not present in the variant string are ignored, even if nodes have b ### Deterministic Block IDs -Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs follow the pattern `{slot}-0`: +Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs use the slot letter itself: ```html -
...
-
...
- +
...
+
...
+ ``` Block IDs are constructed by simple string concatenation (no `fmt.Sprintf`) to keep the `fmt` package out of the WASM import graph. ### Nested Layouts -`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts are cloned and their internal `path` field is set to the parent's block ID as a prefix. This produces hierarchical paths: +`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts retain the parent's block ID as a prefix. This produces hierarchical paths: ```go inner := html.NewLayout("HCF"). @@ -120,7 +120,7 @@ outer := html.NewLayout("HLCRF"). F(html.Raw("foot")) ``` -The inner layout's slots render with prefixed block IDs: `L-0-H-0`, `L-0-C-0`, `L-0-F-0`. At 10 levels of nesting, the deepest block ID becomes `C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0` (tested in `edge_test.go`). +The inner layout's slots render with prefixed block IDs: `L.0`, `L.0.1`, `L.0.2`. At 10 levels of nesting, the deepest block ID becomes `C.0.0.0.0.0.0.0.0.0` (tested in `edge_test.go`). The clone-on-render approach means the original layout is never mutated. This is safe for concurrent use. @@ -141,9 +141,9 @@ html.NewLayout("HCF"). `ParseBlockID()` in `path.go` extracts the slot letter sequence from a `data-block` attribute value: ```go -ParseBlockID("L-0-C-0") // returns ['L', 'C'] -ParseBlockID("C-0-C-0-C-0") // returns ['C', 'C', 'C'] -ParseBlockID("H-0") // returns ['H'] +ParseBlockID("L.0.C.0") // returns ['L', 'C'] +ParseBlockID("C.0.C.0.C.0") // returns ['C', 'C', 'C'] +ParseBlockID("H") // returns ['H'] ParseBlockID("") // returns nil ``` diff --git a/docs/history.md b/docs/history.md index fc6ff0b..0680820 100644 --- a/docs/history.md +++ b/docs/history.md @@ -95,7 +95,7 @@ The test is skipped under `go test -short` and is guarded with `//go:build !js`. These are not regressions; they are design choices or deferred work recorded for future consideration. -1. **Invalid layout variants are silent.** `NewLayout("XYZ")` produces empty output. No error, no warning. Adding validation would require changing the return type of `NewLayout` from `*Layout` to `(*Layout, error)`, which is a breaking API change. +1. **Invalid layout variants are still non-fatal at render time.** `NewLayout("XYZ")` produces empty output, but `VariantError()` exposes the validation result without changing the `NewLayout` API. 2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed. @@ -103,7 +103,7 @@ These are not regressions; they are design choices or deferred work recorded for 4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation. -5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components. +5. **TypeScript definitions are generated.** `codegen.GenerateTypeScriptDefinitions()` and the `cmd/codegen -types` flag emit `.d.ts` companions for generated Web Components. 6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged. diff --git a/node_test.go b/node_test.go index ee534e2..60eeb1d 100644 --- a/node_test.go +++ b/node_test.go @@ -259,6 +259,60 @@ func TestElNode_Attr_Good(t *testing.T) { } } +func TestAccessibilityHelpers_Good(t *testing.T) { + ctx := NewContext() + + button := Role( + AriaLabel( + TabIndex( + AutoFocus(El("button", Raw("save"))), + 3, + ), + "Save changes", + ), + "button", + ) + + got := button.Render(ctx) + for _, want := range []string{ + `aria-label="Save changes"`, + `autofocus="autofocus"`, + `role="button"`, + `tabindex="3"`, + ">save", + } { + if !containsText(got, want) { + t.Fatalf("accessibility helpers missing %q in:\n%s", want, got) + } + } + + img := AltText(El("img"), "Profile photo") + if got := img.Render(ctx); got != `Profile photo` { + t.Fatalf("AltText() = %q, want %q", got, `Profile photo`) + } +} + +func TestSwitchNode_Good(t *testing.T) { + ctx := NewContext() + ctx.Locale = "en-GB" + + node := Switch( + func(ctx *Context) string { return ctx.Locale }, + map[string]Node{ + "en-GB": Raw("hello"), + "fr-FR": Raw("bonjour"), + }, + ) + + if got := node.Render(ctx); got != "hello" { + t.Fatalf("Switch matched case = %q, want %q", got, "hello") + } + + if got := Switch(func(*Context) string { return "de-DE" }, map[string]Node{"en-GB": Raw("hello")}).Render(ctx); got != "" { + t.Fatalf("Switch missing case = %q, want empty", got) + } +} + func TestElNode_AttrEscaping_Good(t *testing.T) { ctx := NewContext() node := Attr(El("img"), "alt", `he said "hello"`)