test(html): add helper coverage and refresh docs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-15 01:28:56 +01:00
parent ebbd050df4
commit 0020f26582
4 changed files with 66 additions and 12 deletions

View file

@ -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

View file

@ -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
<header role="banner" data-block="H-0">...</header>
<main role="main" data-block="C-0">...</main>
<footer role="contentinfo" data-block="F-0">...</footer>
<header role="banner" data-block="H">...</header>
<main role="main" data-block="C">...</main>
<footer role="contentinfo" data-block="F">...</footer>
```
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
```

View file

@ -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.

View file

@ -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</button>",
} {
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 != `<img alt="Profile photo">` {
t.Fatalf("AltText() = %q, want %q", got, `<img alt="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"`)