diff --git a/.core/build.yaml b/.core/build.yaml deleted file mode 100644 index 95a5309..0000000 --- a/.core/build.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: 1 - -project: - name: go-html - description: HTML templating engine - main: ./cmd/wasm - binary: core-html-wasm - -build: - cgo: false - flags: - - -trimpath - ldflags: - - -s - - -w - -targets: - - os: linux - arch: amd64 - - os: linux - arch: arm64 - - os: darwin - arch: arm64 - - os: windows - arch: amd64 diff --git a/.core/release.yaml b/.core/release.yaml deleted file mode 100644 index 5a1a9c5..0000000 --- a/.core/release.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: 1 - -project: - name: go-html - repository: core/go-html - -publishers: [] - -changelog: - include: - - feat - - fix - - perf - - refactor - exclude: - - chore - - docs - - style - - test - - ci diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5ab5fb7..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = tab -indent_size = 4 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{md,yml,yaml,json,txt}] -indent_style = space -indent_size = 2 diff --git a/.forgejo/workflows/security-scan.yml b/.forgejo/workflows/security-scan.yml deleted file mode 100644 index 1b5530d..0000000 --- a/.forgejo/workflows/security-scan.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Security Scan - -on: - push: - branches: [main, dev, 'feat/*'] - pull_request: - branches: [main] - -jobs: - security: - uses: core/go-devops/.forgejo/workflows/security-scan.yml@main - secrets: inherit diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index 4045779..0000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Test - -on: - push: - branches: [main, dev] - pull_request: - branches: [main] - -jobs: - test: - uses: core/go-devops/.forgejo/workflows/go-test.yml@main - with: - race: true - coverage: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4963a87..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: CI - -on: - push: - branches: [main, dev] - pull_request: - branches: [main] - pull_request_review: - types: [submitted] - -jobs: - test: - if: github.event_name != 'pull_request_review' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dAppCore/build/actions/build/core@dev - with: - go-version: "1.26" - run-vet: "true" - - auto-fix: - if: > - github.event_name == 'pull_request_review' && - github.event.review.user.login == 'coderabbitai' && - github.event.review.state == 'changes_requested' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - uses: dAppCore/build/actions/fix@dev - with: - go-version: "1.26" - - auto-merge: - if: > - github.event_name == 'pull_request_review' && - github.event.review.user.login == 'coderabbitai' && - github.event.review.state == 'approved' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - name: Merge PR - run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 509a6b1..0000000 --- a/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.idea/ -.vscode/ -*.log -.core/ -dist/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 774475b..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,22 +0,0 @@ -run: - timeout: 5m - go: "1.26" - -linters: - enable: - - govet - - errcheck - - staticcheck - - unused - - gosimple - - ineffassign - - typecheck - - gocritic - - gofmt - disable: - - exhaustive - - wrapcheck - -issues: - exclude-use-default: false - max-same-issues: 0 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 53333f7..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,68 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -Agent instructions for `go-html`. Module path: `dappco.re/go/core/html` - -## Commands - -```bash -go test ./... # Run all tests -go test -run TestName ./... # Single test -go test -short ./... # Skip slow WASM build test -go test -bench . ./... # Benchmarks -go test -bench . -benchmem ./... # Benchmarks with alloc stats -go vet ./... # Static analysis -GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ # WASM build -make wasm # WASM build with size gate -echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI -``` - -## Architecture - -See `docs/architecture.md` for full detail. Summary: - -- **Node interface**: `Render(ctx *Context) string` — El, Text, Raw, If, Unless, Each[T], EachSeq[T], Switch, Entitled -- **HLCRF Layout**: Header/Left/Content/Right/Footer compositor with ARIA roles and deterministic `data-block` IDs. Variant string (e.g. "HCF", "HLCRF", "C") controls which slots render. Layouts nest via clone-on-render (thread-safe). -- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order -- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only) -- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/` -- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip - -## Server/Client Split - -Files guarded with `//go:build !js` are excluded from WASM: - -- `pipeline.go` — Imprint/CompareVariants use `go-i18n/reversal` (server-side only) -- `cmd/wasm/register.go` — encoding/json + codegen (replaced by `cmd/codegen/` CLI) - -**Critical WASM constraint**: Never import `encoding/json`, `text/template`, or `fmt` in WASM-linked code (files without a `!js` build tag). Use string concatenation instead of `fmt.Sprintf` in `layout.go`, `node.go`, `responsive.go`, `render.go`, `path.go`, and `context.go`. The `fmt` package alone adds ~500 KB to the WASM binary. - -## Dependencies - -- `dappco.re/go/core/i18n` (replace directive → local go-i18n) -- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n; not yet migrated) -- `forge.lthn.ai/core/go-log` (indirect, via go-i18n; not yet migrated) -- Both `go-i18n` and `go-inference` must be cloned alongside this repo for builds -- Go 1.26+ required (uses `range` over integers, `iter.Seq`, `maps.Keys`, `slices.Collect`) - -## Coding Standards - -- UK English (colour, organisation, centre, behaviour, licence, serialise) -- All types annotated; use `any` not `interface{}` -- Tests use `testify` assert/require -- Licence: EUPL-1.2 — add `// SPDX-Licence-Identifier: EUPL-1.2` to new files -- Safe-by-default: HTML escaping via `html.EscapeString()` on Text nodes and attribute values, void element handling, entitlement deny-by-default -- Deterministic output: sorted attributes on El nodes, reproducible block ID paths -- Errors: use `log.E("scope", "message", err)` from `go-log`, never `fmt.Errorf` -- File I/O: use `coreio.Local` from `go-io`, never `os.ReadFile`/`os.WriteFile` -- Commits: conventional commits + `Co-Authored-By: Virgil ` - -## Test Conventions - -Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must initialise i18n before rendering: - -```go -svc, _ := i18n.New() -i18n.SetDefault(svc) -``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 6b96297..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,35 +0,0 @@ -# Contributing - -Thank you for your interest in contributing! - -## Requirements -- **Go Version**: 1.26 or higher is required. -- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended. - -## Development Workflow -1. **Testing**: Ensure all tests pass before submitting changes. - ```bash - go test ./... - ``` -2. **Code Style**: All code must follow standard Go formatting. - ```bash - gofmt -w . - go vet ./... - ``` -3. **Linting**: We use `golangci-lint` to maintain code quality. - ```bash - golangci-lint run ./... - ``` - -## Commit Message Format -We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation changes -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `chore`: Changes to the build process or auxiliary tools and libraries - -Example: `feat: add new endpoint for health check` - -## License -By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**. diff --git a/Makefile b/Makefile deleted file mode 100644 index 4fdb11f..0000000 --- a/Makefile +++ /dev/null @@ -1,30 +0,0 @@ -.PHONY: wasm test clean - -WASM_OUT := dist/go-html.wasm -# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth) -WASM_RAW_LIMIT := 3670016 -# Gzip transfer size limit: 1MB (what users actually download) -WASM_GZ_LIMIT := 1048576 - -test: - go test ./... - -wasm: $(WASM_OUT) - -$(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*') - @mkdir -p dist - GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm/ - @RAW=$$(stat -c%s "$(WASM_OUT)" 2>/dev/null || stat -f%z "$(WASM_OUT)"); \ - GZ=$$(gzip -c "$(WASM_OUT)" | wc -c); \ - echo "WASM size: $${RAW} bytes raw, $${GZ} bytes gzip"; \ - if [ "$$GZ" -gt $(WASM_GZ_LIMIT) ]; then \ - echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \ - exit 1; \ - elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \ - echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \ - else \ - echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \ - fi - -clean: - rm -rf dist/ diff --git a/README.md b/README.md deleted file mode 100644 index 73639bb..0000000 --- a/README.md +++ /dev/null @@ -1,48 +0,0 @@ -[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/html.svg)](https://pkg.go.dev/dappco.re/go/core/html) -[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) -[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) - -# 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, 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 -**Language**: Go 1.26 - -## Quick Start - -```go -import html "dappco.re/go/core/html" - -page := html.NewLayout("HCF"). - H(html.El("nav", html.Text("i18n.label.navigation"))). - C(html.El("main", - html.El("h1", html.Text("i18n.label.welcome")), - html.Each(items, func(item Item) html.Node { - return html.El("li", html.Text(item.Name)) - }), - )). - F(html.El("footer", html.Text("i18n.label.copyright"))) - -rendered := page.Render(html.NewContext("en-GB")) -``` - -## Documentation - -- [Architecture](docs/architecture.md) — node interface, HLCRF layout, responsive compositor, grammar pipeline, WASM module, codegen CLI -- [Development Guide](docs/development.md) — building, testing, WASM build, server/client split rules -- [Project History](docs/history.md) — completed phases and known limitations - -## Build & Test - -```bash -go test ./... -go test -bench . ./... -GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ -go build ./... -``` - -## Licence - -European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details. diff --git a/SESSION-BRIEF.md b/SESSION-BRIEF.md deleted file mode 100644 index 82fb5fb..0000000 --- a/SESSION-BRIEF.md +++ /dev/null @@ -1,65 +0,0 @@ -# Session Brief: core/go-html - -**Repo**: `forge.lthn.ai/core/go-html` (clone at `/tmp/core-go-html`) -**Module**: `forge.lthn.ai/core/go-html` -**Status**: Current tests pass; WASM build is within budget and codegen emits JS plus TypeScript defs -**Wiki**: https://forge.lthn.ai/core/go-html/wiki (6 pages) - -## What This Is - -HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composable node trees with: -- **Node interface**: El, Text, Raw, If, Unless, Each[T], Switch, Entitled -- **HLCRF Layout**: Header/Left/Content/Right/Footer with ARIA roles -- **Responsive**: Multi-variant breakpoint rendering -- **Pipeline**: Render → strip tags → tokenise via go-i18n/reversal → GrammarImprint -- **WASM target**: `cmd/wasm/` exposes `renderToString()` to JS -- **Codegen**: Web Component classes with closed Shadow DOM plus `.d.ts` generation - -## Current State - -| Area | Status | -|------|--------| -| Core (node, layout, responsive, pipeline) | SOLID — all tested, clean API | -| Tests | Passing | -| go vet | Clean | -| TODOs/FIXMEs | None | -| WASM build | PASS — within the 1 MB gzip gate | -| Codegen | Working — generates WC classes and `.d.ts` definitions | - -## Dependencies - -- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`) -- `github.com/stretchr/testify` v1.11.1 -- `golang.org/x/text` v0.33.0 - -## Priority Work - -No active blockers recorded here. See `docs/history.md` for the remaining design choices and deferred ideas that were captured during earlier implementation phases. - -## File Map - -``` -/tmp/core-go-html/ -├── node.go (254) + node_test.go (206) -├── layout.go (119) + layout_test.go (116) -├── pipeline.go (83) + pipeline_test.go (128) -├── responsive.go (39) + responsive_test.go (89) -├── context.go (27) -├── render.go (9) + render_test.go (97) -├── path.go (22) + path_test.go (86) -├── integration_test.go (52) -├── cmd/wasm/ -│ ├── main.go (78) — WASM entry point -│ ├── register.go (18) + register_test.go (24) -├── codegen/ -│ ├── codegen.go (90) + codegen_test.go (54) -├── go.mod -└── Makefile -``` - -## Conventions - -- UK English (colour, organisation) -- `declare(strict_types=1)` equivalent: all types annotated -- Tests: testify assert/require -- Licence: EUPL-1.2 diff --git a/bench_test.go b/bench_test.go deleted file mode 100644 index 7440203..0000000 --- a/bench_test.go +++ /dev/null @@ -1,289 +0,0 @@ -package html - -import ( - "testing" - - i18n "dappco.re/go/core/i18n" -) - -func init() { - svc, _ := i18n.New() - i18n.SetDefault(svc) -} - -// --- BenchmarkRender --- - -// buildTree creates an El tree of the given depth with branching factor 3. -func buildTree(depth int) Node { - if depth <= 0 { - return Raw("leaf") - } - children := make([]Node, 3) - for i := range children { - children[i] = buildTree(depth - 1) - } - return El("div", children...) -} - -func BenchmarkRender_Depth1(b *testing.B) { - node := buildTree(1) - ctx := NewContext() - b.ResetTimer() - for b.Loop() { - node.Render(ctx) - } -} - -func BenchmarkRender_Depth3(b *testing.B) { - node := buildTree(3) - ctx := NewContext() - b.ResetTimer() - for b.Loop() { - node.Render(ctx) - } -} - -func BenchmarkRender_Depth5(b *testing.B) { - node := buildTree(5) - ctx := NewContext() - b.ResetTimer() - for b.Loop() { - node.Render(ctx) - } -} - -func BenchmarkRender_Depth7(b *testing.B) { - node := buildTree(7) - ctx := NewContext() - b.ResetTimer() - for b.Loop() { - node.Render(ctx) - } -} - -func BenchmarkRender_FullPage(b *testing.B) { - page := NewLayout("HCF"). - H(El("h1", Text("Dashboard"))). - C( - El("div", - El("p", Text("Welcome")), - Each([]string{"Home", "Settings", "Profile"}, func(item string) Node { - return El("a", Raw(item)) - }), - ), - ). - F(El("small", Text("Footer"))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - page.Render(ctx) - } -} - -// --- BenchmarkImprint --- - -func BenchmarkImprint_Small(b *testing.B) { - page := NewLayout("HCF"). - H(El("h1", Text("Building project"))). - C(El("p", Text("Files deleted successfully"))). - F(El("small", Text("Completed"))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - Imprint(page, ctx) - } -} - -func BenchmarkImprint_Large(b *testing.B) { - items := make([]string, 20) - for i := range items { - items[i] = "Item " + itoaText(i) + " was created successfully" - } - page := NewLayout("HLCRF"). - H(El("h1", Text("Building project"))). - L(El("nav", Each(items[:5], func(s string) Node { return El("a", Text(s)) }))). - C(El("div", Each(items, func(s string) Node { return El("p", Text(s)) }))). - R(El("aside", Text("Completed rendering operation"))). - F(El("small", Text("Finished processing all items"))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - Imprint(page, ctx) - } -} - -// --- BenchmarkCompareVariants --- - -func BenchmarkCompareVariants_TwoVariants(b *testing.B) { - r := NewResponsive(). - Variant("desktop", NewLayout("HLCRF"). - H(El("h1", Text("Building project"))). - C(El("p", Text("Files deleted successfully"))). - F(El("small", Text("Completed")))). - Variant("mobile", NewLayout("HCF"). - H(El("h1", Text("Building project"))). - C(El("p", Text("Files deleted successfully"))). - F(El("small", Text("Completed")))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - CompareVariants(r, ctx) - } -} - -func BenchmarkCompareVariants_ThreeVariants(b *testing.B) { - r := NewResponsive(). - Variant("desktop", NewLayout("HLCRF"). - H(El("h1", Text("Building project"))). - L(El("nav", Text("Navigation links"))). - C(El("p", Text("Files deleted successfully"))). - R(El("aside", Text("Sidebar content"))). - F(El("small", Text("Completed")))). - Variant("tablet", NewLayout("HCF"). - H(El("h1", Text("Building project"))). - C(El("p", Text("Files deleted successfully"))). - F(El("small", Text("Completed")))). - Variant("mobile", NewLayout("C"). - C(El("p", Text("Files deleted successfully")))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - CompareVariants(r, ctx) - } -} - -// --- BenchmarkLayout --- - -func BenchmarkLayout_ContentOnly(b *testing.B) { - layout := NewLayout("C").C(Raw("content")) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - layout.Render(ctx) - } -} - -func BenchmarkLayout_HCF(b *testing.B) { - layout := NewLayout("HCF"). - H(Raw("header")).C(Raw("main")).F(Raw("footer")) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - layout.Render(ctx) - } -} - -func BenchmarkLayout_HLCRF(b *testing.B) { - layout := NewLayout("HLCRF"). - H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer")) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - layout.Render(ctx) - } -} - -func BenchmarkLayout_Nested(b *testing.B) { - inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if")) - layout := NewLayout("HLCRF"). - H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer")) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - layout.Render(ctx) - } -} - -func BenchmarkLayout_ManySlotChildren(b *testing.B) { - nodes := make([]Node, 50) - for i := range nodes { - nodes[i] = El("p", Raw("paragraph "+itoaText(i))) - } - layout := NewLayout("HLCRF"). - H(Raw("header")). - C(nodes...). - F(Raw("footer")) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - layout.Render(ctx) - } -} - -// --- BenchmarkEach --- - -func BenchmarkEach_10(b *testing.B) { - benchEach(b, 10) -} - -func BenchmarkEach_100(b *testing.B) { - benchEach(b, 100) -} - -func BenchmarkEach_1000(b *testing.B) { - benchEach(b, 1000) -} - -func benchEach(b *testing.B, n int) { - b.Helper() - items := make([]int, n) - for i := range items { - items[i] = i - } - node := Each(items, func(i int) Node { - return El("li", Raw("item-"+itoaText(i))) - }) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - node.Render(ctx) - } -} - -// --- BenchmarkResponsive --- - -func BenchmarkResponsive_ThreeVariants(b *testing.B) { - r := NewResponsive(). - Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))). - Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))). - Variant("mobile", NewLayout("C").C(Raw("c"))) - ctx := NewContext() - - b.ResetTimer() - for b.Loop() { - r.Render(ctx) - } -} - -// --- BenchmarkStripTags --- - -func BenchmarkStripTags_Short(b *testing.B) { - input := `
hello
` - for b.Loop() { - StripTags(input) - } -} - -func BenchmarkStripTags_Long(b *testing.B) { - layout := NewLayout("HLCRF"). - H(Raw("header content")).L(Raw("left sidebar")). - C(Raw("main body content with multiple words")). - R(Raw("right sidebar")).F(Raw("footer content")) - input := layout.Render(NewContext()) - - b.ResetTimer() - for b.Loop() { - StripTags(input) - } -} diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go deleted file mode 100644 index 6697c7d..0000000 --- a/cmd/codegen/main.go +++ /dev/null @@ -1,180 +0,0 @@ -//go:build !js - -// Package main provides a build-time CLI for generating Web Component bundles. -// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout. -// -// Usage: -// -// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js -// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts -// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js -package main - -import ( - "context" - "flag" - goio "io" - "os" - "os/signal" - "time" - - core "dappco.re/go/core" - "dappco.re/go/core/html/codegen" - coreio "dappco.re/go/core/io" - log "dappco.re/go/core/log" -) - -func generate(data []byte, emitTypes bool) (string, error) { - var slots map[string]string - if result := core.JSONUnmarshal(data, &slots); !result.OK { - err, _ := result.Value.(error) - return "", log.E("codegen", "invalid JSON", err) - } - - if emitTypes { - return codegen.GenerateTypeScriptDefinitions(slots), nil - } - - out, err := codegen.GenerateBundle(slots) - if err != nil { - return "", log.E("codegen", "generate bundle", err) - } - return out, nil -} - -func run(r goio.Reader, w goio.Writer, emitTypes bool) error { - data, err := goio.ReadAll(r) - if err != nil { - return log.E("codegen", "reading stdin", err) - } - - out, err := generate(data, emitTypes) - if err != nil { - return err - } - - _, err = goio.WriteString(w, out) - if err != nil { - return log.E("codegen", "writing output", err) - } - return nil -} - -func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error { - if inputPath == "" { - return log.E("codegen", "watch mode requires -input", nil) - } - if outputPath == "" { - return log.E("codegen", "watch mode requires -output", nil) - } - if pollInterval <= 0 { - pollInterval = 250 * time.Millisecond - } - - var lastInput []byte - for { - input, err := readLocalFile(inputPath) - if err != nil { - return log.E("codegen", "reading input file", err) - } - - if !sameBytes(input, lastInput) { - out, err := generate(input, emitTypes) - if err != nil { - return err - } - if err := writeLocalFile(outputPath, out); err != nil { - return log.E("codegen", "writing output file", err) - } - lastInput = append(lastInput[:0], input...) - } - - select { - case <-ctx.Done(): - if core.Is(ctx.Err(), context.Canceled) { - return nil - } - return ctx.Err() - case <-time.After(pollInterval): - } - } -} - -func readLocalFile(path string) ([]byte, error) { - f, err := coreio.Local.Open(path) - if err != nil { - return nil, err - } - defer func() { - _ = f.Close() - }() - - return goio.ReadAll(f) -} - -func writeLocalFile(path, content string) error { - f, err := coreio.Local.Create(path) - if err != nil { - return err - } - defer func() { - _ = f.Close() - }() - - _, err = goio.WriteString(f, content) - return err -} - -func sameBytes(a, b []byte) bool { - if len(a) != len(b) { - return false - } - for i := range len(a) { - if a[i] != b[i] { - return false - } - } - return true -} - -func main() { - emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes") - inputPath := flag.String("input", "", "path to the JSON slot map used by -watch") - outputPath := flag.String("output", "", "path to the generated bundle written by -watch") - emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript") - pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch") - flag.Parse() - - if *emitWatch { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - - if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil { - log.Error("codegen failed", "scope", "codegen.main", "err", err) - os.Exit(1) - } - return - } - - stdin, err := coreio.Local.Open("/dev/stdin") - if err != nil { - 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 { - _ = 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() - _ = stdout.Close() - }() - - if err := run(stdin, stdout, *emitTypes); err != nil { - log.Error("codegen failed", "scope", "codegen.main", "err", err) - os.Exit(1) - } -} diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go deleted file mode 100644 index 703d0ab..0000000 --- a/cmd/codegen/main_test.go +++ /dev/null @@ -1,179 +0,0 @@ -//go:build !js - -package main - -import ( - "context" - goio "io" - "path/filepath" - "strings" - "testing" - "time" - - core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_WritesBundle_Good(t *testing.T) { - input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) - output := core.NewBuilder() - - err := run(input, output, false) - 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, countSubstr(js, "extends HTMLElement")) -} - -func TestRun_InvalidJSON_Bad(t *testing.T) { - input := core.NewReader(`not json`) - output := core.NewBuilder() - - err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid JSON") -} - -func TestRun_InvalidTag_Bad(t *testing.T) { - input := core.NewReader(`{"H":"notag"}`) - output := core.NewBuilder() - - err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "hyphen") -} - -func TestRun_InvalidTagCharacters_Bad(t *testing.T) { - input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`) - output := core.NewBuilder() - - err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "lowercase hyphenated name") -} - -func TestRun_EmptySlots_Good(t *testing.T) { - input := core.NewReader(`{}`) - output := core.NewBuilder() - - err := run(input, output, false) - require.NoError(t, err) - assert.Empty(t, output.String()) -} - -func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) { - input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) - output := core.NewBuilder() - - err := run(input, output, true) - require.NoError(t, err) - - dts := output.String() - assert.Contains(t, dts, "declare global") - assert.Contains(t, dts, `"nav-bar": NavBar;`) - assert.Contains(t, dts, `"main-content": MainContent;`) - assert.Contains(t, dts, "export declare class NavBar extends HTMLElement") - assert.Contains(t, dts, "export declare class MainContent extends HTMLElement") -} - -func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) { - dir := t.TempDir() - inputPath := filepath.Join(dir, "slots.json") - outputPath := filepath.Join(dir, "bundle.js") - - require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`)) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan error, 1) - go func() { - done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond) - }() - - require.Eventually(t, func() bool { - got, err := readTextFile(outputPath) - if err != nil { - return false - } - return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent") - }, time.Second, 10*time.Millisecond) - - cancel() - require.NoError(t, <-done) -} - -func TestRunDaemon_MissingPaths_Bad(t *testing.T) { - err := runDaemon(context.Background(), "", "", false, time.Millisecond) - require.Error(t, err) - assert.Contains(t, err.Error(), "watch mode requires -input") -} - -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 -} - -func writeTextFile(path, content string) error { - f, err := coreio.Local.Create(path) - if err != nil { - return err - } - defer func() { - _ = f.Close() - }() - - _, err = goio.WriteString(f, content) - return err -} - -func readTextFile(path string) (string, error) { - f, err := coreio.Local.Open(path) - if err != nil { - return "", err - } - defer func() { - _ = f.Close() - }() - - data, err := goio.ReadAll(f) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go deleted file mode 100644 index 2ef7a46..0000000 --- a/cmd/wasm/main.go +++ /dev/null @@ -1,54 +0,0 @@ -//go:build js && wasm - -package main - -import ( - "syscall/js" -) - -// Keep the callback alive for the lifetime of the WASM module. -var renderToStringFunc js.Func - -// renderToString builds an HLCRF layout from JS arguments and returns HTML. -// Slot content is injected via Raw() — the caller is responsible for sanitisation. -// This is intentional: the WASM module is a rendering engine for trusted content -// produced server-side or by the application's own templates. -func renderToString(_ js.Value, args []js.Value) any { - if len(args) < 1 || args[0].Type() != js.TypeString { - return "" - } - - variant := args[0].String() - if variant == "" { - return "" - } - - locale := "" - if len(args) >= 2 && args[1].Type() == js.TypeString { - locale = args[1].String() - } - - slots := make(map[string]string) - - if len(args) >= 3 && args[2].Type() == js.TypeObject { - jsSlots := args[2] - for _, slot := range []string{"H", "L", "C", "R", "F"} { - content := jsSlots.Get(slot) - if content.Type() == js.TypeString { - slots[slot] = content.String() - } - } - } - - return renderLayout(variant, locale, slots) -} - -func main() { - renderToStringFunc = js.FuncOf(renderToString) - - api := js.Global().Get("Object").New() - api.Set("renderToString", renderToStringFunc) - js.Global().Set("gohtml", api) - - select {} -} diff --git a/cmd/wasm/main_test.go b/cmd/wasm/main_test.go deleted file mode 100644 index e1f0739..0000000 --- a/cmd/wasm/main_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build js && wasm - -package main - -import ( - "testing" - - "syscall/js" -) - -func TestRenderToString_Good(t *testing.T) { - gotAny := renderToString(js.Value{}, []js.Value{ - js.ValueOf("C"), - js.ValueOf("en-GB"), - js.ValueOf(map[string]any{"C": "hello"}), - }) - - got, ok := gotAny.(string) - if !ok { - t.Fatalf("renderToString should return string, got %T", gotAny) - } - - want := `
hello
` - if got != want { - t.Fatalf("renderToString(...) = %q, want %q", got, want) - } -} - -func TestRenderToString_EmptySlot_Good(t *testing.T) { - gotAny := renderToString(js.Value{}, []js.Value{ - js.ValueOf("C"), - js.ValueOf("en-GB"), - js.ValueOf(map[string]any{"C": ""}), - }) - - got, ok := gotAny.(string) - if !ok { - t.Fatalf("renderToString should return string, got %T", gotAny) - } - - want := `
` - if got != want { - t.Fatalf("renderToString empty slot = %q, want %q", got, want) - } -} - -func TestRenderToString_VariantTypeGuard(t *testing.T) { - if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" { - t.Fatalf("non-string variant should be empty, got %q", got) - } - - if got := renderToString(js.Value{}, []js.Value{}); got != "" { - t.Fatalf("missing variant should be empty, got %q", got) - } -} - -func TestRenderToString_LocaleTypeGuard(t *testing.T) { - gotAny := renderToString(js.Value{}, []js.Value{ - js.ValueOf("C"), - js.ValueOf(123), - js.ValueOf(map[string]any{"C": "x"}), - }) - - got, ok := gotAny.(string) - if !ok { - t.Fatalf("renderToString should return string, got %T", gotAny) - } - - want := `
x
` - if got != want { - t.Fatalf("renderToString with non-string locale = %q, want %q", got, want) - } -} diff --git a/cmd/wasm/register.go b/cmd/wasm/register.go deleted file mode 100644 index d463465..0000000 --- a/cmd/wasm/register.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !js - -package main - -import ( - core "dappco.re/go/core" - - "dappco.re/go/core/html/codegen" - log "dappco.re/go/core/log" -) - -// buildComponentJS takes a JSON slot map and returns the WC bundle JS string. -// This is the pure-Go part testable without WASM. -// Excluded from WASM builds — encoding/json and text/template are too heavy. -// Use cmd/codegen/ CLI instead for build-time generation. -func buildComponentJS(slotsJSON string) (string, error) { - var slots map[string]string - if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK { - err, _ := result.Value.(error) - return "", log.E("buildComponentJS", "unmarshal JSON", err) - } - return codegen.GenerateBundle(slots) -} - -func main() { - log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm") -} diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go deleted file mode 100644 index 9365e9e..0000000 --- a/cmd/wasm/register_test.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !js - -package main - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildComponentJS_ValidJSON_Good(t *testing.T) { - slotsJSON := `{"H":"nav-bar","C":"main-content"}` - js, err := buildComponentJS(slotsJSON) - require.NoError(t, err) - assert.Contains(t, js, "NavBar") - assert.Contains(t, js, "MainContent") - assert.Contains(t, js, "customElements.define") -} - -func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) { - _, err := buildComponentJS("not json") - assert.Error(t, err) -} diff --git a/cmd/wasm/render_layout.go b/cmd/wasm/render_layout.go deleted file mode 100644 index 848b4d9..0000000 --- a/cmd/wasm/render_layout.go +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-Licence-Identifier: EUPL-1.2 - -package main - -import html "dappco.re/go/core/html" - -// renderLayout renders an HLCRF layout from a slot map. -// -// Empty string values are meaningful: they create an explicit empty slot -// container rather than being treated as absent input. -func renderLayout(variant, locale string, slots map[string]string) string { - if variant == "" { - return "" - } - - ctx := html.NewContext() - if locale != "" { - ctx.SetLocale(locale) - } - - layout := html.NewLayout(variant) - - for _, slot := range []string{"H", "L", "C", "R", "F"} { - content, ok := slots[slot] - if !ok { - continue - } - - switch slot { - case "H": - layout.H(html.Raw(content)) - case "L": - layout.L(html.Raw(content)) - case "C": - layout.C(html.Raw(content)) - case "R": - layout.R(html.Raw(content)) - case "F": - layout.F(html.Raw(content)) - } - } - - return layout.Render(ctx) -} diff --git a/cmd/wasm/render_layout_test.go b/cmd/wasm/render_layout_test.go deleted file mode 100644 index 899b56c..0000000 --- a/cmd/wasm/render_layout_test.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !js - -// SPDX-Licence-Identifier: EUPL-1.2 - -package main - -import "testing" - -func TestRenderLayout_EmptyStringSlot_Good(t *testing.T) { - got := renderLayout("C", "en-GB", map[string]string{"C": ""}) - want := `
` - if got != want { - t.Fatalf("renderLayout with empty slot = %q, want %q", got, want) - } -} diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go deleted file mode 100644 index e8b1d96..0000000 --- a/cmd/wasm/size_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-Licence-Identifier: EUPL-1.2 -//go:build !js - -package main - -import ( - "compress/gzip" - "context" - "testing" - - core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - process "dappco.re/go/core/process" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - wasmGzLimit = 1_048_576 // 1 MB gzip transfer size limit - wasmRawLimit = 3_670_016 // 3.5 MB raw size limit -) - -func TestCmdWasm_WASMBinarySize_Good(t *testing.T) { - if testing.Short() { - t.Skip("skipping WASM build test in short mode") - } - - dir := t.TempDir() - out := core.Path(dir, "gohtml.wasm") - - factory := process.NewService(process.Options{}) - serviceValue, err := factory(core.New()) - require.NoError(t, err) - - svc, ok := serviceValue.(*process.Service) - require.True(t, ok, "process service factory returned %T", serviceValue) - - output, err := svc.RunWithOptions(context.Background(), process.RunOptions{ - Command: "go", - Args: []string{"build", "-ldflags=-s -w", "-o", out, "."}, - Dir: ".", - Env: []string{"GOOS=js", "GOARCH=wasm"}, - }) - require.NoError(t, err, "WASM build failed: %s", output) - - rawStr, err := coreio.Local.Read(out) - require.NoError(t, err) - rawBytes := []byte(rawStr) - - buf := core.NewBuilder() - gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression) - require.NoError(t, err) - _, err = gz.Write(rawBytes) - require.NoError(t, err) - require.NoError(t, gz.Close()) - - t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len()) - - assert.Less(t, buf.Len(), wasmGzLimit, - "WASM gzip size %d exceeds 1MB limit", buf.Len()) - assert.Less(t, len(rawBytes), wasmRawLimit, - "WASM raw size %d exceeds 3MB limit", len(rawBytes)) -} diff --git a/cmd/wasm/test.html b/cmd/wasm/test.html deleted file mode 100644 index 25ab038..0000000 --- a/cmd/wasm/test.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - go-html WASM Test - - - -

go-html WASM Test

-
- -
-
- -
-
- -
-
- -
- -

Raw HTML Output

-

-
-    
-    
-
-
diff --git a/codegen/bench_test.go b/codegen/bench_test.go
deleted file mode 100644
index 4a678d9..0000000
--- a/codegen/bench_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-//go:build !js
-
-package codegen
-
-import "testing"
-
-func BenchmarkGenerateClass(b *testing.B) {
-	for b.Loop() {
-		GenerateClass("photo-grid", "C")
-	}
-}
-
-func BenchmarkTagToClassName(b *testing.B) {
-	for b.Loop() {
-		TagToClassName("my-super-widget-component")
-	}
-}
-
-func BenchmarkGenerateBundle_Small(b *testing.B) {
-	slots := map[string]string{
-		"H": "nav-bar",
-		"C": "main-content",
-	}
-	b.ResetTimer()
-	for b.Loop() {
-		GenerateBundle(slots)
-	}
-}
-
-func BenchmarkGenerateBundle_Full(b *testing.B) {
-	slots := map[string]string{
-		"H": "nav-bar",
-		"L": "side-panel",
-		"C": "main-content",
-		"R": "aside-widget",
-		"F": "page-footer",
-	}
-	b.ResetTimer()
-	for b.Loop() {
-		GenerateBundle(slots)
-	}
-}
-
-func BenchmarkGenerateRegistration(b *testing.B) {
-	for b.Loop() {
-		GenerateRegistration("photo-grid", "PhotoGrid")
-	}
-}
diff --git a/codegen/codegen.go b/codegen/codegen.go
deleted file mode 100644
index c6728e5..0000000
--- a/codegen/codegen.go
+++ /dev/null
@@ -1,221 +0,0 @@
-//go:build !js
-
-package codegen
-
-import (
-	"sort"
-	"text/template"
-	"unicode"
-	"unicode/utf8"
-
-	core "dappco.re/go/core"
-	log "dappco.re/go/core/log"
-)
-
-var reservedCustomElementNames = map[string]struct{}{
-	"annotation-xml":   {},
-	"color-profile":    {},
-	"font-face":        {},
-	"font-face-src":    {},
-	"font-face-uri":    {},
-	"font-face-format": {},
-	"font-face-name":   {},
-	"missing-glyph":    {},
-}
-
-// isValidCustomElementTag reports whether tag is a valid custom element name.
-// The generator rejects values that would fail at customElements.define() time.
-func isValidCustomElementTag(tag string) bool {
-	if tag == "" || !core.Contains(tag, "-") {
-		return false
-	}
-	if !utf8.ValidString(tag) {
-		return false
-	}
-
-	if _, reserved := reservedCustomElementNames[tag]; reserved {
-		return false
-	}
-
-	first, _ := utf8.DecodeRuneInString(tag)
-	if first < 'a' || first > 'z' {
-		return false
-	}
-
-	for _, r := range tag {
-		if r >= 'A' && r <= 'Z' {
-			return false
-		}
-		switch r {
-		case 0, '/', '>', '\t', '\n', '\f', '\r', ' ':
-			return false
-		}
-	}
-
-	return true
-}
-
-type jsStringBuilder interface {
-	WriteByte(byte) error
-	WriteRune(rune) (int, error)
-	WriteString(string) (int, error)
-	String() string
-}
-
-// escapeJSStringLiteral escapes content for inclusion inside a double-quoted JS string.
-func escapeJSStringLiteral(s string) string {
-	b := core.NewBuilder()
-	appendJSStringLiteral(b, s)
-	return b.String()
-}
-
-func appendJSStringLiteral(b jsStringBuilder, s string) {
-	for _, r := range s {
-		switch r {
-		case '\\':
-			b.WriteString(`\\`)
-		case '"':
-			b.WriteString(`\"`)
-		case '\b':
-			b.WriteString(`\b`)
-		case '\f':
-			b.WriteString(`\f`)
-		case '\n':
-			b.WriteString(`\n`)
-		case '\r':
-			b.WriteString(`\r`)
-		case '\t':
-			b.WriteString(`\t`)
-		case 0x2028:
-			b.WriteString(`\u2028`)
-		case 0x2029:
-			b.WriteString(`\u2029`)
-		default:
-			if r < 0x20 {
-				appendUnicodeEscape(b, r)
-				continue
-			}
-			if r > 0xFFFF {
-				rr := r - 0x10000
-				appendUnicodeEscape(b, rune(0xD800+(rr>>10)))
-				appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF)))
-				continue
-			}
-			_, _ = b.WriteRune(r)
-		}
-	}
-}
-
-func appendUnicodeEscape(b jsStringBuilder, r rune) {
-	const hex = "0123456789ABCDEF"
-	b.WriteString(`\u`)
-	b.WriteByte(hex[(r>>12)&0xF])
-	b.WriteByte(hex[(r>>8)&0xF])
-	b.WriteByte(hex[(r>>4)&0xF])
-	b.WriteByte(hex[r&0xF])
-}
-
-// wcTemplate is the Web Component class template.
-// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
-// DOM API using trusted go-html codegen output (never user input).
-var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} extends HTMLElement {
-  #shadow;
-  constructor() {
-    super();
-    this.#shadow = this.attachShadow({ mode: "closed" });
-  }
-  connectedCallback() {
-    this.#shadow.textContent = "";
-    const slot = this.getAttribute("data-slot") || "{{.SlotLiteral}}";
-    this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } }));
-  }
-  render(html) {
-    const tpl = document.createElement("template");
-    tpl.innerHTML = html;
-    this.#shadow.textContent = "";
-    this.#shadow.appendChild(tpl.content.cloneNode(true));
-  }
-}`))
-
-// GenerateClass produces a JS class definition for a custom element.
-// Usage example: js, err := GenerateClass("nav-bar", "H")
-func GenerateClass(tag, slot string) (string, error) {
-	if !isValidCustomElementTag(tag) {
-		return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
-	}
-	b := core.NewBuilder()
-	tagLiteral := escapeJSStringLiteral(tag)
-	slotLiteral := escapeJSStringLiteral(slot)
-	err := wcTemplate.Execute(b, struct {
-		ClassName, TagLiteral, SlotLiteral string
-	}{
-		ClassName:   TagToClassName(tag),
-		TagLiteral:  tagLiteral,
-		SlotLiteral: slotLiteral,
-	})
-	if err != nil {
-		return "", log.E("codegen.GenerateClass", "template exec", err)
-	}
-	return b.String(), nil
-}
-
-// GenerateRegistration produces the customElements.define() call.
-// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
-func GenerateRegistration(tag, className string) string {
-	return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);`
-}
-
-// TagToClassName converts a custom element tag to PascalCase class name.
-// Usage example: className := TagToClassName("nav-bar")
-func TagToClassName(tag string) string {
-	b := core.NewBuilder()
-	upperNext := true
-	for _, r := range tag {
-		switch {
-		case unicode.IsLetter(r):
-			if upperNext {
-				_, _ = b.WriteRune(unicode.ToUpper(r))
-			} else {
-				_, _ = b.WriteRune(r)
-			}
-			upperNext = false
-		case unicode.IsDigit(r):
-			_, _ = b.WriteRune(r)
-			upperNext = false
-		default:
-			upperNext = true
-		}
-	}
-	return b.String()
-}
-
-// 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"})
-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 := range keys {
-		tag := slots[slot]
-		if seen[tag] {
-			continue
-		}
-		seen[tag] = true
-
-		cls, err := GenerateClass(tag, slot)
-		if err != nil {
-			return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
-		}
-		b.WriteString(cls)
-		b.WriteByte('\n')
-		b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
-		b.WriteByte('\n')
-	}
-	return b.String(), nil
-}
diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go
deleted file mode 100644
index ce2967d..0000000
--- a/codegen/codegen_test.go
+++ /dev/null
@@ -1,194 +0,0 @@
-//go:build !js
-
-package codegen
-
-import (
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestGenerateClass_ValidTag_Good(t *testing.T) {
-	js, err := GenerateClass("photo-grid", "C")
-	require.NoError(t, err)
-	assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
-	assert.Contains(t, js, "attachShadow")
-	assert.Contains(t, js, `mode: "closed"`)
-	assert.Contains(t, js, "photo-grid")
-}
-
-func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
-	_, err := GenerateClass("invalid", "C")
-	assert.Error(t, err, "custom element names must contain a hyphen")
-
-	_, err = GenerateClass("Nav-Bar", "C")
-	assert.Error(t, err, "custom element names must be lowercase")
-
-	_, err = GenerateClass("nav bar", "C")
-	assert.Error(t, err, "custom element names must reject spaces")
-
-	_, err = GenerateClass("annotation-xml", "C")
-	assert.Error(t, err, "reserved custom element names must be rejected")
-}
-
-func TestGenerateRegistration_DefinesCustomElement_Good(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 TestGenerateClass_ValidExtendedTag_Good(t *testing.T) {
-	tests := []struct {
-		tag       string
-		wantClass string
-	}{
-		{tag: "foo.bar-baz", wantClass: "FooBarBaz"},
-		{tag: "math-α", wantClass: "MathΑ"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.tag, func(t *testing.T) {
-			js, err := GenerateClass(tt.tag, "C")
-			require.NoError(t, err)
-			assert.Contains(t, js, "class "+tt.wantClass+" extends HTMLElement")
-			assert.Contains(t, js, `tag: "`+tt.tag+`"`)
-			assert.Contains(t, js, `slot = this.getAttribute("data-slot") || "C";`)
-		})
-	}
-}
-
-func TestTagToClassName_KebabCase_Good(t *testing.T) {
-	tests := []struct{ tag, want string }{
-		{"photo-grid", "PhotoGrid"},
-		{"nav-breadcrumb", "NavBreadcrumb"},
-		{"my-super-widget", "MySuperWidget"},
-		{"nav_bar", "NavBar"},
-		{"nav.bar", "NavBar"},
-		{"nav--bar", "NavBar"},
-		{"math-α", "MathΑ"},
-	}
-	for _, tt := range tests {
-		got := TagToClassName(tt.tag)
-		assert.Equal(t, tt.want, got, "TagToClassName(%q)", tt.tag)
-	}
-}
-
-func TestGenerateBundle_DeduplicatesRegistrations_Good(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, countSubstr(js, "extends HTMLElement"))
-	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 TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
-	slots := map[string]string{
-		"Z": "zed-panel",
-		"A": "alpha-panel",
-		"M": "alpha-panel",
-	}
-
-	dts := GenerateTypeScriptDefinitions(slots)
-
-	assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
-	assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
-	assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
-	assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
-	assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
-	assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
-	assert.Contains(t, dts, "export {};")
-	assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
-}
-
-func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
-	slots := map[string]string{
-		"H": "nav-bar",
-		"C": "Nav-Bar",
-		"F": "nav bar",
-	}
-
-	dts := GenerateTypeScriptDefinitions(slots)
-
-	assert.Contains(t, dts, `"nav-bar": NavBar;`)
-	assert.NotContains(t, dts, "Nav-Bar")
-	assert.NotContains(t, dts, "nav bar")
-	assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
-}
-
-func TestGenerateTypeScriptDefinitions_ValidExtendedTag_Good(t *testing.T) {
-	slots := map[string]string{
-		"H": "foo.bar-baz",
-	}
-
-	dts := GenerateTypeScriptDefinitions(slots)
-
-	assert.Contains(t, dts, `"foo.bar-baz": FooBarBaz;`)
-	assert.Contains(t, dts, `export declare class FooBarBaz extends HTMLElement`)
-}
-
-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/codegen/doc.go b/codegen/doc.go
deleted file mode 100644
index afc9b13..0000000
--- a/codegen/doc.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//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/codegen/typescript.go b/codegen/typescript.go
deleted file mode 100644
index 38aad3f..0000000
--- a/codegen/typescript.go
+++ /dev/null
@@ -1,61 +0,0 @@
-//go:build !js
-
-// SPDX-Licence-Identifier: EUPL-1.2
-
-package codegen
-
-import (
-	"sort"
-
-	core "dappco.re/go/core"
-)
-
-// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
-// a set of custom elements generated from HLCRF slot assignments.
-// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
-func GenerateTypeScriptDefinitions(slots map[string]string) string {
-	seen := make(map[string]bool)
-	declared := make(map[string]bool)
-	b := core.NewBuilder()
-
-	keys := make([]string, 0, len(slots))
-	for slot := range slots {
-		keys = append(keys, slot)
-	}
-	sort.Strings(keys)
-
-	b.WriteString("declare global {\n")
-	b.WriteString("  interface HTMLElementTagNameMap {\n")
-	for _, slot := range keys {
-		tag := slots[slot]
-		if !isValidCustomElementTag(tag) || seen[tag] {
-			continue
-		}
-		seen[tag] = true
-		b.WriteString("    \"")
-		b.WriteString(escapeJSStringLiteral(tag))
-		b.WriteString("\": ")
-		b.WriteString(TagToClassName(tag))
-		b.WriteString(";\n")
-	}
-	b.WriteString("  }\n")
-	b.WriteString("}\n\n")
-
-	for _, slot := range keys {
-		tag := slots[slot]
-		if !seen[tag] || declared[tag] {
-			continue
-		}
-		declared[tag] = true
-		b.WriteString("export declare class ")
-		b.WriteString(TagToClassName(tag))
-		b.WriteString(" extends HTMLElement {\n")
-		b.WriteString("  connectedCallback(): void;\n")
-		b.WriteString("  render(html: string): void;\n")
-		b.WriteString("}\n\n")
-	}
-
-	b.WriteString("export {};\n")
-
-	return b.String()
-}
diff --git a/context.go b/context.go
deleted file mode 100644
index 0ef7d71..0000000
--- a/context.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package html
-
-import "reflect"
-
-// 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.
-type Translator interface {
-	T(key string, args ...any) string
-}
-
-// Context carries rendering state through the node tree.
-// Usage example: ctx := NewContext()
-//
-// Metadata is an alias for Data — both fields reference the same underlying map.
-// Treat them as interchangeable; use whichever reads best in context.
-type Context struct {
-	Identity     string
-	Locale       string
-	Entitlements func(feature string) bool
-	Data         map[string]any
-	Metadata     map[string]any
-	service      Translator
-}
-
-func applyLocaleToService(svc Translator, locale string) {
-	if svc == nil || locale == "" {
-		return
-	}
-
-	if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
-		base := locale
-		for i := 0; i < len(base); i++ {
-			if base[i] == '-' || base[i] == '_' {
-				base = base[:i]
-				break
-			}
-		}
-		_ = setter.SetLanguage(base)
-	}
-}
-
-// NewContext creates a new rendering context with sensible defaults.
-// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
-func NewContext(locale ...string) *Context {
-	data := make(map[string]any)
-	ctx := &Context{
-		Data:     data,
-		Metadata: data, // alias — same underlying map
-	}
-	if len(locale) > 0 {
-		ctx.SetLocale(locale[0])
-	}
-	return ctx
-}
-
-// NewContextWithService creates a rendering context backed by a specific translator.
-// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
-func NewContextWithService(svc Translator, locale ...string) *Context {
-	ctx := NewContext(locale...)
-	ctx.SetService(svc)
-	return ctx
-}
-
-// SetService swaps the translator used by the context.
-// Usage example: ctx.SetService(myTranslator)
-func (ctx *Context) SetService(svc Translator) *Context {
-	if ctx == nil {
-		return nil
-	}
-
-	ctx.service = svc
-	applyLocaleToService(svc, ctx.Locale)
-	return ctx
-}
-
-// SetLocale updates the context locale and reapplies it to the active translator.
-// Usage example: ctx.SetLocale("en-GB")
-func (ctx *Context) SetLocale(locale string) *Context {
-	if ctx == nil {
-		return nil
-	}
-
-	ctx.Locale = locale
-	applyLocaleToService(ctx.service, ctx.Locale)
-	return ctx
-}
-
-func cloneContext(ctx *Context) *Context {
-	if ctx == nil {
-		return nil
-	}
-
-	clone := *ctx
-	// Preserve the shared Data/Metadata alias when callers pointed both fields
-	// at the same map.
-	if sameMetadataMap(ctx.Data, ctx.Metadata) {
-		shared := cloneMetadataMap(ctx.Data)
-		clone.Data = shared
-		clone.Metadata = shared
-		return &clone
-	}
-
-	clone.Data = cloneMetadataMap(ctx.Data)
-	clone.Metadata = cloneMetadataMap(ctx.Metadata)
-	return &clone
-}
-
-func cloneMetadataMap(src map[string]any) map[string]any {
-	if src == nil {
-		return nil
-	}
-
-	dst := make(map[string]any, len(src))
-	for key, value := range src {
-		dst[key] = value
-	}
-	return dst
-}
-
-func sameMetadataMap(a, b map[string]any) bool {
-	if a == nil || b == nil {
-		return a == nil && b == nil
-	}
-
-	return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer()
-}
diff --git a/context_test.go b/context_test.go
deleted file mode 100644
index a58a42e..0000000
--- a/context_test.go
+++ /dev/null
@@ -1,173 +0,0 @@
-// SPDX-Licence-Identifier: EUPL-1.2
-
-package html
-
-import (
-	"reflect"
-	"testing"
-
-	i18n "dappco.re/go/core/i18n"
-)
-
-type recordingTranslator struct {
-	key  string
-	args []any
-}
-
-func (r *recordingTranslator) T(key string, args ...any) string {
-	r.key = key
-	r.args = append(r.args[:0], args...)
-	return "translated"
-}
-
-func TestNewContext_OptionalLocale_Good(t *testing.T) {
-	ctx := NewContext("en-GB")
-
-	if ctx == nil {
-		t.Fatal("NewContext returned nil")
-	}
-	if ctx.Locale != "en-GB" {
-		t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
-	}
-	if ctx.Data == nil {
-		t.Fatal("NewContext should initialise Data")
-	}
-}
-
-func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
-	svc, _ := i18n.New()
-	ctx := NewContextWithService(svc, "fr-FR")
-
-	if ctx == nil {
-		t.Fatal("NewContextWithService returned nil")
-	}
-	if ctx.Locale != "fr-FR" {
-		t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
-	}
-	if ctx.service == nil {
-		t.Fatal("NewContextWithService should set translator service")
-	}
-}
-
-func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
-	svc, _ := i18n.New()
-	ctx := NewContextWithService(svc, "fr-FR")
-
-	got := Text("prompt.yes").Render(ctx)
-	if got != "o" {
-		t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
-	}
-}
-
-func TestTextNode_UsesMetadataAliasWhenDataNil_Good(t *testing.T) {
-	svc, _ := i18n.New()
-	i18n.SetDefault(svc)
-
-	ctx := &Context{
-		Metadata: map[string]any{"count": 1},
-	}
-
-	got := Text("i18n.count.file").Render(ctx)
-	if got != "1 file" {
-		t.Fatalf("Text with metadata-only count = %q, want %q", got, "1 file")
-	}
-}
-
-func TestTextNode_CustomTranslatorReceivesCountArgs_Good(t *testing.T) {
-	ctx := NewContextWithService(&recordingTranslator{})
-	ctx.Metadata["count"] = 3
-
-	got := Text("i18n.count.file", "ignored").Render(ctx)
-	if got != "translated" {
-		t.Fatalf("Text with custom translator = %q, want %q", got, "translated")
-	}
-
-	svc := ctx.service.(*recordingTranslator)
-	if svc.key != "i18n.count.file" {
-		t.Fatalf("custom translator key = %q, want %q", svc.key, "i18n.count.file")
-	}
-
-	wantArgs := []any{3, "ignored"}
-	if !reflect.DeepEqual(svc.args, wantArgs) {
-		t.Fatalf("custom translator args = %#v, want %#v", svc.args, wantArgs)
-	}
-}
-
-func TestTextNode_NonCountKey_DoesNotInjectCount_Good(t *testing.T) {
-	ctx := NewContextWithService(&recordingTranslator{})
-	ctx.Metadata["count"] = 3
-
-	got := Text("greeting.hello").Render(ctx)
-	if got != "translated" {
-		t.Fatalf("Text with non-count key = %q, want %q", got, "translated")
-	}
-
-	svc := ctx.service.(*recordingTranslator)
-	if len(svc.args) != 0 {
-		t.Fatalf("non-count key should not receive count args, got %#v", svc.args)
-	}
-}
-
-func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
-	svc, _ := i18n.New()
-	ctx := NewContext("fr-FR")
-
-	if got := ctx.SetService(svc); got != ctx {
-		t.Fatal("SetService should return the same context for chaining")
-	}
-
-	got := Text("prompt.yes").Render(ctx)
-	if got != "o" {
-		t.Fatalf("SetService locale translation = %q, want %q", got, "o")
-	}
-}
-
-func TestContext_SetService_NilContext_Ugly(t *testing.T) {
-	var ctx *Context
-	if got := ctx.SetService(nil); got != nil {
-		t.Fatal("SetService on nil context should return nil")
-	}
-}
-
-func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
-	svc, _ := i18n.New()
-	ctx := NewContextWithService(svc)
-
-	if got := ctx.SetLocale("fr-FR"); got != ctx {
-		t.Fatal("SetLocale should return the same context for chaining")
-	}
-
-	got := Text("prompt.yes").Render(ctx)
-	if got != "o" {
-		t.Fatalf("SetLocale translation = %q, want %q", got, "o")
-	}
-}
-
-func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
-	var ctx *Context
-	if got := ctx.SetLocale("en-GB"); got != nil {
-		t.Fatal("SetLocale on nil context should return nil")
-	}
-}
-
-func TestCloneContext_PreservesMetadataAlias_Good(t *testing.T) {
-	ctx := NewContext()
-	ctx.Data["count"] = 3
-
-	clone := cloneContext(ctx)
-	if clone == nil {
-		t.Fatal("cloneContext returned nil")
-	}
-	if clone.Data == nil || clone.Metadata == nil {
-		t.Fatal("cloneContext should preserve non-nil metadata maps")
-	}
-
-	dataPtr := reflect.ValueOf(clone.Data).Pointer()
-	metadataPtr := reflect.ValueOf(clone.Metadata).Pointer()
-	if dataPtr != metadataPtr {
-		t.Fatalf("cloneContext should keep Data and Metadata aliased, got %x and %x", dataPtr, metadataPtr)
-	}
-	if clone.Data["count"] != 3 || clone.Metadata["count"] != 3 {
-		t.Fatalf("cloneContext should copy map contents, got Data=%v Metadata=%v", clone.Data, clone.Metadata)
-	}
-}
diff --git a/doc.go b/doc.go
deleted file mode 100644
index b3c3317..0000000
--- a/doc.go
+++ /dev/null
@@ -1,12 +0,0 @@
-// 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/architecture.md b/docs/architecture.md
deleted file mode 100644
index 7018240..0000000
--- a/docs/architecture.md
+++ /dev/null
@@ -1,309 +0,0 @@
----
-title: Architecture
-description: Internals of the go-html HLCRF DOM compositor, covering the node interface, layout system, responsive wrapper, grammar pipeline, WASM module, and codegen CLI.
----
-
-# Architecture
-
-`go-html` is structured around a single interface, a layout compositor, and a server-side analysis pipeline. Everything renders to `string` -- there is no virtual DOM, no diffing, and no retained state between renders.
-
-## Node Interface
-
-Every renderable unit implements one method:
-
-```go
-type Node interface {
-    Render(ctx *Context) string
-}
-```
-
-All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four 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`, `Entitled`, `Each`, `EachSeq`, `Switch`, `Layout`, and `Responsive` 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. |
-| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. |
-| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. |
-| `Role(Node, role)` | Convenience helper that sets `role` 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. |
-| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
-| `Each[T](items, fn)` | Iterates a slice and renders each item via a mapping function. Generic over `T`. |
-| `EachSeq[T](items, fn)` | Same as `Each` but accepts an `iter.Seq[T]` instead of a slice. |
-| `Switch(selector, cases)` | Renders one of several named cases based on a runtime selector function. Returns empty string when no case matches. |
-| `Entitled(feature, Node)` | Renders the child only when the context's entitlement function grants the named feature. Deny-by-default: returns empty string when no entitlement function is set. |
-
-### Safety Guarantees
-
-- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
-- **Attribute escaping**: Attribute values are escaped with `html.EscapeString()`, handling `&`, `<`, `>`, `"`, and `'`.
-- **Deterministic output**: Attribute keys on `El` nodes are sorted alphabetically before rendering, producing identical output regardless of insertion order.
-- **Void elements**: A lookup table of 13 void elements (`area`, `base`, `br`, `col`, `embed`, `hr`, `img`, `input`, `link`, `meta`, `source`, `track`, `wbr`) ensures these never emit a closing tag.
-- **Deny-by-default entitlements**: `Entitled` returns an empty string when the context is nil, when no entitlement function is set, or when the function returns false. Content is absent from the DOM, not merely hidden.
-
-## Rendering Context
-
-The `Context` struct carries per-request state through the node tree during rendering:
-
-```go
-type Context struct {
-    Identity     string                     // e.g. user ID or session identifier
-    Locale       string                     // BCP 47 locale string
-    Entitlements func(feature string) bool  // feature gate callback
-    Data         map[string]any             // arbitrary per-request data
-    Metadata     map[string]any             // alias of Data for alternate naming
-    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 any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
-
-`Data` and `Metadata` point at the same backing map when the context is created through `NewContext()`. Use whichever name is clearer in the calling code. `SetLocale()` and `SetService()` keep the active translator in sync when either value changes.
-
-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
-
-The `Layout` type is a compositor for five named slots:
-
-| Slot Letter | Semantic Element | ARIA Role | Accessor |
-|-------------|-----------------|-----------|----------|
-| H | `
` | `banner` | `layout.H(...)` | -| L | `