Compare commits

..

No commits in common. "dev" and "v0.1.2" have entirely different histories.
dev ... v0.1.2

56 changed files with 3061 additions and 2803 deletions

View file

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

View file

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

View file

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

5
.gitignore vendored
View file

@ -1,4 +1 @@
.idea/ dist/
.vscode/
*.log
.core/

View file

@ -1,8 +1,6 @@
# CLAUDE.md # 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: `forge.lthn.ai/core/go-html`
Agent instructions for `go-html`. Module path: `dappco.re/go/core/html`
## Commands ## Commands
@ -11,10 +9,8 @@ go test ./... # Run all tes
go test -run TestName ./... # Single test go test -run TestName ./... # Single test
go test -short ./... # Skip slow WASM build test go test -short ./... # Skip slow WASM build test
go test -bench . ./... # Benchmarks go test -bench . ./... # Benchmarks
go test -bench . -benchmem ./... # Benchmarks with alloc stats
go vet ./... # Static analysis go vet ./... # Static analysis
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ # WASM build 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 echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI
``` ```
@ -22,12 +18,12 @@ echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI
See `docs/architecture.md` for full detail. Summary: 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 - **Node interface**: `Render(ctx *Context) string` — El, Text, Raw, If, Unless, Each[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). - **HLCRF Layout**: Header/Left/Content/Right/Footer compositor with ARIA roles and deterministic `data-block` IDs
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order - **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes)
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only) - **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/` - **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 - **WASM**: `cmd/wasm/` exports `renderToString()` only — 2.90 MB raw / 842 KB gzip
## Server/Client Split ## Server/Client Split
@ -36,33 +32,37 @@ Files guarded with `//go:build !js` are excluded from WASM:
- `pipeline.go` — Imprint/CompareVariants use `go-i18n/reversal` (server-side only) - `pipeline.go` — Imprint/CompareVariants use `go-i18n/reversal` (server-side only)
- `cmd/wasm/register.go` — encoding/json + codegen (replaced by `cmd/codegen/` CLI) - `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. Never import `encoding/json`, `text/template`, or `fmt` in WASM-linked code. Use string concatenation instead of `fmt.Sprintf` in `layout.go` and any other file without a `!js` guard.
## Key Files
| File | Purpose |
|------|---------|
| `node.go` | All node types (El, Text, Raw, If, Unless, Each, Switch, Entitled) |
| `layout.go` | HLCRF compositor |
| `pipeline.go` | StripTags, Imprint, CompareVariants (!js only) |
| `responsive.go` | Multi-variant breakpoint wrapper |
| `context.go` | Rendering context (Identity, Locale, Entitlements, i18n Service) |
| `codegen/codegen.go` | Web Component class generation |
| `cmd/wasm/main.go` | WASM entry point (renderToString only) |
| `cmd/codegen/main.go` | Build-time CLI for WC bundle generation |
| `cmd/wasm/size_test.go` | WASM binary size gate (< 1 MB gzip, < 3 MB raw) |
## Dependencies ## Dependencies
- `dappco.re/go/core/i18n` (replace directive → local go-i18n) - `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n; not yet migrated) - `go-i18n` and `go-inference` must be present alongside this repo for builds
- `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 ## Coding Standards
- UK English (colour, organisation, centre, behaviour, licence, serialise) - UK English (colour, organisation, centre)
- All types annotated; use `any` not `interface{}` - All types annotated
- Tests use `testify` assert/require - Tests use `testify` assert/require
- Licence: EUPL-1.2 — add `// SPDX-Licence-Identifier: EUPL-1.2` to new files - 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 - Safe-by-default: HTML escaping on Text nodes, void element handling, entitlement deny-by-default
- Deterministic output: sorted attributes on El nodes, reproducible block ID paths - Deterministic output: sorted attributes, reproducible 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 <virgil@lethean.io>` - Commits: conventional commits + `Co-Authored-By: Virgil <virgil@lethean.io>`
## Test Conventions ## Test Conventions
Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must initialise i18n before rendering: No specific suffix pattern. Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must call `i18n.SetDefault(svc)` before rendering.
```go
svc, _ := i18n.New()
i18n.SetDefault(svc)
```

View file

@ -1,8 +1,8 @@
.PHONY: wasm test clean .PHONY: wasm test clean
WASM_OUT := dist/go-html.wasm WASM_OUT := dist/go-html.wasm
# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth) # Raw size limit: 3MB (Go WASM has ~2MB runtime floor)
WASM_RAW_LIMIT := 3670016 WASM_RAW_LIMIT := 3145728
# Gzip transfer size limit: 1MB (what users actually download) # Gzip transfer size limit: 1MB (what users actually download)
WASM_GZ_LIMIT := 1048576 WASM_GZ_LIMIT := 1048576
@ -21,9 +21,9 @@ $(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \ echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
exit 1; \ exit 1; \
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \ elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \ echo "WARNING: raw binary exceeds 3MB ($${RAW} bytes) — check imports"; \
else \ else \
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \ echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3MB)"; \
fi fi
clean: clean:

View file

@ -4,7 +4,7 @@
# go-html # 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), 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` **Module**: `forge.lthn.ai/core/go-html`
**Licence**: EUPL-1.2 **Licence**: EUPL-1.2

View file

@ -1,9 +1,10 @@
package html package html
import ( import (
"fmt"
"testing" "testing"
i18n "dappco.re/go/core/i18n" i18n "forge.lthn.ai/core/go-i18n"
) )
func init() { func init() {
@ -99,7 +100,7 @@ func BenchmarkImprint_Small(b *testing.B) {
func BenchmarkImprint_Large(b *testing.B) { func BenchmarkImprint_Large(b *testing.B) {
items := make([]string, 20) items := make([]string, 20)
for i := range items { for i := range items {
items[i] = "Item " + itoaText(i) + " was created successfully" items[i] = fmt.Sprintf("Item %d was created successfully", i)
} }
page := NewLayout("HLCRF"). page := NewLayout("HLCRF").
H(El("h1", Text("Building project"))). H(El("h1", Text("Building project"))).
@ -206,7 +207,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
func BenchmarkLayout_ManySlotChildren(b *testing.B) { func BenchmarkLayout_ManySlotChildren(b *testing.B) {
nodes := make([]Node, 50) nodes := make([]Node, 50)
for i := range nodes { for i := range nodes {
nodes[i] = El("p", Raw("paragraph "+itoaText(i))) nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
} }
layout := NewLayout("HLCRF"). layout := NewLayout("HLCRF").
H(Raw("header")). H(Raw("header")).
@ -241,7 +242,7 @@ func benchEach(b *testing.B, n int) {
items[i] = i items[i] = i
} }
node := Each(items, func(i int) Node { node := Each(items, func(i int) Node {
return El("li", Raw("item-"+itoaText(i))) return El("li", Raw(fmt.Sprintf("item-%d", i)))
}) })
ctx := NewContext() ctx := NewContext()

View file

@ -1,181 +1,43 @@
//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.
// 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: // 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/ > 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 package main
import ( import (
"context" "encoding/json"
"errors" "fmt"
"flag" "io"
goio "io"
"os" "os"
"os/signal"
"time"
core "dappco.re/go/core" "forge.lthn.ai/core/go-html/codegen"
"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) { func run(r io.Reader, w io.Writer) error {
data, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("codegen: reading stdin: %w", err)
}
var slots map[string]string var slots map[string]string
if result := core.JSONUnmarshal(data, &slots); !result.OK { if err := json.Unmarshal(data, &slots); err != nil {
err, _ := result.Value.(error) return fmt.Errorf("codegen: invalid JSON: %w", err)
return "", log.E("codegen", "invalid JSON", err)
} }
if emitTypes { js, err := codegen.GenerateBundle(slots)
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 { if err != nil {
return err return err
} }
_, err = goio.WriteString(w, out) _, err = io.WriteString(w, js)
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 errors.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 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() { func main() {
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes") if err := run(os.Stdin, os.Stdout); err != nil {
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch") fmt.Fprintf(os.Stderr, "%s\n", err)
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) os.Exit(1)
} }
} }

View file

@ -1,179 +1,51 @@
//go:build !js
package main package main
import ( import (
"context" "bytes"
goio "io"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestRun_WritesBundle_Good(t *testing.T) { func TestRun_Good(t *testing.T) {
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder() var output bytes.Buffer
err := run(input, output, false) err := run(input, &output)
require.NoError(t, err) require.NoError(t, err)
js := output.String() js := output.String()
assert.Contains(t, js, "NavBar") assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent") assert.Contains(t, js, "MainContent")
assert.Contains(t, js, "customElements.define") assert.Contains(t, js, "customElements.define")
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement")) assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
} }
func TestRun_InvalidJSON_Bad(t *testing.T) { func TestRun_Bad_InvalidJSON(t *testing.T) {
input := core.NewReader(`not json`) input := strings.NewReader(`not json`)
output := core.NewBuilder() var output bytes.Buffer
err := run(input, output, false) err := run(input, &output)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON") assert.Contains(t, err.Error(), "invalid JSON")
} }
func TestRun_InvalidTag_Bad(t *testing.T) { func TestRun_Bad_InvalidTag(t *testing.T) {
input := core.NewReader(`{"H":"notag"}`) input := strings.NewReader(`{"H":"notag"}`)
output := core.NewBuilder() var output bytes.Buffer
err := run(input, output, false) err := run(input, &output)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "hyphen") assert.Contains(t, err.Error(), "hyphen")
} }
func TestRun_InvalidTagCharacters_Bad(t *testing.T) { func TestRun_Good_Empty(t *testing.T) {
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`) input := strings.NewReader(`{}`)
output := core.NewBuilder() var output bytes.Buffer
err := run(input, output, false) err := run(input, &output)
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) require.NoError(t, err)
assert.Empty(t, output.String()) 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
}

View file

@ -5,7 +5,7 @@ package main
import ( import (
"syscall/js" "syscall/js"
html "dappco.re/go/core/html" html "forge.lthn.ai/core/go-html"
) )
// renderToString builds an HLCRF layout from JS arguments and returns HTML. // renderToString builds an HLCRF layout from JS arguments and returns HTML.
@ -13,19 +13,15 @@ import (
// This is intentional: the WASM module is a rendering engine for trusted content // This is intentional: the WASM module is a rendering engine for trusted content
// produced server-side or by the application's own templates. // produced server-side or by the application's own templates.
func renderToString(_ js.Value, args []js.Value) any { func renderToString(_ js.Value, args []js.Value) any {
if len(args) < 1 || args[0].Type() != js.TypeString { if len(args) < 1 {
return "" return ""
} }
variant := args[0].String() variant := args[0].String()
if variant == "" {
return ""
}
ctx := html.NewContext() ctx := html.NewContext()
if len(args) >= 2 && args[1].Type() == js.TypeString { if len(args) >= 2 {
ctx.SetLocale(args[1].String()) ctx.Locale = args[1].String()
} }
layout := html.NewLayout(variant) layout := html.NewLayout(variant)

View file

@ -1,55 +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": "<strong>hello</strong>"}),
})
got, ok := gotAny.(string)
if !ok {
t.Fatalf("renderToString should return string, got %T", gotAny)
}
want := `<main role="main" data-block="C-0"><strong>hello</strong></main>`
if got != want {
t.Fatalf("renderToString(...) = %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 := `<main role="main" data-block="C-0">x</main>`
if got != want {
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
}
}

View file

@ -3,10 +3,10 @@
package main package main
import ( import (
core "dappco.re/go/core" "encoding/json"
"fmt"
"dappco.re/go/core/html/codegen" "forge.lthn.ai/core/go-html/codegen"
log "dappco.re/go/core/log"
) )
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string. // buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
@ -15,13 +15,12 @@ import (
// Use cmd/codegen/ CLI instead for build-time generation. // Use cmd/codegen/ CLI instead for build-time generation.
func buildComponentJS(slotsJSON string) (string, error) { func buildComponentJS(slotsJSON string) (string, error) {
var slots map[string]string var slots map[string]string
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK { if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
err, _ := result.Value.(error) return "", fmt.Errorf("registerComponents: %w", err)
return "", log.E("buildComponentJS", "unmarshal JSON", err)
} }
return codegen.GenerateBundle(slots) return codegen.GenerateBundle(slots)
} }
func main() { func main() {
log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm") fmt.Println("go-html WASM module — build with GOOS=js GOARCH=wasm")
} }

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) { func TestBuildComponentJS_Good(t *testing.T) {
slotsJSON := `{"H":"nav-bar","C":"main-content"}` slotsJSON := `{"H":"nav-bar","C":"main-content"}`
js, err := buildComponentJS(slotsJSON) js, err := buildComponentJS(slotsJSON)
require.NoError(t, err) require.NoError(t, err)
@ -18,7 +18,7 @@ func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
assert.Contains(t, js, "customElements.define") assert.Contains(t, js, "customElements.define")
} }
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) { func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
_, err := buildComponentJS("not json") _, err := buildComponentJS("not json")
assert.Error(t, err) assert.Error(t, err)
} }

View file

@ -4,13 +4,13 @@
package main package main
import ( import (
"bytes"
"compress/gzip" "compress/gzip"
"context" "os"
"os/exec"
"path/filepath"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -20,44 +20,33 @@ const (
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
) )
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) { func TestWASMBinarySize_Good(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping WASM build test in short mode") t.Skip("skipping WASM build test in short mode")
} }
dir := t.TempDir() dir := t.TempDir()
out := core.Path(dir, "gohtml.wasm") out := filepath.Join(dir, "gohtml.wasm")
factory := process.NewService(process.Options{}) cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
serviceValue, err := factory(core.New()) cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
require.NoError(t, err) output, err := cmd.CombinedOutput()
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) require.NoError(t, err, "WASM build failed: %s", output)
rawStr, err := coreio.Local.Read(out) raw, err := os.ReadFile(out)
require.NoError(t, err) require.NoError(t, err)
rawBytes := []byte(rawStr)
buf := core.NewBuilder() var buf bytes.Buffer
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression) gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
require.NoError(t, err) require.NoError(t, err)
_, err = gz.Write(rawBytes) _, err = gz.Write(raw)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, gz.Close()) require.NoError(t, gz.Close())
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len()) t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len())
assert.Less(t, buf.Len(), wasmGzLimit, assert.Less(t, buf.Len(), wasmGzLimit,
"WASM gzip size %d exceeds 1MB limit", buf.Len()) "WASM gzip size %d exceeds 1MB limit", buf.Len())
assert.Less(t, len(rawBytes), wasmRawLimit, assert.Less(t, len(raw), wasmRawLimit,
"WASM raw size %d exceeds 3MB limit", len(rawBytes)) "WASM raw size %d exceeds 3MB limit", len(raw))
} }

View file

@ -1,5 +1,3 @@
//go:build !js
package codegen package codegen
import "testing" import "testing"

View file

@ -1,40 +1,11 @@
//go:build !js
package codegen package codegen
import ( import (
"sort" "fmt"
"strings"
"text/template" "text/template"
core "dappco.re/go/core"
log "dappco.re/go/core/log"
) )
// isValidCustomElementTag reports whether tag is a safe 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 tag[0] < 'a' || tag[0] > 'z' {
return false
}
for i := range len(tag) {
ch := tag[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
case ch == '-' || ch == '.' || ch == '_':
default:
return false
}
}
return true
}
// wcTemplate is the Web Component class template. // wcTemplate is the Web Component class template.
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's // 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). // DOM API using trusted go-html codegen output (never user input).
@ -58,13 +29,12 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
}`)) }`))
// GenerateClass produces a JS class definition for a custom element. // 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) { func GenerateClass(tag, slot string) (string, error) {
if !isValidCustomElementTag(tag) { if !strings.Contains(tag, "-") {
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil) return "", fmt.Errorf("codegen: custom element tag %q must contain a hyphen", tag)
} }
b := core.NewBuilder() var b strings.Builder
err := wcTemplate.Execute(b, struct { err := wcTemplate.Execute(&b, struct {
ClassName, Tag, Slot string ClassName, Tag, Slot string
}{ }{
ClassName: TagToClassName(tag), ClassName: TagToClassName(tag),
@ -72,24 +42,22 @@ func GenerateClass(tag, slot string) (string, error) {
Slot: slot, Slot: slot,
}) })
if err != nil { if err != nil {
return "", log.E("codegen.GenerateClass", "template exec", err) return "", fmt.Errorf("codegen: template exec: %w", err)
} }
return b.String(), nil return b.String(), nil
} }
// GenerateRegistration produces the customElements.define() call. // GenerateRegistration produces the customElements.define() call.
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
func GenerateRegistration(tag, className string) string { func GenerateRegistration(tag, className string) string {
return `customElements.define("` + tag + `", ` + className + `);` return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
} }
// TagToClassName converts a kebab-case tag to PascalCase class name. // TagToClassName converts a kebab-case tag to PascalCase class name.
// Usage example: className := TagToClassName("nav-bar")
func TagToClassName(tag string) string { func TagToClassName(tag string) string {
b := core.NewBuilder() var b strings.Builder
for _, p := range core.Split(tag, "-") { for p := range strings.SplitSeq(tag, "-") {
if len(p) > 0 { if len(p) > 0 {
b.WriteString(core.Upper(p[:1])) b.WriteString(strings.ToUpper(p[:1]))
b.WriteString(p[1:]) b.WriteString(p[1:])
} }
} }
@ -98,18 +66,11 @@ func TagToClassName(tag string) string {
// GenerateBundle produces all WC class definitions and registrations // GenerateBundle produces all WC class definitions and registrations
// for a set of HLCRF slot assignments. // 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) { func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool) seen := make(map[string]bool)
b := core.NewBuilder() var b strings.Builder
keys := make([]string, 0, len(slots))
for slot := range slots {
keys = append(keys, slot)
}
sort.Strings(keys)
for _, slot := range keys { for slot, tag := range slots {
tag := slots[slot]
if seen[tag] { if seen[tag] {
continue continue
} }
@ -117,7 +78,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
cls, err := GenerateClass(tag, slot) cls, err := GenerateClass(tag, slot)
if err != nil { if err != nil {
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err) return "", err
} }
b.WriteString(cls) b.WriteString(cls)
b.WriteByte('\n') b.WriteByte('\n')

View file

@ -1,5 +1,3 @@
//go:build !js
package codegen package codegen
import ( import (
@ -10,7 +8,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestGenerateClass_ValidTag_Good(t *testing.T) { func TestGenerateClass_Good(t *testing.T) {
js, err := GenerateClass("photo-grid", "C") js, err := GenerateClass("photo-grid", "C")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, js, "class PhotoGrid extends HTMLElement") assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
@ -19,25 +17,19 @@ func TestGenerateClass_ValidTag_Good(t *testing.T) {
assert.Contains(t, js, "photo-grid") assert.Contains(t, js, "photo-grid")
} }
func TestGenerateClass_InvalidTag_Bad(t *testing.T) { func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
_, err := GenerateClass("invalid", "C") _, err := GenerateClass("invalid", "C")
assert.Error(t, err, "custom element names must contain a hyphen") 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")
} }
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) { func TestGenerateRegistration_Good(t *testing.T) {
js := GenerateRegistration("photo-grid", "PhotoGrid") js := GenerateRegistration("photo-grid", "PhotoGrid")
assert.Contains(t, js, "customElements.define") assert.Contains(t, js, "customElements.define")
assert.Contains(t, js, `"photo-grid"`) assert.Contains(t, js, `"photo-grid"`)
assert.Contains(t, js, "PhotoGrid") assert.Contains(t, js, "PhotoGrid")
} }
func TestTagToClassName_KebabCase_Good(t *testing.T) { func TestTagToClassName_Good(t *testing.T) {
tests := []struct{ tag, want string }{ tests := []struct{ tag, want string }{
{"photo-grid", "PhotoGrid"}, {"photo-grid", "PhotoGrid"},
{"nav-breadcrumb", "NavBreadcrumb"}, {"nav-breadcrumb", "NavBreadcrumb"},
@ -49,108 +41,14 @@ func TestTagToClassName_KebabCase_Good(t *testing.T) {
} }
} }
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) { func TestGenerateBundle_Good(t *testing.T) {
slots := map[string]string{ slots := map[string]string{
"H": "nav-bar", "H": "nav-bar",
"C": "main-content", "C": "main-content",
"F": "nav-bar",
} }
js, err := GenerateBundle(slots) js, err := GenerateBundle(slots)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, js, "NavBar") assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent") assert.Contains(t, js, "MainContent")
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement")) assert.Equal(t, 2, strings.Count(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 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
} }

View file

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

View file

@ -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(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()
}

View file

@ -1,81 +1,27 @@
package html package html
// Translator provides Text() lookups for a rendering context. import i18n "forge.lthn.ai/core/go-i18n"
// 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. // Context carries rendering state through the node tree.
// Usage example: ctx := NewContext()
type Context struct { type Context struct {
Identity string Identity string
Locale string Locale string
Entitlements func(feature string) bool Entitlements func(feature string) bool
Data map[string]any Data map[string]any
service Translator service *i18n.Service
}
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. // NewContext creates a new rendering context with sensible defaults.
// Usage example: html := Render(Text("welcome"), NewContext("en-GB")) func NewContext() *Context {
func NewContext(locale ...string) *Context { return &Context{
ctx := &Context{
Data: make(map[string]any), Data: make(map[string]any),
} }
if len(locale) > 0 { }
ctx.SetLocale(locale[0])
// NewContextWithService creates a rendering context backed by a specific i18n service.
func NewContextWithService(svc *i18n.Service) *Context {
return &Context{
Data: make(map[string]any),
service: svc,
} }
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
} }

View file

@ -1,90 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
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 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")
}
}

12
doc.go
View file

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

View file

@ -1,15 +1,12 @@
---
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 # 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. `go-html` is an HLCRF DOM compositor with grammar pipeline integration. It provides a pure-Go, type-safe HTML rendering library designed for server-side generation with an optional lightweight WASM client module.
Module path: `forge.lthn.ai/core/go-html`
## Node Interface ## Node Interface
Every renderable unit implements one method: All renderable units implement a single interface:
```go ```go
type Node interface { type Node interface {
@ -17,289 +14,204 @@ type Node interface {
} }
``` ```
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: Every node type is a private struct with a public constructor. The API surface is intentionally small: nine public constructors plus `Attr()` and `Render()` helpers.
| Constructor | Behaviour | | Constructor | Description |
|-------------|-----------| |-------------|-------------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. | | `El(tag, ...Node)` | HTML element with children |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. | | `Attr(Node, key, value)` | Set attribute on an El node; chainable |
| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. | | `Text(key, ...any)` | Translated, HTML-escaped text via go-i18n |
| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. | | `Raw(content)` | Unescaped trusted content |
| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. | | `If(cond, Node)` | Conditional render |
| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. | | `Unless(cond, Node)` | Inverse conditional render |
| `Role(Node, role)` | Convenience helper that sets `role` on an element node. | | `Each[T](items, fn)` | Type-safe iteration with generics |
| `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. | | `Switch(selector, cases)` | Runtime dispatch to named cases |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. | | `Entitled(feature, Node)` | Entitlement-gated render; deny-by-default |
| `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 ### Safety guarantees
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML. - `Text` nodes are always HTML-escaped. XSS via user-supplied strings fed through `Text()` is not possible.
- **Attribute escaping**: Attribute values are escaped with `html.EscapeString()`, handling `&`, `<`, `>`, `"`, and `'`. - `Raw` is an explicit escape hatch for trusted content only. Its name signals intent.
- **Deterministic output**: Attribute keys on `El` nodes are sorted alphabetically before rendering, producing identical output regardless of insertion order. - `Entitled` returns an empty string when no entitlement function is set on the context. Access is denied by default, not granted.
- **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. - `El` attributes are sorted alphabetically before output, producing deterministic HTML regardless of insertion order.
- **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. - Void elements (`br`, `img`, `input`, etc.) never emit a closing tag.
## 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
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`.
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 ## HLCRF Layout
The `Layout` type is a compositor for five named slots: The `Layout` type is a compositor for five named slots: **H**eader, **L**eft, **C**ontent, **R**ight, **F**ooter. Each slot maps to a specific semantic HTML element and ARIA role:
| Slot Letter | Semantic Element | ARIA Role | Accessor | | Slot | Element | ARIA role |
|-------------|-----------------|-----------|----------| |------|---------|-----------|
| H | `<header>` | `banner` | `layout.H(...)` | | H | `<header>` | `banner` |
| L | `<aside>` | `complementary` | `layout.L(...)` | | L | `<aside>` | `complementary` |
| C | `<main>` | `main` | `layout.C(...)` | | C | `<main>` | `main` |
| R | `<aside>` | `complementary` | `layout.R(...)` | | R | `<aside>` | `complementary` |
| F | `<footer>` | `contentinfo` | `layout.F(...)` | | F | `<footer>` | `contentinfo` |
### Variant String A layout variant string selects which slots are rendered and in which order:
The variant string passed to `NewLayout()` determines which slots render and in which order:
```go ```go
NewLayout("HLCRF") // all five slots NewLayout("HLCRF") // all five slots
NewLayout("HCF") // header, content, footer (no sidebars) NewLayout("HCF") // header, content, footer — no sidebars
NewLayout("C") // content only NewLayout("C") // content only
NewLayout("LC") // left sidebar and content
``` ```
Slot letters not present in the variant string are ignored, even if nodes have been appended to those slots. Unrecognised characters (lowercase, digits, special characters) are silently skipped -- no error is returned. Each rendered slot receives a deterministic `data-block` attribute encoding its position in the tree. The root layout produces IDs in the form `{slot}-0` (e.g., `H-0`, `C-0`). Nested layouts extend the parent's block ID as a path prefix: a `Layout` placed inside the `L` slot of a root layout will produce inner slot IDs like `L-0-H-0`, `L-0-C-0`.
### Deterministic Block IDs This path scheme is computed without `fmt.Sprintf` — using simple string concatenation — to keep `fmt` out of the WASM import graph.
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`: ### Nested layouts
```html `Layout` implements `Node`, so it can be placed inside any slot of another layout. At render time, nested layouts are cloned and their `path` field is set to the parent's block ID. This clone-on-render approach avoids shared mutation and is safe for concurrent use.
<header role="banner" data-block="H-0">...</header>
<main role="main" data-block="C-0">...</main>
<footer role="contentinfo" data-block="F-0">...</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:
```go ```go
inner := html.NewLayout("HCF"). inner := NewLayout("HCF").H(Raw("nav")).C(Raw("body")).F(Raw("links"))
H(html.Raw("nav")). outer := NewLayout("HLCRF").H(Raw("top")).L(inner).C(Raw("main")).F(Raw("foot"))
C(html.Raw("body")).
F(html.Raw("links"))
outer := html.NewLayout("HLCRF").
H(html.Raw("top")).
L(inner). // inner layout nested in the Left slot
C(html.Raw("main")).
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`). ### Fluent builder
The clone-on-render approach means the original layout is never mutated. This is safe for concurrent use. All slot methods return the `*Layout` for chaining. Multiple nodes may be appended to the same slot across multiple calls:
### Fluent Builder
All slot methods return `*Layout` for chaining. Multiple nodes can be appended to the same slot across multiple calls:
```go ```go
html.NewLayout("HCF"). NewLayout("HCF").
H(html.El("h1", html.Text("page.title"))). H(El("h1", Text("Title"))).
C(html.El("p", html.Text("intro"))). C(El("p", Text("Content")), Raw("<hr>")).
C(html.El("p", html.Text("body"))). // appends to the same C slot F(El("small", Text("Copyright")))
F(html.El("small", html.Text("footer")))
``` ```
### Block ID Parsing
`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("") // returns nil
```
This enables server-side or client-side code to locate a specific block in the rendered tree by its structural path.
## Responsive Compositor ## Responsive Compositor
`Responsive` wraps multiple named `Layout` variants for breakpoint-aware rendering: `Responsive` wraps multiple named `Layout` variants for breakpoint-aware rendering. Each variant renders inside a `<div data-variant="name">` container, giving CSS media queries or JavaScript a stable hook for show/hide logic.
```go ```go
html.NewResponsive(). NewResponsive().
Variant("desktop", html.NewLayout("HLCRF"). Variant("desktop", NewLayout("HLCRF")...).
H(html.Raw("header")).L(html.Raw("nav")).C(html.Raw("main")). Variant("tablet", NewLayout("HCF")...).
R(html.Raw("aside")).F(html.Raw("footer"))). Variant("mobile", NewLayout("C")...)
Variant("tablet", html.NewLayout("HCF").
H(html.Raw("header")).C(html.Raw("main")).F(html.Raw("footer"))).
Variant("mobile", html.NewLayout("C").
C(html.Raw("main")))
``` ```
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic. `Responsive` itself implements `Node` and may be passed to `Imprint()` for cross-variant semantic analysis.
`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly. Note: `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a layout first.
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values. ## Rendering Context
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant. `Context` carries per-request state through the entire node tree:
## Grammar Pipeline (Server-Side Only) ```go
type Context struct {
Identity string
Locale string
Entitlements func(feature string) bool
Data map[string]any
service *i18n.Service // private; set via NewContextWithService()
}
```
The grammar pipeline is excluded from WASM builds via `//go:build !js` on `pipeline.go`. It bridges the rendering layer to the semantic analysis layer. The `service` field is intentionally unexported. Custom i18n adapter injection requires `NewContextWithService(svc)`. This prevents callers from setting it inconsistently after construction.
When `ctx.service` is nil, `Text` nodes fall back to the global `i18n.T()` default service.
## Grammar Pipeline
The grammar pipeline is a server-side-only feature. It is guarded with `//go:build !js` and absent from all WASM builds.
### StripTags ### StripTags
```go `StripTags(html string) string` converts rendered HTML to plain text. Tag boundaries are collapsed to single spaces; the result is trimmed. The implementation is a single-pass rune scanner: no regex, no allocations beyond the output builder. It does not attempt to elide `<script>` or `<style>` content because `go-html` never generates those elements.
func StripTags(html string) string
```
Converts rendered HTML to plain text. Tag boundaries are collapsed into single spaces; the result is trimmed. The implementation is a single-pass rune scanner with no regular expressions and no allocations beyond the output `strings.Builder`. It does not handle `<script>` or `<style>` content because `go-html` never generates those elements.
### Imprint ### Imprint
```go `Imprint(node Node, ctx *Context) reversal.GrammarImprint` runs the full render-to-analysis pipeline:
func Imprint(node Node, ctx *Context) reversal.GrammarImprint
```
Runs the full render-to-analysis pipeline: 1. Call `node.Render(ctx)` to produce HTML.
2. Pass HTML through `StripTags` to extract plain text.
3. Pass plain text through `go-i18n/reversal.Tokeniser` to produce a token sequence.
4. Wrap tokens in a `reversal.GrammarImprint` for structural analysis.
1. Renders the node tree to HTML via `node.Render(ctx)`. The resulting `GrammarImprint` exposes `TokenCount`, `UniqueVerbs`, and a `Similar()` method for pairwise semantic similarity scoring. This bridges the rendering layer to the privacy and analytics layers of the Lethean stack.
2. Strips HTML tags via `StripTags()` to extract plain text.
3. Tokenises the text via `go-i18n/reversal.NewTokeniser().Tokenise()`.
4. Wraps tokens in a `reversal.GrammarImprint` for structural analysis.
The resulting `GrammarImprint` exposes `TokenCount`, `UniqueVerbs`, and a `Similar()` method for pairwise semantic similarity scoring.
A nil context is handled gracefully: `Imprint` creates a default context internally.
### CompareVariants ### CompareVariants
```go `CompareVariants(r *Responsive, ctx *Context) map[string]float64` runs `Imprint` on each named layout variant in a `Responsive` and returns pairwise similarity scores. Keys are `"name1:name2"`. This enables detection of semantically divergent responsive variants — for example, a mobile layout that strips critical information that appears in the desktop variant.
func CompareVariants(r *Responsive, ctx *Context) map[string]float64
```
Runs `Imprint` independently on each named layout variant in a `Responsive` and returns pairwise similarity scores. Keys are formatted as `"name1:name2"`. ## Server/Client Split
This enables detection of semantically divergent responsive variants -- for example, a mobile layout that strips critical information present in the desktop variant. Same-content variants with different layout structures (e.g. `HLCRF` vs `HCF`) score above 0.8 similarity. The binary split is enforced by Go build tags.
A single-variant `Responsive` produces an empty score map (no pairs to compare). | File | Build tag | Reason for exclusion from WASM |
|------|-----------|-------------------------------|
| `pipeline.go` | `//go:build !js` | Imports `go-i18n/reversal` (~250 KB gzip) |
| `cmd/wasm/register.go` | `//go:build !js` | Imports `encoding/json` (~200 KB gzip) and `text/template` (~125 KB gzip) |
The WASM binary includes only: node types, layout, responsive, context, render, path, and go-i18n core (translation). No codegen, no pipeline, no JSON, no templates, no `fmt`.
## WASM Module ## WASM Module
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function: The WASM entry point is `cmd/wasm/main.go`, compiled with `GOOS=js GOARCH=wasm`.
It exposes a single JavaScript function on `window.gohtml`:
```js ```js
gohtml.renderToString(variant, locale, slots) gohtml.renderToString(variant, locale, slots)
``` ```
**Parameters:** - `variant`: HLCRF variant string, e.g. `"HCF"`.
- `locale`: BCP 47 locale string for i18n, e.g. `"en-GB"`.
- `slots`: object with optional keys `H`, `L`, `C`, `R`, `F` containing HTML strings.
- `variant` (string): HLCRF variant string, e.g. `"HCF"`. 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.
- `locale` (string): BCP 47 locale string for i18n, e.g. `"en-GB"`.
- `slots` (object): Optional keys `H`, `L`, `C`, `R`, `F` containing HTML strings.
Slot content is injected via `Raw()`. The caller is responsible for sanitisation -- the WASM module is a rendering engine for trusted content produced server-side or by the application's own templates. ### Size gate
### Size Budget `cmd/wasm/size_test.go` contains `TestWASMBinarySize_Good`, a build-gated test that:
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`: 1. Builds the WASM binary with `-ldflags=-s -w`.
2. Gzip-compresses the output at best compression.
3. Asserts the compressed size is below 1,048,576 bytes (1 MB).
4. Asserts the raw size is below 3,145,728 bytes (3 MB).
| Metric | Limit | Current | This test is skipped under `go test -short`. It is guarded with `//go:build !js` so it does not run within the WASM environment itself. Current measured size: 2.90 MB raw, 842 KB gzip.
|--------|-------|---------|
| Raw binary | 3.5 MB | ~2.90 MB |
| Gzip compressed | 1 MB | ~842 KB |
The test builds the WASM binary as a subprocess and is skipped under `go test -short`. The Makefile `wasm` target performs the same build with size checking.
### Server/Client Split
The binary split is enforced by Go build tags:
| File | Build Tag | Reason for WASM Exclusion |
|------|-----------|--------------------------|
| `pipeline.go` | `!js` | Imports `go-i18n/reversal` |
| `cmd/wasm/register.go` | `!js` | Imports `encoding/json` and `text/template` |
The WASM binary includes only: node types, layout, responsive, context, render, path, and `go-i18n` core translation. No codegen, no pipeline, no JSON, no templates, no `fmt`.
## Codegen CLI ## Codegen CLI
`cmd/codegen/main.go` generates Web Component JavaScript bundles from HLCRF slot assignments at build time: `cmd/codegen/main.go` is a build-time tool for generating Web Component JavaScript bundles from HLCRF slot assignments. It reads a JSON slot map from stdin and writes the generated JS to stdout.
```bash ```bash
echo '{"H":"nav-bar","C":"main-content","F":"page-footer"}' | go run ./cmd/codegen/ > components.js echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
``` ```
The `codegen` package (`codegen/codegen.go`) generates ES2022 class definitions with closed Shadow DOM. For each custom element tag, it produces: The `codegen` package generates ES2022 class definitions with closed Shadow DOM. The generated pattern per component:
1. A class extending `HTMLElement` with a private `#shadow` field. - A class extending `HTMLElement` with a private `#shadow` field.
2. `constructor()` attaching a closed shadow root (`mode: "closed"`). - `constructor()` attaches a closed shadow root (`mode: "closed"`).
3. `connectedCallback()` dispatching a `wc-ready` custom event with the tag name and slot. - `connectedCallback()` dispatches a `wc-ready` custom event with the tag name and slot.
4. `render(html)` method that sets shadow content from a `<template>` clone. - `render(html)` sets shadow content from a `<template>` clone.
5. A `customElements.define()` registration call. - `customElements.define()` registration.
Tag names must contain a hyphen (Web Components specification requirement). `TagToClassName()` converts kebab-case to PascalCase: `nav-bar` becomes `NavBar`, `my-super-widget` becomes `MySuperWidget`. Closed Shadow DOM provides style isolation. Content is set via the DOM API, never via `innerHTML` directly on the element.
`GenerateBundle()` deduplicates tags -- if the same tag is assigned to multiple slots, only one class definition is emitted. Tag names must contain a hyphen (Web Components specification requirement). `TagToClassName()` converts kebab-case tags to PascalCase class names: `nav-bar` becomes `NavBar`.
The codegen CLI uses `encoding/json` and `text/template`, which are excluded from the WASM build. Consumers generate the JS bundle at build time and serve it as a static asset. The codegen CLI uses `encoding/json` and `text/template`, which are excluded from the WASM build. Consumers generate the JS bundle at build time, not at runtime.
## Data Flow Summary ## Block ID Path Scheme
`path.go` exports `ParseBlockID(id string) []byte`, which extracts the slot letter sequence from a `data-block` attribute value.
Format: slots are separated by `-0-`. The sequence `L-0-C-0` decodes to `['L', 'C']`, meaning the content slot of a layout nested inside the left slot.
This scheme is deterministic and human-readable. It enables server-side or client-side code to locate a specific block in the rendered tree by path.
## Dependency Graph
``` ```
Server-Side go-html
+-------------------+ ├── forge.lthn.ai/core/go-i18n (direct, all builds)
| | │ └── forge.lthn.ai/core/go-inference (indirect)
Node tree -------> Render(ctx) |-----> HTML string ├── forge.lthn.ai/core/go-i18n/reversal (server builds only, !js)
| | └── github.com/stretchr/testify (test only)
| StripTags() |-----> plain text
| |
| Imprint() |-----> GrammarImprint
| | .TokenCount
| CompareVariants()| .UniqueVerbs
| | .Similar()
+-------------------+
WASM Client
+-------------------+
| |
JS call ---------> renderToString() |-----> HTML string
(variant, locale, | |
slots object) +-------------------+
Build Time
+-------------------+
| |
JSON slot map ---> cmd/codegen/ |-----> Web Component JS
(stdin) | | (stdout)
+-------------------+
``` ```
Both `go-i18n` and `go-html` are developed in parallel. The `go.mod` uses a `replace` directive pointing to `../go-i18n`. Both repositories must be present on the local filesystem for builds and tests.

View file

@ -1,48 +1,36 @@
---
title: Development Guide
description: How to build, test, and contribute to go-html, including WASM builds, benchmarks, coding standards, and test patterns.
---
# Development Guide # Development Guide
## Prerequisites ## Prerequisites
- **Go 1.26** or later. The module uses Go 1.26 features (e.g. `range` over integers, `iter.Seq`). - Go 1.25 or later (Go workspace required).
- **go-i18n** cloned alongside this repository at `../go-i18n` relative to the repo root. The `go.mod` `replace` directive points there. - `go-i18n` repository cloned alongside this one: `../go-i18n` relative to the repository root. The `go.mod` `replace` directive points there.
- **go-inference** also resolved via `replace` directive at `../go-inference`. It is an indirect dependency pulled in by `go-i18n`. - `go-inference` also resolved via `replace` directive at `../go-inference`. It is an indirect dependency pulled in by `go-i18n`.
- **Go workspace** (`go.work`): this module is part of a shared workspace. Run `go work sync` after cloning. - `testify` is the only external test dependency; it is fetched by the Go module system.
No additional tools are required for server-side development. WASM builds require the standard Go cross-compilation support (`GOOS=js GOARCH=wasm`), included in all official Go distributions. No additional tools are required for server-side development. WASM builds require a standard Go installation with `GOOS=js GOARCH=wasm` cross-compilation support, which is included in all official Go distributions.
## Directory Layout ## Directory Layout
``` ```
go-html/ go-html/
node.go Node interface and all node types ├── node.go Node interface and all node types
layout.go HLCRF compositor ├── layout.go HLCRF compositor
pipeline.go StripTags, Imprint, CompareVariants (!js only) ├── pipeline.go StripTags, Imprint, CompareVariants (!js only)
responsive.go Multi-variant breakpoint wrapper ├── responsive.go Multi-variant breakpoint wrapper
context.go Rendering context ├── context.go Rendering context
render.go Render() convenience function ├── render.go Render() convenience function
path.go ParseBlockID() for data-block path decoding ├── path.go ParseBlockID() for data-block path decoding
codegen/ ├── codegen/
codegen.go Web Component JS generation (server-side) │ └── codegen.go Web Component JS generation (server-side)
codegen_test.go Tests for codegen ├── cmd/
bench_test.go Codegen benchmarks │ ├── codegen/
cmd/ │ │ └── main.go Build-time CLI (stdin JSON → stdout JS)
codegen/ │ └── wasm/
main.go Build-time CLI (stdin JSON, stdout JS) │ ├── main.go WASM entry point (js+wasm build only)
main_test.go CLI integration tests │ ├── register.go buildComponentJS helper (!js only)
wasm/ │ └── size_test.go WASM binary size gate test (!js only)
main.go WASM entry point (js+wasm build only) └── docs/
register.go buildComponentJS helper (!js only) └── plans/ Phase design documents (historical)
register_test.go Tests for register helper
size_test.go WASM binary size gate test (!js only)
dist/ WASM build output (gitignored)
docs/ This documentation
plans/ Phase design documents (historical)
Makefile WASM build with size checking
.core/build.yaml Build system configuration
``` ```
## Running Tests ## Running Tests
@ -52,32 +40,18 @@ go-html/
go test ./... go test ./...
# Single test by name # Single test by name
go test -run TestElNode_Render . go test -run TestWASMBinarySize_Good ./cmd/wasm/
# Skip the slow WASM build test # Skip slow WASM build test
go test -short ./... go test -short ./...
# Verbose output # Tests with verbose output
go test -v ./... go test -v ./...
# Tests for a specific package
go test ./codegen/
go test ./cmd/codegen/
go test ./cmd/wasm/
``` ```
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. Tests use `testify` assert and require helpers. Test names follow Go's standard `TestFunctionName` convention. Subtests use `t.Run()` with descriptive names.
### Test Dependencies The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess and is therefore slow. It is skipped automatically under `-short`. It is also guarded with `//go:build !js` so it cannot run under `GOARCH=wasm`.
Tests use the `testify` library (`assert` and `require` packages). Integration tests and benchmarks that exercise `Text` nodes must initialise the `go-i18n` default service before rendering:
```go
svc, _ := i18n.New()
i18n.SetDefault(svc)
```
The `bench_test.go` file does this in an `init()` function. Individual integration tests do so explicitly.
## Benchmarks ## Benchmarks
@ -86,30 +60,26 @@ The `bench_test.go` file does this in an `init()` function. Individual integrati
go test -bench . ./... go test -bench . ./...
# Specific benchmark # Specific benchmark
go test -bench BenchmarkRender_FullPage . go test -bench BenchmarkRender_FullPage ./...
# With memory allocation statistics # With memory allocations
go test -bench . -benchmem ./... go test -bench . -benchmem ./...
# Extended benchmark duration # Fixed iteration count
go test -bench . -benchtime=5s ./... go test -bench . -benchtime=5s ./...
``` ```
Available benchmark groups: Benchmarks are organised by operation:
| Group | Variants | | Group | Variants |
|-------|----------| |-------|---------|
| `BenchmarkRender_*` | Depth 1, 3, 5, 7 element trees; full page with layout | | `BenchmarkRender_*` | Depth 1, 3, 5, 7 trees; full page |
| `BenchmarkLayout_*` | Content-only, HCF, HLCRF, nested, 50-child slot | | `BenchmarkLayout_*` | Content-only, HCF, HLCRF, nested, many children |
| `BenchmarkEach_*` | 10, 100, 1000 items | | `BenchmarkEach_*` | 10, 100, 1000 items |
| `BenchmarkResponsive_*` | Three-variant compositor | | `BenchmarkResponsive_*` | Three-variant compositor |
| `BenchmarkStripTags_*` | Short and long HTML inputs | | `BenchmarkStripTags_*` | Short and long HTML inputs |
| `BenchmarkImprint_*` | Small and large page trees | | `BenchmarkImprint_*` | Small and large page trees |
| `BenchmarkCompareVariants_*` | Two and three variant comparison | | `BenchmarkCompareVariants_*` | Two and three variant comparison |
| `BenchmarkGenerateClass` | Single Web Component class generation |
| `BenchmarkGenerateBundle_*` | Small (2-slot) and full (5-slot) bundles |
| `BenchmarkTagToClassName` | Kebab-to-PascalCase conversion |
| `BenchmarkGenerateRegistration` | `customElements.define()` call generation |
## WASM Build ## WASM Build
@ -117,51 +87,36 @@ Available benchmark groups:
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
``` ```
Strip flags (`-s -w`) are required. Without them, the binary is approximately 50% larger. Strip flags (`-s -w`) are required. Without them the binary is approximately 50% larger.
The Makefile `wasm` target performs the build and checks the output size: The Makefile target `make wasm` performs the build and measures the gzip size:
```bash ```bash
make wasm make wasm
``` ```
The Makefile enforces a 1 MB gzip transfer limit and a 3 MB raw size limit. Current measured output: approximately 2.90 MB raw, 842 KB gzip. The Makefile enforces a 1 MB gzip limit (`WASM_GZ_LIMIT = 1048576`). The build fails if this limit is exceeded.
To verify the gzip size manually: To verify the size manually:
```bash ```bash
gzip -c -9 gohtml.wasm | wc -c gzip -c -9 gohtml.wasm | wc -c
``` ```
Current measured output: 2.90 MB raw, 842 KB gzip.
## Codegen CLI ## Codegen CLI
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout: The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout. It is a build-time tool, not intended for runtime use.
```bash ```bash
# Generate components for a two-slot layout
echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
| go run ./cmd/codegen/ \ | go run ./cmd/codegen/ \
> components.js > components.js
``` ```
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated. The JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). The values are custom element tag names (must contain a hyphen). Duplicate tag values are deduplicated.
Pass `-types` to emit ambient TypeScript declarations instead of JavaScript:
```bash
echo '{"H":"site-header","C":"app-content"}' \
| go run ./cmd/codegen/ -types \
> components.d.ts
```
For local development, `-watch` polls an input JSON file and rewrites the
output file whenever the slot map changes:
```bash
go run ./cmd/codegen/ \
-watch \
-input slots.json \
-output components.js
```
To test the CLI: To test the CLI:
@ -175,7 +130,7 @@ go test ./cmd/codegen/
go vet ./... go vet ./...
``` ```
The repository also includes a `.golangci.yml` configuration for `golangci-lint`. The codebase passes `go vet` with no warnings.
## Coding Standards ## Coding Standards
@ -183,39 +138,37 @@ The repository also includes a `.golangci.yml` configuration for `golangci-lint`
UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used. UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used.
### Type Annotations ### Types
All exported and unexported functions carry full parameter and return type annotations. The `any` alias is used in preference to `interface{}`. All exported and unexported functions carry full parameter and return type annotations. The `any` alias is used in preference to `interface{}`.
### HTML Safety ### Error handling
- Use `Text()` for any user-supplied or translated content. It escapes HTML automatically. Errors are wrapped with context using `fmt.Errorf("pkg.Function: %w", err)`. The codegen package prefixes all errors with `codegen:`.
- Use `Raw()` only for content you control or have sanitised upstream. Its name explicitly signals "no escaping".
### HTML safety
- Use `Text()` for any user-supplied or translated content. It escapes HTML.
- Use `Raw()` only for content you control or have sanitised upstream.
- Never construct HTML by string concatenation in application code. - Never construct HTML by string concatenation in application code.
### Error Handling
Errors are wrapped with context using `fmt.Errorf()`. The codegen package prefixes all errors with `codegen:`.
### Determinism ### Determinism
Output must be deterministic. `El` node attributes are sorted alphabetically before rendering. `map` iteration order in `codegen.GenerateBundle()` may vary across runs -- this is acceptable because Web Component registration order does not affect correctness. Output must be deterministic. Attributes are sorted before rendering. `map` iteration in `codegen.GenerateBundle()` may produce non-deterministic class order across runs — this is acceptable because Web Component registration order does not affect correctness.
### Build Tags ### Build tags
Files excluded from WASM use `//go:build !js` as the first line, before the `package` declaration. Files compiled only under WASM use `//go:build js && wasm`. The older `// +build` syntax is not used. Files excluded from WASM use `//go:build !js` as the first line, before the `package` declaration. Files compiled only under WASM use `//go:build js && wasm`. Do not use the older `// +build` syntax.
The `fmt` package must never be imported in files without a `!js` build tag, as it significantly inflates the WASM binary. Use string concatenation instead of `fmt.Sprintf` in layout and node code.
### Licence ### Licence
All new files should carry the EUPL-1.2 SPDX identifier: All files carry the EUPL-1.2 SPDX identifier:
```go ```go
// SPDX-Licence-Identifier: EUPL-1.2 // SPDX-Licence-Identifier: EUPL-1.2
``` ```
### Commit Format ### Commit format
Conventional commits with lowercase type and optional scope: Conventional commits with lowercase type and optional scope:
@ -226,7 +179,7 @@ test: add edge case for Unicode surrogate pairs
docs: update architecture with pipeline diagram docs: update architecture with pipeline diagram
``` ```
Include a co-author trailer: Commits include a co-author trailer:
``` ```
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Virgil <virgil@lethean.io>
@ -234,7 +187,7 @@ Co-Authored-By: Virgil <virgil@lethean.io>
## Test Patterns ## Test Patterns
### Standard Unit Test ### Standard unit test
```go ```go
func TestElNode_Render(t *testing.T) { func TestElNode_Render(t *testing.T) {
@ -248,31 +201,32 @@ func TestElNode_Render(t *testing.T) {
} }
``` ```
### Table-Driven Subtest ### Table-driven subtest
```go ```go
func TestStripTags_Unicode(t *testing.T) { func TestStripTags(t *testing.T) {
tests := []struct { cases := []struct {
name string name string
input string input string
want string want string
}{ }{
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"}, {"empty", "", ""},
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"}, {"plain", "hello", "hello"},
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"}, {"single tag", "<p>hello</p>", "hello"},
{"nested", "<div><p>a</p><p>b</p></div>", "a b"},
} }
for _, tt := range tests { for _, tc := range cases {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
got := StripTags(tt.input) got := StripTags(tc.input)
if got != tt.want { if got != tc.want {
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want) t.Errorf("StripTags(%q) = %q, want %q", tc.input, got, tc.want)
} }
}) })
} }
} }
``` ```
### Integration Test with i18n ### Integration test with i18n
```go ```go
func TestIntegration_RenderThenReverse(t *testing.T) { func TestIntegration_RenderThenReverse(t *testing.T) {
@ -293,22 +247,11 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
} }
``` ```
### Codegen Tests with Testify Integration tests that exercise the full pipeline (`Imprint`, `CompareVariants`) must initialise the i18n default service before calling `Text` nodes. The `bench_test.go` `init()` function does this for benchmarks; individual integration tests must do so explicitly.
```go
func TestGenerateClass_ValidTag(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"`)
}
```
## Known Limitations ## Known Limitations
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning. - `NewLayout("XYZ")` silently produces empty output when given unrecognised slot letters. There is no warning or error. Valid slot letters are `H`, `L`, `C`, `R`, `F`.
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first. - `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout.
- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services. - `Context.service` is private. Custom i18n adapter injection requires `NewContextWithService()`. There is no way to set or swap the service 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. - `cmd/wasm/main.go` has no integration test for the JS exports. The `size_test.go` file tests binary size only; it does not exercise `renderToString` behaviour.
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.

View file

@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
### Size gate test (`aae5d21`) ### Size gate test (`aae5d21`)
`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: `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:
- Gzip size < 1,048,576 bytes (1 MB). - Gzip size < 1,048,576 bytes (1 MB).
- Raw size < 3,145,728 bytes (3 MB). - Raw size < 3,145,728 bytes (3 MB).
@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis. 3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
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. 4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
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 not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the 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. 6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented. 7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
@ -114,7 +114,6 @@ These are not regressions; they are design choices or deferred work recorded for
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items. These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. - **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
- **Accessibility helpers**`aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`. - **Accessibility helpers**`aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
- **Responsive CSS helpers**`VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters. - **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows. - **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.

View file

@ -1,80 +0,0 @@
---
title: go-html
description: HLCRF DOM compositor with grammar pipeline integration for type-safe server-side HTML generation and optional WASM client rendering.
---
# go-html
`go-html` is a pure-Go library for building HTML documents as type-safe node trees and rendering them to string output. It provides a five-slot layout compositor (Header, Left, Content, Right, Footer -- abbreviated HLCRF), a responsive multi-variant wrapper, a server-side grammar analysis pipeline, a Web Component code generator, and an optional WASM module for client-side rendering.
**Module path:** `forge.lthn.ai/core/go-html`
**Go version:** 1.26
**Licence:** EUPL-1.2
## Quick Start
```go
package main
import html "forge.lthn.ai/core/go-html"
func main() {
page := html.NewLayout("HCF").
H(html.El("nav", html.Text("nav.label"))).
C(html.El("article",
html.El("h1", html.Text("page.title")),
html.Each(items, func(item Item) html.Node {
return html.El("li", html.Text(item.Name))
}),
)).
F(html.El("footer", html.Text("footer.copyright")))
output := page.Render(html.NewContext())
}
```
This builds a Header-Content-Footer layout with semantic HTML elements (`<header>`, `<main>`, `<footer>`), ARIA roles, and deterministic `data-block` path identifiers. Text nodes pass through the `go-i18n` translation layer and are HTML-escaped by default.
## Package Layout
| Path | Purpose |
|------|---------|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, and `Role` helpers |
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
| `render.go` | `Render()` convenience function |
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
## Key Concepts
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), and accessibility helpers (`AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, `Role`).
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime.
## Dependencies
```
forge.lthn.ai/core/go-html
forge.lthn.ai/core/go-i18n (direct, all builds)
forge.lthn.ai/core/go-inference (indirect, via go-i18n)
forge.lthn.ai/core/go-i18n/reversal (server builds only, !js)
github.com/stretchr/testify (test only)
```
Both `go-i18n` and `go-inference` must be present on the local filesystem. The `go.mod` uses `replace` directives pointing to sibling directories (`../go-i18n`, `../go-inference`).
## Further Reading
- [Architecture](architecture.md) -- Node interface, HLCRF layout internals, responsive compositor, grammar pipeline, WASM module, codegen CLI
- [Development](development.md) -- Building, testing, benchmarks, WASM builds, coding standards, contribution guide

View file

@ -0,0 +1,440 @@
# Phase 4: CoreDeno + Web Components
**Date:** 2026-02-17
**Status:** Approved
**Heritage:** dAppServer prototype (20 repos), Chandler/Dreaming in Code
## Vision
A universal application framework where `.core/view.yml` defines what an app IS.
Run `core` in any directory — it discovers the manifest, verifies its signature,
and boots the application. Like `docker-compose.yml` but for applications.
Philosophical lineage: Mitch Kapor's Chandler (universal configurable app),
rebuilt with Web Components, Deno sandboxing, WASM rendering, and LEM ethics.
## Architecture
```
┌─────────────────────────────────────────────┐
│ WebView2 (Browser) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Angular │ │ Web Comp │ │ go-html │ │
│ │ (shell) │ │ (modules)│ │ WASM │ │
│ └─────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────┬───────┘ │ │
│ │ fetch/WS │ │
└───────────────┼─────────────────────┼───────┘
│ │
┌───────────────┼─────────────────────┼───────┐
│ CoreDeno (Deno sidecar) │ │
│ ┌────────────┴──────────┐ ┌─────┴─────┐ │
│ │ Module Loader │ │ ITW3→WC │ │
│ │ + Permission Gates │ │ Codegen │ │
│ │ + Dev Server (HMR) │ │ │ │
│ └────────────┬──────────┘ └───────────┘ │
│ │ gRPC / Unix socket │
└───────────────┼─────────────────────────────┘
┌───────────────┼─────────────────────────────┐
│ Go Backend (CoreGO) │
│ ┌────────┐ ┌┴───────┐ ┌─────────────────┐ │
│ │ Module │ │ gRPC │ │ MCPBridge │ │
│ │Registry│ │ Server │ │ (WebView tools) │ │
│ └────────┘ └────────┘ └─────────────────┘ │
└─────────────────────────────────────────────┘
```
Three processes:
- **WebView2**: Angular shell (gradual migration) + Web Components + go-html WASM
- **CoreDeno**: Deno sidecar — module sandbox, I/O fortress, TypeScript toolchain
- **CoreGO**: Framework backbone — lifecycle, services, I/O (core/pkg/io), gRPC server
## Responsibility Split
| Layer | Role |
|-------|------|
| **CoreGO** | Framework (lifecycle, services, I/O via core/pkg/io, module registry, gRPC server) |
| **go-html** | Web Component factory (layout → Shadow DOM, manifest → custom element, WASM client-side registration) |
| **CoreDeno** | Sandbox + toolchain (Deno permissions, TypeScript compilation, dev server, asset serving) |
| **MCPBridge** | Retained for direct WebView tools (window control, display, clipboard, dialogs) |
## CoreDeno Sidecar
### Lifecycle
Go spawns Deno as a managed child process at app startup. Auto-restart on crash.
SIGTERM on app shutdown.
### Communication
- **Channel**: Unix domain socket at `$XDG_RUNTIME_DIR/core/deno.sock`
- **Protocol**: gRPC (proto definitions in `pkg/coredeno/proto/`)
- **Direction**: Bidirectional
- Deno → Go: I/O requests (file, network, process) gated by permissions
- Go → Deno: Module lifecycle events, HLCRF re-render triggers
### Deno's Three Roles
**1. Module loader + sandbox**: Reads ITW3 manifests, loads modules with
per-module `--allow-*` permission flags. Modules run in Deno's isolate.
**2. I/O fortress gateway**: All file/network/process I/O routed through Deno's
permission gates before reaching Go via gRPC. A module requesting access outside
its declared paths is denied before Go ever sees the request.
**3. Build/dev toolchain**: TypeScript compilation, module resolution, dev server
with HMR. Replaces Node/npm entirely. In production, pre-compiled bundles
embedded in binary.
### Permission Model
Each module declares required permissions in its manifest:
```yaml
permissions:
read: ["/data/mining/"]
write: ["/data/mining/config.json"]
net: ["pool.lthn.io:3333"]
run: ["xmrig"]
```
CoreDeno enforces these at the gRPC boundary.
## The .core/ Convention
### Auto-Discovery
Run `core` in any directory. If `.core/view.yml` exists, CoreGO reads it,
validates the ed25519 signature, and boots the application context.
### view.yml Format (successor to .itw3.json)
```yaml
code: photo-browser
name: Photo Browser
version: 0.1.0
sign: <ed25519 signature>
layout: HLCRF
slots:
H: nav-breadcrumb
L: folder-tree
C: photo-grid
R: metadata-panel
F: status-bar
permissions:
read: ["./photos/"]
net: []
run: []
modules:
- core/media
- core/fs
```
### Signed Application Loading
The `sign` field contains an ed25519 signature. CoreGO verifies before loading.
Unsigned or tampered manifests are rejected. The I/O fortress operates at the
application boundary — the entire app load chain is authenticated.
## Web Component Lifecycle
1. **Discovery**`core` reads `.core/view.yml`, verifies signature
2. **Resolve** → CoreGO checks module registry for declared components
3. **Codegen** → go-html generates Web Component class definitions from manifest
4. **Permission binding** → CoreDeno wraps component I/O calls with per-module gates
5. **Composition** → HLCRF layout assembles slots, each a custom element with Shadow DOM
6. **Hot reload** → Dev mode: Deno watches files, WASM re-renders affected slots only
### HLCRF Slot Composition
```
┌──────────────────────────────────┐
<nav-breadcrumb> (H - shadow) │
├────────┬───────────────┬─────────┤
<folder <photo-grid><metadata
│ -tree> │ (C-shadow) │ -panel> │
│(L-shad)│ │(R-shad) │
├────────┴───────────────┴─────────┤
<status-bar> (F - shadow) │
└──────────────────────────────────┘
```
Each slot is a custom element with closed Shadow DOM. Isolation by design —
one module cannot reach into another's shadow tree.
### go-html WASM Integration
- **Server-side (Go)**: go-html reads manifests, generates WC class definitions
- **Client-side (WASM)**: go-html WASM in browser dynamically registers custom
elements at runtime via `customElements.define()`
- Same code, two targets. Server pre-renders for initial load, client handles
dynamic re-renders when slots change.
## Angular Migration Path
**Phase 4a** (current): Web Components load inside Angular's `<router-outlet>`.
Angular sees custom elements via `CUSTOM_ELEMENTS_SCHEMA`. No Angular code needed
for new modules.
**Phase 4b**: ApplicationFrame becomes a go-html Web Component (HLCRF outer frame).
Angular router replaced by lightweight hash-based router mapping URLs to
`.core/view.yml` slot configurations.
**Phase 4c**: Angular removed. WebView2 loads:
1. go-html WASM (layout engine + WC factory)
2. Thin router (~50 lines)
3. CoreDeno-served module bundles
4. Web Awesome (design system — already vanilla custom elements)
## dAppServer Heritage
20 repos at `github.com/dAppServer/` — the original client-side server concept
and browser↔Go communications bridge. Extract and port, not copy.
### Tier 1: Extract (Core Architecture)
| dAppServer repo | What to extract | Phase 4 target |
|---|---|---|
| `server` | Port 36911 bridge, ZeroMQ IPC (pub/sub + req/rep + push/pull), air-gapped PGP auth, object store, 13 test files with RPC procedures | CoreDeno sidecar, I/O fortress, auth |
| `dappui` | Angular→WC migration, REST+WS+Wails triple, terminal (xterm.js) | Web Component framework, MCPBridge |
| `mod-auth` | PGP zero-knowledge auth (sign→encrypt→verify→JWT), QuasiSalt, roles | Signed manifest verification, identity |
| `mod-io-process` | Process registry, 3-layer I/O streaming (process→ZeroMQ→WS→browser) | `core/pkg/process`, event bus |
| `app-marketplace` | Git-as-database registry, category-as-directory, install pipeline | Module registry, `.core/view.yml` loader |
### Tier 2: Port (Useful Patterns)
| dAppServer repo | What to port | Phase 4 target |
|---|---|---|
| `auth-server` | Keycloak + native PGP fallback | External auth option |
| `mod-docker` | Docker socket client, container CRUD (8 ops) | `core/pkg/process` |
| `app-mining` | CLI Bridge (camelCase→kebab-case), Process-as-Service, API proxy | Generic CLI wrapper |
| `app-directory-browser` | Split-pane layout, lazy tree, filesystem CRUD RPCs | `<core-file-tree>` WC |
| `wails-build-action` | Auto-stack detection, cross-platform signing, Deno CI | Build tooling |
### Tier 3: Reference
| dAppServer repo | Value |
|---|---|
| `depends` | Bitcoin Core hermetic build; libmultiprocess + Cap'n Proto validates process-separation |
| `app-utils-cyberchef` | Purest manifest-only pattern ("manifest IS the app") |
| `devops` | Cross-compilation matrix (9 triples), ancestor of ADR-001 |
| `pwa-native-action` | PWA→Wails native shell proof, ancestor of `core-gui` |
| `docker-images` | C++ cross-compile layers |
### Tier 4: Skip
| dAppServer repo | Reason |
|---|---|
| `server-sdk-python` | Auto-generated, Go replaces |
| `server-sdk-typescript-angular` | Auto-generated, superseded |
| `openvpn` | Unmodified upstream fork |
| `ansible-server-base` | Standard Ansible hardening |
| `.github` | Org profile only |
## Polyfills
dAppServer polyfilled nothing at the browser level. The prototype ran inside
Electron/WebView2 (Chromium), which already supports all required APIs natively:
Custom Elements v1, Shadow DOM v1, ES Modules, `fetch`, `WebSocket`,
`customElements.define()`, `structuredClone()`.
**Decision**: No polyfills needed. WebView2 is Chromium-based. The minimum
Chromium version Wails v3 targets already supports all Web Component APIs.
## Object Store
dAppServer used a file-based JSON key-value store at `data/objects/{group}/{object}.json`.
Six operations discovered from test files:
| Operation | dAppServer endpoint | Phase 4 equivalent |
|-----------|--------------------|--------------------|
| Get | `GET /config/object/{group}/{object}` | `store.get(group, key)` |
| Set | `POST /config/object/{group}/{object}` | `store.set(group, key, value)` |
| Clear | `DELETE /config/object/{group}/{object}` | `store.delete(group, key)` |
| Count | `GET /config/object/{group}/count` | `store.count(group)` |
| Remove group | `DELETE /config/object/{group}` | `store.deleteGroup(group)` |
| Render template | `POST /config/render` | `store.render(template, vars)` |
Used for: installed app registry (`conf/installed-apps.json`), menu state
(`conf/menu.json`), per-module config, user preferences.
**Decision**: Go-managed storage via gRPC. CoreGO owns persistence through
`core/pkg/io`. Modules request storage through the I/O fortress — never
touching the filesystem directly. SQLite backend (already a dependency in
the blockchain layer). IndexedDB reserved for client-side cache only.
## Templated Config Generators
dAppServer's `config.render` endpoint accepted a template string + variable map
and returned the rendered output. Used to generate configs for managed processes
(e.g., xmrig config.json from user-selected pool/wallet parameters).
The pattern in Phase 4:
1. Module declares config templates in `.core/view.yml` under a `config:` key
2. User preferences stored in the object store
3. CoreGO renders templates at process-start time via Go `text/template`
4. Rendered configs written to sandboxed paths the module has `write` permission for
Example from mining module (camelCase→kebab-case CLI arg transformation):
```yaml
config:
xmrig:
template: conf/xmrig/config.json.tmpl
vars:
pool: "{{ .user.pool }}"
wallet: "{{ .user.wallet }}"
```
**Decision**: Go `text/template` in CoreGO. Templates live in the module's
`.core/` directory. Variables come from the object store. No Deno involvement —
config rendering is a Go-side I/O fortress operation.
## Git-Based Plugin Marketplace
### dAppServer Pattern (Extracted from `app-marketplace`)
A Git repository serves as the package registry. No server infrastructure needed.
```
marketplace/ # Git repo
├── index.json # Root: {version, apps[], dirs[]}
├── miner/
│ └── index.json # Category: {version, apps[], dirs[]}
├── utils/
│ └── index.json # Category: {version, apps[], dirs[]}
└── ...
```
Each `index.json` entry points to a raw `.itw3.json` URL in the plugin's own repo:
```json
{"code": "utils-cyberchef", "name": "CyberChef", "type": "bin",
"pkg": "https://raw.githubusercontent.com/dAppServer/app-utils-cyberchef/main/.itw3.json"}
```
Install pipeline: browse index → fetch manifest → download zip from `app.url`
extract → run hooks (rename, etc.) → register in object store → add menu entry.
### Phase 4 Evolution
Replace GitHub-specific URLs with Forgejo-compatible Git operations:
1. **Registry**: A Git repo (`host-uk/marketplace`) with category directories
and `index.json` files. Cloned/pulled by CoreGO at startup and periodically.
2. **Manifests**: Each module's `.core/view.yml` is the manifest (replaces `.itw3.json`).
The marketplace index points to the module's Git repo, not a raw file URL.
3. **Distribution**: Git clone of the module repo (not zip downloads). CoreGO
clones into a managed modules directory with depth=1.
4. **Verification**: ed25519 signature in `view.yml` verified before loading.
The marketplace index includes the expected signing public key.
5. **Install hooks**: Declared in `view.yml` under `hooks:`. Executed by CoreGO
in the I/O fortress (rename, template render, permission grant).
6. **Updates**: `git pull` on the module repo. Signature re-verified after pull.
If signature fails, rollback to previous commit.
7. **Discovery**: `core marketplace list`, `core marketplace search <query>`,
`core marketplace install <code>`.
```yaml
# marketplace/index.json
version: 1
modules:
- code: utils-cyberchef
name: CyberChef Data Toolkit
repo: https://forge.lthn.io/host-uk/mod-cyberchef.git
sign_key: <ed25519 public key>
category: utils
categories:
- miner
- utils
- network
```
### RPC Surface (from dAppServer test extraction)
| Operation | CLI | RPC |
|-----------|-----|-----|
| Browse | `core marketplace list` | `marketplace.list(category?)` |
| Search | `core marketplace search <q>` | `marketplace.search(query)` |
| Install | `core marketplace install <code>` | `marketplace.install(code)` |
| Remove | `core marketplace remove <code>` | `marketplace.remove(code)` |
| Installed | `core marketplace installed` | `marketplace.installed()` |
| Update | `core marketplace update <code>` | `marketplace.update(code)` |
| Update all | `core marketplace update` | `marketplace.updateAll()` |
## Complete RPC Surface (Archaeological Extraction)
All procedures discovered from dAppServer test files and controllers:
### Auth
- `auth.create(username, password)` — PGP key generation + QuasiSalt hash
- `auth.login(username, encryptedPayload)` — Zero-knowledge PGP verify → JWT
- `auth.delete(username)` — Remove account
### Crypto
- `crypto.pgp.generateKeyPair(name, email, passphrase)` → {publicKey, privateKey}
- `crypto.pgp.encrypt(data, publicKey)` → encryptedData
- `crypto.pgp.decrypt(data, privateKey, passphrase)` → plaintext
- `crypto.pgp.sign(data, privateKey, passphrase)` → signature
- `crypto.pgp.verify(data, signature, publicKey)` → boolean
### Filesystem
- `fs.list(path, detailed?)` → FileEntry[]
- `fs.read(path)` → content
- `fs.write(path, content)` → boolean
- `fs.delete(path)` → boolean
- `fs.rename(from, to)` → boolean
- `fs.mkdir(path)` → boolean
- `fs.isDir(path)` → boolean
- `fs.ensureDir(path)` → boolean
### Process
- `process.run(command, args, options)` → ProcessHandle
- `process.add(request)` → key
- `process.start(key)` → boolean
- `process.stop(key)` → boolean
- `process.kill(key)` → boolean
- `process.list()` → string[]
- `process.get(key)` → ProcessInfo
- `process.stdout.subscribe(key)` → stream
- `process.stdin.write(key, data)` → void
### Object Store
- `store.get(group, key)` → value
- `store.set(group, key, value)` → void
- `store.delete(group, key)` → void
- `store.count(group)` → number
- `store.deleteGroup(group)` → void
- `store.render(template, vars)` → string
### IPC / Event Bus
- `ipc.pub.subscribe(channel)` → stream
- `ipc.pub.publish(channel, message)` → void
- `ipc.req.send(channel, message)` → response
- `ipc.push.send(message)` → void
### Marketplace
- `marketplace.list(category?)` → ModuleEntry[]
- `marketplace.search(query)` → ModuleEntry[]
- `marketplace.install(code)` → boolean
- `marketplace.remove(code)` → boolean
- `marketplace.installed()` → InstalledModule[]
- `marketplace.update(code)` → boolean
## Deliverables
| Component | Location | Language |
|---|---|---|
| CoreDeno sidecar manager | `core/pkg/coredeno/` | Go |
| gRPC proto definitions | `core/pkg/coredeno/proto/` | Protobuf |
| gRPC server (Go side) | `core/pkg/coredeno/server.go` | Go |
| Deno client runtime | `core-deno/` (new repo) | TypeScript |
| ITW3 → WC codegen | `go-html/codegen/` | Go |
| .core/view.yml loader | `core/pkg/manifest/` | Go |
| Manifest signing/verify | `core/pkg/manifest/sign.go` | Go |
| WASM WC registration | `go-html/cmd/wasm/` (extend) | Go |
## Not In Scope (Future Phases)
- LEM auto-loading from signed manifests
- Marketplace server infrastructure (Git-based registry is sufficient)
- Offline-first sync (IndexedDB client cache)
- Full Angular removal (Phase 4c)
- GlusterFS distributed storage (dAppServer aspiration, not needed yet)
- Multi-chain support (Phase 4 is Lethean-only)

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,16 @@
package html package html
import ( import (
"errors" "fmt"
"strings"
"testing" "testing"
i18n "dappco.re/go/core/i18n" i18n "forge.lthn.ai/core/go-i18n"
) )
// --- Unicode / RTL edge cases --- // --- Unicode / RTL edge cases ---
func TestText_Emoji_Ugly(t *testing.T) { func TestText_Emoji(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -32,7 +33,7 @@ func TestText_Emoji_Ugly(t *testing.T) {
t.Error("Text with emoji should not produce empty output") t.Error("Text with emoji should not produce empty output")
} }
// Emoji should pass through (they are not HTML special chars) // Emoji should pass through (they are not HTML special chars)
if !containsText(got, tt.input) { if !strings.Contains(got, tt.input) {
// Some chars may get escaped, but emoji bytes should survive // Some chars may get escaped, but emoji bytes should survive
t.Logf("note: emoji text rendered as %q", got) t.Logf("note: emoji text rendered as %q", got)
} }
@ -40,7 +41,7 @@ func TestText_Emoji_Ugly(t *testing.T) {
} }
} }
func TestEl_Emoji_Ugly(t *testing.T) { func TestEl_Emoji(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("span", Raw("\U0001F680 Launch")) node := El("span", Raw("\U0001F680 Launch"))
got := node.Render(ctx) got := node.Render(ctx)
@ -50,7 +51,7 @@ func TestEl_Emoji_Ugly(t *testing.T) {
} }
} }
func TestText_RTL_Ugly(t *testing.T) { func TestText_RTL(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -75,19 +76,19 @@ func TestText_RTL_Ugly(t *testing.T) {
} }
} }
func TestEl_RTL_Ugly(t *testing.T) { func TestEl_RTL(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl") node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
got := node.Render(ctx) got := node.Render(ctx)
if !containsText(got, `dir="rtl"`) { if !strings.Contains(got, `dir="rtl"`) {
t.Errorf("RTL element missing dir attribute in: %s", got) t.Errorf("RTL element missing dir attribute in: %s", got)
} }
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") { if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
t.Errorf("RTL element missing Arabic text in: %s", got) t.Errorf("RTL element missing Arabic text in: %s", got)
} }
} }
func TestText_ZeroWidth_Ugly(t *testing.T) { func TestText_ZeroWidth(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -113,7 +114,7 @@ func TestText_ZeroWidth_Ugly(t *testing.T) {
} }
} }
func TestText_MixedScripts_Ugly(t *testing.T) { func TestText_MixedScripts(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -140,7 +141,7 @@ func TestText_MixedScripts_Ugly(t *testing.T) {
} }
} }
func TestStripTags_Unicode_Ugly(t *testing.T) { func TestStripTags_Unicode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
@ -162,19 +163,19 @@ func TestStripTags_Unicode_Ugly(t *testing.T) {
} }
} }
func TestAttr_UnicodeValue_Ugly(t *testing.T) { func TestAttr_UnicodeValue(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch") node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
got := node.Render(ctx) got := node.Render(ctx)
want := "title=\"\U0001F680 Rocket Launch\"" want := "title=\"\U0001F680 Rocket Launch\""
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("attribute with emoji should be preserved, got: %s", got) t.Errorf("attribute with emoji should be preserved, got: %s", got)
} }
} }
// --- Deep nesting stress tests --- // --- Deep nesting stress tests ---
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) { func TestLayout_DeepNesting_10Levels(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// Build 10 levels of nested layouts // Build 10 levels of nested layouts
@ -186,7 +187,7 @@ func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
got := current.Render(ctx) got := current.Render(ctx)
// Should contain the deepest content // Should contain the deepest content
if !containsText(got, "deepest") { if !strings.Contains(got, "deepest") {
t.Error("10 levels deep: missing leaf content") t.Error("10 levels deep: missing leaf content")
} }
@ -195,17 +196,17 @@ func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
for i := 1; i < 10; i++ { for i := 1; i < 10; i++ {
expectedBlock += "-C-0" expectedBlock += "-C-0"
} }
if !containsText(got, `data-block="`+expectedBlock+`"`) { if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got) t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
} }
// Must have exactly 10 <main> tags // Must have exactly 10 <main> tags
if count := countText(got, "<main"); count != 10 { if count := strings.Count(got, "<main"); count != 10 {
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count) t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
} }
} }
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) { func TestLayout_DeepNesting_20Levels(t *testing.T) {
ctx := NewContext() ctx := NewContext()
current := NewLayout("C").C(Raw("bottom")) current := NewLayout("C").C(Raw("bottom"))
@ -215,15 +216,15 @@ func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
got := current.Render(ctx) got := current.Render(ctx)
if !containsText(got, "bottom") { if !strings.Contains(got, "bottom") {
t.Error("20 levels deep: missing leaf content") t.Error("20 levels deep: missing leaf content")
} }
if count := countText(got, "<main"); count != 20 { if count := strings.Count(got, "<main"); count != 20 {
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count) t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
} }
} }
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) { func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// Alternate slot types at each level: C -> L -> C -> L -> ... // Alternate slot types at each level: C -> L -> C -> L -> ...
@ -237,12 +238,12 @@ func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
} }
got := current.Render(ctx) got := current.Render(ctx)
if !containsText(got, "leaf") { if !strings.Contains(got, "leaf") {
t.Error("mixed deep nesting: missing leaf content") t.Error("mixed deep nesting: missing leaf content")
} }
} }
func TestEach_LargeIteration1000_Ugly(t *testing.T) { func TestEach_LargeIteration_1000(t *testing.T) {
ctx := NewContext() ctx := NewContext()
items := make([]int, 1000) items := make([]int, 1000)
for i := range items { for i := range items {
@ -250,23 +251,23 @@ func TestEach_LargeIteration1000_Ugly(t *testing.T) {
} }
node := Each(items, func(i int) Node { node := Each(items, func(i int) Node {
return El("li", Raw(itoaText(i))) return El("li", Raw(fmt.Sprintf("%d", i)))
}) })
got := node.Render(ctx) got := node.Render(ctx)
if count := countText(got, "<li>"); count != 1000 { if count := strings.Count(got, "<li>"); count != 1000 {
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count) t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
} }
if !containsText(got, "<li>0</li>") { if !strings.Contains(got, "<li>0</li>") {
t.Error("Each with 1000 items: missing first item") t.Error("Each with 1000 items: missing first item")
} }
if !containsText(got, "<li>999</li>") { if !strings.Contains(got, "<li>999</li>") {
t.Error("Each with 1000 items: missing last item") t.Error("Each with 1000 items: missing last item")
} }
} }
func TestEach_LargeIteration5000_Ugly(t *testing.T) { func TestEach_LargeIteration_5000(t *testing.T) {
ctx := NewContext() ctx := NewContext()
items := make([]int, 5000) items := make([]int, 5000)
for i := range items { for i := range items {
@ -274,43 +275,43 @@ func TestEach_LargeIteration5000_Ugly(t *testing.T) {
} }
node := Each(items, func(i int) Node { node := Each(items, func(i int) Node {
return El("span", Raw(itoaText(i))) return El("span", Raw(fmt.Sprintf("%d", i)))
}) })
got := node.Render(ctx) got := node.Render(ctx)
if count := countText(got, "<span>"); count != 5000 { if count := strings.Count(got, "<span>"); count != 5000 {
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count) t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
} }
} }
func TestEach_NestedEach_Ugly(t *testing.T) { func TestEach_NestedEach(t *testing.T) {
ctx := NewContext() ctx := NewContext()
rows := []int{0, 1, 2} rows := []int{0, 1, 2}
cols := []string{"a", "b", "c"} cols := []string{"a", "b", "c"}
node := Each(rows, func(row int) Node { node := Each(rows, func(row int) Node {
return El("tr", Each(cols, func(col string) Node { return El("tr", Each(cols, func(col string) Node {
return El("td", Raw(itoaText(row)+"-"+col)) return El("td", Raw(fmt.Sprintf("%d-%s", row, col)))
})) }))
}) })
got := node.Render(ctx) got := node.Render(ctx)
if count := countText(got, "<tr>"); count != 3 { if count := strings.Count(got, "<tr>"); count != 3 {
t.Errorf("nested Each: expected 3 <tr>, got %d", count) t.Errorf("nested Each: expected 3 <tr>, got %d", count)
} }
if count := countText(got, "<td>"); count != 9 { if count := strings.Count(got, "<td>"); count != 9 {
t.Errorf("nested Each: expected 9 <td>, got %d", count) t.Errorf("nested Each: expected 9 <td>, got %d", count)
} }
if !containsText(got, "1-b") { if !strings.Contains(got, "1-b") {
t.Error("nested Each: missing cell content '1-b'") t.Error("nested Each: missing cell content '1-b'")
} }
} }
// --- Layout variant validation --- // --- Layout variant validation ---
func TestLayout_InvalidVariantChars_Bad(t *testing.T) { func TestLayout_InvalidVariant_Chars(t *testing.T) {
ctx := NewContext() ctx := NewContext()
tests := []struct { tests := []struct {
@ -342,96 +343,7 @@ func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
} }
} }
func TestLayout_VariantError_Bad(t *testing.T) { func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
tests := []struct {
name string
variant string
wantInvalid bool
wantErrString string
build func(*Layout)
wantRender string
}{
{
name: "valid variant",
variant: "HCF",
wantInvalid: false,
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
},
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main><footer role="contentinfo" data-block="F-0">footer</footer>`,
},
{
name: "mixed invalid variant",
variant: "HXC",
wantInvalid: true,
wantErrString: "html: invalid layout variant HXC",
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main"))
},
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
layout := NewLayout(tt.variant)
if tt.build != nil {
tt.build(layout)
}
if tt.wantInvalid {
if layout.VariantError() == nil {
t.Fatalf("VariantError() = nil, want sentinel error for %q", tt.variant)
}
if !errors.Is(layout.VariantError(), ErrInvalidLayoutVariant) {
t.Fatalf("VariantError() = %v, want errors.Is(..., ErrInvalidLayoutVariant)", layout.VariantError())
}
if got := layout.VariantError().Error(); got != tt.wantErrString {
t.Fatalf("VariantError().Error() = %q, want %q", got, tt.wantErrString)
}
} else if layout.VariantError() != nil {
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
}
got := layout.Render(NewContext())
if got != tt.wantRender {
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
}
})
}
}
func TestValidateLayoutVariant_Good(t *testing.T) {
tests := []struct {
name string
variant string
wantErr bool
}{
{name: "valid", variant: "HCF", wantErr: false},
{name: "invalid", variant: "HXC", wantErr: true},
{name: "empty", variant: "", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLayoutVariant(tt.variant)
if tt.wantErr {
if err == nil {
t.Fatalf("ValidateLayoutVariant(%q) = nil, want error", tt.variant)
}
if !errors.Is(err, ErrInvalidLayoutVariant) {
t.Fatalf("ValidateLayoutVariant(%q) = %v, want ErrInvalidLayoutVariant", tt.variant, err)
}
return
}
if err != nil {
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
}
})
}
}
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// "HXC" — H and C are valid, X is not. Only H and C should render. // "HXC" — H and C are valid, X is not. Only H and C should render.
@ -439,32 +351,32 @@ func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
H(Raw("header")).C(Raw("main")) H(Raw("header")).C(Raw("main"))
got := layout.Render(ctx) got := layout.Render(ctx)
if !containsText(got, "header") { if !strings.Contains(got, "header") {
t.Errorf("HXC variant should render H slot, got:\n%s", got) t.Errorf("HXC variant should render H slot, got:\n%s", got)
} }
if !containsText(got, "main") { if !strings.Contains(got, "main") {
t.Errorf("HXC variant should render C slot, got:\n%s", got) t.Errorf("HXC variant should render C slot, got:\n%s", got)
} }
// Should only have 2 semantic elements // Should only have 2 semantic elements
if count := countText(got, "data-block="); count != 2 { if count := strings.Count(got, "data-block="); count != 2 {
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got) t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
} }
} }
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) { func TestLayout_DuplicateVariantChars(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// "CCC" — C appears three times. Should render C slot content three times. // "CCC" — C appears three times. Should render C slot content three times.
layout := NewLayout("CCC").C(Raw("content")) layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx) got := layout.Render(ctx)
count := countText(got, "content") count := strings.Count(got, "content")
if count != 3 { if count != 3 {
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got) t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
} }
} }
func TestLayout_EmptySlots_Ugly(t *testing.T) { func TestLayout_EmptySlots(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// Variant includes all slots but none are populated — should produce empty output. // Variant includes all slots but none are populated — should produce empty output.
@ -476,38 +388,9 @@ func TestLayout_EmptySlots_Ugly(t *testing.T) {
} }
} }
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("wrapped"))
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
got := outer.Render(ctx)
if !containsText(got, `data-block="C-0-C-0"`) {
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
}
}
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("wrapped"))
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
"match": inner,
"miss": Raw("ignored"),
}))
got := outer.Render(ctx)
if !containsText(got, `data-block="C-0-C-0"`) {
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
}
}
// --- Render convenience function edge cases --- // --- Render convenience function edge cases ---
func TestRender_NilContext_Ugly(t *testing.T) { func TestRender_NilContext(t *testing.T) {
node := Raw("test") node := Raw("test")
got := Render(node, nil) got := Render(node, nil)
if got != "test" { if got != "test" {
@ -515,7 +398,7 @@ func TestRender_NilContext_Ugly(t *testing.T) {
} }
} }
func TestImprint_NilContext_Ugly(t *testing.T) { func TestImprint_NilContext(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
@ -527,7 +410,7 @@ func TestImprint_NilContext_Ugly(t *testing.T) {
} }
} }
func TestCompareVariants_NilContext_Ugly(t *testing.T) { func TestCompareVariants_NilContext(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
@ -541,7 +424,7 @@ func TestCompareVariants_NilContext_Ugly(t *testing.T) {
} }
} }
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) { func TestCompareVariants_SingleVariant(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
@ -556,31 +439,31 @@ func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
// --- escapeHTML / escapeAttr edge cases --- // --- escapeHTML / escapeAttr edge cases ---
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) { func TestEscapeAttr_AllSpecialChars(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(El("div"), "data-val", `&<>"'`) node := Attr(El("div"), "data-val", `&<>"'`)
got := node.Render(ctx) got := node.Render(ctx)
if containsText(got, `"&<>"'"`) { if strings.Contains(got, `"&<>"'"`) {
t.Error("attribute value with special chars must be fully escaped") t.Error("attribute value with special chars must be fully escaped")
} }
if !containsText(got, "&amp;&lt;&gt;&#34;&#39;") { if !strings.Contains(got, "&amp;&lt;&gt;&quot;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got) t.Errorf("expected all special chars escaped in attribute, got: %s", got)
} }
} }
func TestElNode_EmptyTag_Ugly(t *testing.T) { func TestElNode_EmptyTag(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("", Raw("content")) node := El("", Raw("content"))
got := node.Render(ctx) got := node.Render(ctx)
// Empty tag is weird but should not panic // Empty tag is weird but should not panic
if !containsText(got, "content") { if !strings.Contains(got, "content") {
t.Errorf("El with empty tag should still render children, got %q", got) t.Errorf("El with empty tag should still render children, got %q", got)
} }
} }
func TestSwitchNode_NoMatch_Ugly(t *testing.T) { func TestSwitchNode_NoMatch(t *testing.T) {
ctx := NewContext() ctx := NewContext()
cases := map[string]Node{ cases := map[string]Node{
"a": Raw("alpha"), "a": Raw("alpha"),
@ -593,7 +476,7 @@ func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
} }
} }
func TestEntitled_NilContext_Ugly(t *testing.T) { func TestEntitled_NilContext(t *testing.T) {
node := Entitled("premium", Raw("content")) node := Entitled("premium", Raw("content"))
got := node.Render(nil) got := node.Render(nil)
if got != "" { if got != "" {

13
go.mod
View file

@ -1,21 +1,16 @@
module dappco.re/go/core/html module forge.lthn.ai/core/go-html
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1 forge.lthn.ai/core/go-i18n v0.1.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
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
require ( require (
dappco.re/go/core/inference v0.1.4 // indirect forge.lthn.ai/core/go-inference v0.1.0 // indirect
dappco.re/go/core/log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

22
go.sum
View file

@ -1,17 +1,7 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo= forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
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=
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
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=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -24,8 +14,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -3,10 +3,10 @@ package html
import ( import (
"testing" "testing"
i18n "dappco.re/go/core/i18n" i18n "forge.lthn.ai/core/go-i18n"
) )
func TestIntegration_RenderThenReverse_Good(t *testing.T) { func TestIntegration_RenderThenReverse(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -26,7 +26,7 @@ func TestIntegration_RenderThenReverse_Good(t *testing.T) {
} }
} }
func TestIntegration_ResponsiveImprint_Good(t *testing.T) { func TestIntegration_ResponsiveImprint(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()

183
layout.go
View file

@ -1,13 +1,6 @@
package html package html
import "errors" import "strings"
// Compile-time interface check.
var _ Node = (*Layout)(nil)
// ErrInvalidLayoutVariant reports that a layout variant string contains at least
// one unrecognised slot character.
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
// slotMeta holds the semantic HTML mapping for each HLCRF slot. // slotMeta holds the semantic HTML mapping for each HLCRF slot.
type slotMeta struct { type slotMeta struct {
@ -26,159 +19,48 @@ var slotRegistry = map[byte]slotMeta{
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions // Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
// with deterministic path-based IDs. // with deterministic path-based IDs.
// Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
type Layout struct { type Layout struct {
variant string // "HLCRF", "HCF", "C", etc. variant string // "HLCRF", "HCF", "C", etc.
path string // "" for root, "L-0-" for nested path string // "" for root, "L-0-" for nested
slots map[byte][]Node // H, L, C, R, F → children slots map[byte][]Node // H, L, C, R, F → children
variantErr error
}
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
if node == nil {
return ""
}
if renderer, ok := node.(layoutPathRenderer); ok {
return renderer.renderWithLayoutPath(ctx, path)
}
switch t := node.(type) {
case *Layout:
if t == nil {
return ""
}
clone := *t
clone.path = path
return clone.Render(ctx)
case *ifNode:
if t == nil || t.cond == nil || t.node == nil {
return ""
}
if t.cond(ctx) {
return renderWithLayoutPath(t.node, ctx, path)
}
return ""
case *unlessNode:
if t == nil || t.cond == nil || t.node == nil {
return ""
}
if !t.cond(ctx) {
return renderWithLayoutPath(t.node, ctx, path)
}
return ""
case *entitledNode:
if t == nil || t.node == nil {
return ""
}
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) {
return ""
}
return renderWithLayoutPath(t.node, ctx, path)
case *switchNode:
if t == nil || t.selector == nil || t.cases == nil {
return ""
}
key := t.selector(ctx)
node, ok := t.cases[key]
if !ok || node == nil {
return ""
}
return renderWithLayoutPath(node, ctx, path)
default:
return node.Render(ctx)
}
} }
// NewLayout creates a new Layout with the given variant string. // 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"). // The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
func NewLayout(variant string) *Layout { func NewLayout(variant string) *Layout {
l := &Layout{ return &Layout{
variant: variant, variant: variant,
slots: make(map[byte][]Node), slots: make(map[byte][]Node),
} }
l.variantErr = ValidateLayoutVariant(variant)
return l
}
// ValidateLayoutVariant reports whether a layout variant string contains only
// recognised slot characters.
//
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
// layoutVariantError for invalid ones.
func ValidateLayoutVariant(variant string) error {
var invalid bool
for i := range len(variant) {
if _, ok := slotRegistry[variant[i]]; ok {
continue
}
invalid = true
break
}
if !invalid {
return nil
}
return &layoutVariantError{variant: variant}
}
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. // H appends nodes to the Header slot.
// Usage example: NewLayout("HCF").H(Text("title"))
func (l *Layout) H(nodes ...Node) *Layout { func (l *Layout) H(nodes ...Node) *Layout {
if l == nil { l.slots['H'] = append(l.slots['H'], nodes...)
return nil
}
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
return l return l
} }
// L appends nodes to the Left aside slot. // L appends nodes to the Left aside slot.
// Usage example: NewLayout("LC").L(Text("nav"))
func (l *Layout) L(nodes ...Node) *Layout { func (l *Layout) L(nodes ...Node) *Layout {
if l == nil { l.slots['L'] = append(l.slots['L'], nodes...)
return nil
}
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
return l return l
} }
// C appends nodes to the Content (main) slot. // C appends nodes to the Content (main) slot.
// Usage example: NewLayout("C").C(Text("body"))
func (l *Layout) C(nodes ...Node) *Layout { func (l *Layout) C(nodes ...Node) *Layout {
if l == nil { l.slots['C'] = append(l.slots['C'], nodes...)
return nil
}
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
return l return l
} }
// R appends nodes to the Right aside slot. // R appends nodes to the Right aside slot.
// Usage example: NewLayout("CR").R(Text("ads"))
func (l *Layout) R(nodes ...Node) *Layout { func (l *Layout) R(nodes ...Node) *Layout {
if l == nil { l.slots['R'] = append(l.slots['R'], nodes...)
return nil
}
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
return l return l
} }
// F appends nodes to the Footer slot. // F appends nodes to the Footer slot.
// Usage example: NewLayout("CF").F(Text("footer"))
func (l *Layout) F(nodes ...Node) *Layout { func (l *Layout) F(nodes ...Node) *Layout {
if l == nil { l.slots['F'] = append(l.slots['F'], nodes...)
return nil
}
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
return l return l
} }
@ -187,27 +69,10 @@ func (l *Layout) blockID(slot byte) string {
return l.path + string(slot) + "-0" return l.path + string(slot) + "-0"
} }
// VariantError reports whether the layout variant string contained any invalid
// slot characters when the layout was constructed.
func (l *Layout) VariantError() error {
if l == nil {
return nil
}
return l.variantErr
}
// Render produces the semantic HTML for this layout. // 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. // Only slots present in the variant string are rendered.
func (l *Layout) Render(ctx *Context) string { func (l *Layout) Render(ctx *Context) string {
if l == nil { var b strings.Builder
return ""
}
if ctx == nil {
ctx = NewContext()
}
b := newTextBuilder()
for i := range len(l.variant) { for i := range len(l.variant) {
slot := l.variant[i] slot := l.variant[i]
@ -224,18 +89,22 @@ func (l *Layout) Render(ctx *Context) string {
bid := l.blockID(slot) bid := l.blockID(slot)
b.WriteByte('<') b.WriteByte('<')
b.WriteString(escapeHTML(meta.tag)) b.WriteString(meta.tag)
b.WriteString(` role="`) b.WriteString(` role="`)
b.WriteString(escapeAttr(meta.role)) b.WriteString(meta.role)
b.WriteString(`" data-block="`) b.WriteString(`" data-block="`)
b.WriteString(escapeAttr(bid)) b.WriteString(bid)
b.WriteString(`">`) b.WriteString(`">`)
for _, child := range children { for _, child := range children {
if child == nil { // Clone nested layouts before setting path (thread-safe).
if inner, ok := child.(*Layout); ok {
clone := *inner
clone.path = bid + "-"
b.WriteString(clone.Render(ctx))
continue continue
} }
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-")) b.WriteString(child.Render(ctx))
} }
b.WriteString("</") b.WriteString("</")
@ -245,15 +114,3 @@ func (l *Layout) Render(ctx *Context) string {
return b.String() return b.String()
} }
type layoutVariantError struct {
variant string
}
func (e *layoutVariantError) Error() string {
return "html: invalid layout variant " + e.variant
}
func (e *layoutVariantError) Unwrap() error {
return ErrInvalidLayoutVariant
}

View file

@ -1,10 +1,11 @@
package html package html
import ( import (
"strings"
"testing" "testing"
) )
func TestLayout_HLCRF_Good(t *testing.T) { func TestLayout_HLCRF(t *testing.T) {
ctx := NewContext() ctx := NewContext()
layout := NewLayout("HLCRF"). layout := NewLayout("HLCRF").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer")) H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
@ -12,34 +13,34 @@ func TestLayout_HLCRF_Good(t *testing.T) {
// Must contain semantic elements // Must contain semantic elements
for _, want := range []string{"<header", "<aside", "<main", "<footer"} { for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got) t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
} }
} }
// Must contain ARIA roles // Must contain ARIA roles
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} { for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got) t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
} }
} }
// Must contain data-block IDs // Must contain data-block IDs
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} { for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got) t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
} }
} }
// Must contain content // Must contain content
for _, want := range []string{"header", "left", "main", "right", "footer"} { for _, want := range []string{"header", "left", "main", "right", "footer"} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got) t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
} }
} }
} }
func TestLayout_HCF_Good(t *testing.T) { func TestLayout_HCF(t *testing.T) {
ctx := NewContext() ctx := NewContext()
layout := NewLayout("HCF"). layout := NewLayout("HCF").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer")) H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
@ -47,42 +48,42 @@ func TestLayout_HCF_Good(t *testing.T) {
// HCF should have header, main, footer // HCF should have header, main, footer
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} { for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("HCF layout missing %q in:\n%s", want, got) t.Errorf("HCF layout missing %q in:\n%s", want, got)
} }
} }
// HCF must NOT have L or R slots // HCF must NOT have L or R slots
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} { for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
if containsText(got, unwanted) { if strings.Contains(got, unwanted) {
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got) t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
} }
} }
} }
func TestLayout_ContentOnly_Good(t *testing.T) { func TestLayout_ContentOnly(t *testing.T) {
ctx := NewContext() ctx := NewContext()
layout := NewLayout("C"). layout := NewLayout("C").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer")) H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
got := layout.Render(ctx) got := layout.Render(ctx)
// Only C slot should render // Only C slot should render
if !containsText(got, `data-block="C-0"`) { if !strings.Contains(got, `data-block="C-0"`) {
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got) t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
} }
if !containsText(got, "<main") { if !strings.Contains(got, "<main") {
t.Errorf("C layout missing <main in:\n%s", got) t.Errorf("C layout missing <main in:\n%s", got)
} }
// No other slots // No other slots
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} { for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
if containsText(got, unwanted) { if strings.Contains(got, unwanted) {
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got) t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
} }
} }
} }
func TestLayout_FluentAPI_Good(t *testing.T) { func TestLayout_FluentAPI(t *testing.T) {
layout := NewLayout("HLCRF") layout := NewLayout("HLCRF")
// Fluent methods should return the same layout for chaining // Fluent methods should return the same layout for chaining
@ -97,53 +98,19 @@ func TestLayout_FluentAPI_Good(t *testing.T) {
} }
} }
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) { func TestLayout_IgnoresInvalidSlots(t *testing.T) {
ctx := NewContext() ctx := NewContext()
// "C" variant: populating L and R should have no effect // "C" variant: populating L and R should have no effect
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right")) layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
got := layout.Render(ctx) got := layout.Render(ctx)
if !containsText(got, "main") { if !strings.Contains(got, "main") {
t.Errorf("C variant should render main content, got:\n%s", got) t.Errorf("C variant should render main content, got:\n%s", got)
} }
if containsText(got, "left") { if strings.Contains(got, "left") {
t.Errorf("C variant should ignore L slot content, got:\n%s", got) t.Errorf("C variant should ignore L slot content, got:\n%s", got)
} }
if containsText(got, "right") { if strings.Contains(got, "right") {
t.Errorf("C variant should ignore R slot content, got:\n%s", got) 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)
}
}
func TestLayout_Render_NilContext_Good(t *testing.T) {
layout := NewLayout("C").C(Raw("content"))
got := layout.Render(nil)
want := `<main role="main" data-block="C-0">content</main>`
if got != want {
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
}
}

175
node.go
View file

@ -1,35 +1,19 @@
package html package html
import ( import (
"html"
"iter" "iter"
"maps" "maps"
"slices" "slices"
"strconv" "strings"
i18n "forge.lthn.ai/core/go-i18n"
) )
// Node is anything renderable. // Node is anything renderable.
// Usage example: var n Node = El("div", Text("welcome"))
type Node interface { type Node interface {
Render(ctx *Context) string Render(ctx *Context) string
} }
// Compile-time interface checks.
var (
_ Node = (*rawNode)(nil)
_ Node = (*elNode)(nil)
_ Node = (*textNode)(nil)
_ Node = (*ifNode)(nil)
_ Node = (*unlessNode)(nil)
_ Node = (*entitledNode)(nil)
_ Node = (*switchNode)(nil)
_ Node = (*eachNode[any])(nil)
)
type layoutPathRenderer interface {
renderWithLayoutPath(ctx *Context, path string) string
}
// voidElements is the set of HTML elements that must not have a closing tag. // voidElements is the set of HTML elements that must not have a closing tag.
var voidElements = map[string]bool{ var voidElements = map[string]bool{
"area": true, "area": true,
@ -49,7 +33,12 @@ var voidElements = map[string]bool{
// escapeAttr escapes a string for use in an HTML attribute value. // escapeAttr escapes a string for use in an HTML attribute value.
func escapeAttr(s string) string { func escapeAttr(s string) string {
return html.EscapeString(s) s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
} }
// --- rawNode --- // --- rawNode ---
@ -59,15 +48,11 @@ type rawNode struct {
} }
// Raw creates a node that renders without escaping (escape hatch for trusted content). // Raw creates a node that renders without escaping (escape hatch for trusted content).
// Usage example: Raw("<strong>trusted</strong>")
func Raw(content string) Node { func Raw(content string) Node {
return &rawNode{content: content} return &rawNode{content: content}
} }
func (n *rawNode) Render(_ *Context) string { func (n *rawNode) Render(_ *Context) string {
if n == nil {
return ""
}
return n.content return n.content
} }
@ -80,7 +65,6 @@ type elNode struct {
} }
// El creates an HTML element node with children. // El creates an HTML element node with children.
// Usage example: El("section", Text("welcome"))
func El(tag string, children ...Node) Node { func El(tag string, children ...Node) Node {
return &elNode{ return &elNode{
tag: tag, tag: tag,
@ -90,78 +74,26 @@ func El(tag string, children ...Node) Node {
} }
// Attr sets an attribute on an El node. Returns the node for chaining. // Attr sets an attribute on an El node. Returns the node for chaining.
// Usage example: Attr(El("a", Text("docs")), "href", "/docs") // If the node is not an *elNode, returns it unchanged.
// It recursively traverses through wrappers like If, Unless, Entitled, and Each.
func Attr(n Node, key, value string) Node { func Attr(n Node, key, value string) Node {
if n == nil { if el, ok := n.(*elNode); ok {
return n el.attrs[key] = value
}
switch t := n.(type) {
case *elNode:
t.attrs[key] = value
case *ifNode:
Attr(t.node, key, value)
case *unlessNode:
Attr(t.node, key, value)
case *entitledNode:
Attr(t.node, key, value)
case *switchNode:
for _, child := range t.cases {
Attr(child, key, value)
}
case attrApplier:
t.applyAttr(key, value)
} }
return n return n
} }
// AriaLabel sets an aria-label attribute on an element node.
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
func AriaLabel(n Node, label string) Node {
return Attr(n, "aria-label", label)
}
// AltText sets an alt attribute on an element node.
// Usage example: AltText(El("img"), "Profile photo")
func AltText(n Node, text string) Node {
return Attr(n, "alt", text)
}
// TabIndex sets a tabindex attribute on an element node.
// Usage example: TabIndex(El("button", Text("save")), 0)
func TabIndex(n Node, index int) Node {
return Attr(n, "tabindex", strconv.Itoa(index))
}
// AutoFocus sets an autofocus attribute on an element node.
// Usage example: AutoFocus(El("input"))
func AutoFocus(n Node) Node {
return Attr(n, "autofocus", "autofocus")
}
// Role sets a role attribute on an element node.
// Usage example: Role(El("nav", Text("links")), "navigation")
func Role(n Node, role string) Node {
return Attr(n, "role", role)
}
func (n *elNode) Render(ctx *Context) string { func (n *elNode) Render(ctx *Context) string {
if n == nil { var b strings.Builder
return ""
}
b := newTextBuilder()
b.WriteByte('<') b.WriteByte('<')
b.WriteString(escapeHTML(n.tag)) b.WriteString(n.tag)
// Sort attribute keys for deterministic output. // Sort attribute keys for deterministic output.
keys := slices.Collect(maps.Keys(n.attrs)) keys := slices.Collect(maps.Keys(n.attrs))
slices.Sort(keys) slices.Sort(keys)
for _, key := range keys { for _, key := range keys {
b.WriteByte(' ') b.WriteByte(' ')
b.WriteString(escapeHTML(key)) b.WriteString(key)
b.WriteString(`="`) b.WriteString(`="`)
b.WriteString(escapeAttr(n.attrs[key])) b.WriteString(escapeAttr(n.attrs[key]))
b.WriteByte('"') b.WriteByte('"')
@ -174,14 +106,11 @@ func (n *elNode) Render(ctx *Context) string {
} }
for i := range len(n.children) { for i := range len(n.children) {
if n.children[i] == nil {
continue
}
b.WriteString(n.children[i].Render(ctx)) b.WriteString(n.children[i].Render(ctx))
} }
b.WriteString("</") b.WriteString("</")
b.WriteString(escapeHTML(n.tag)) b.WriteString(n.tag)
b.WriteByte('>') b.WriteByte('>')
return b.String() return b.String()
@ -191,7 +120,12 @@ func (n *elNode) Render(ctx *Context) string {
// escapeHTML escapes a string for safe inclusion in HTML text content. // escapeHTML escapes a string for safe inclusion in HTML text content.
func escapeHTML(s string) string { func escapeHTML(s string) string {
return html.EscapeString(s) s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
} }
// --- textNode --- // --- textNode ---
@ -202,17 +136,19 @@ type textNode struct {
} }
// Text creates a node that renders through the go-i18n grammar pipeline. // 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. // Output is HTML-escaped by default. Safe-by-default path.
func Text(key string, args ...any) Node { func Text(key string, args ...any) Node {
return &textNode{key: key, args: args} return &textNode{key: key, args: args}
} }
func (n *textNode) Render(ctx *Context) string { func (n *textNode) Render(ctx *Context) string {
if n == nil { var text string
return "" if ctx != nil && ctx.service != nil {
text = ctx.service.T(n.key, n.args...)
} else {
text = i18n.T(n.key, n.args...)
} }
return escapeHTML(translateText(ctx, n.key, n.args...)) return escapeHTML(text)
} }
// --- ifNode --- // --- ifNode ---
@ -223,15 +159,11 @@ type ifNode struct {
} }
// If renders child only when condition is true. // If renders child only when condition is true.
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
func If(cond func(*Context) bool, node Node) Node { func If(cond func(*Context) bool, node Node) Node {
return &ifNode{cond: cond, node: node} return &ifNode{cond: cond, node: node}
} }
func (n *ifNode) Render(ctx *Context) string { func (n *ifNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if n.cond(ctx) { if n.cond(ctx) {
return n.node.Render(ctx) return n.node.Render(ctx)
} }
@ -246,15 +178,11 @@ type unlessNode struct {
} }
// Unless renders child only when condition is false. // Unless renders child only when condition is false.
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
func Unless(cond func(*Context) bool, node Node) Node { func Unless(cond func(*Context) bool, node Node) Node {
return &unlessNode{cond: cond, node: node} return &unlessNode{cond: cond, node: node}
} }
func (n *unlessNode) Render(ctx *Context) string { func (n *unlessNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if !n.cond(ctx) { if !n.cond(ctx) {
return n.node.Render(ctx) return n.node.Render(ctx)
} }
@ -269,16 +197,12 @@ type entitledNode struct {
} }
// Entitled renders child only when entitlement is granted. Absent, not hidden. // 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 no entitlement function is set on the context, access is denied by default.
func Entitled(feature string, node Node) Node { func Entitled(feature string, node Node) Node {
return &entitledNode{feature: feature, node: node} return &entitledNode{feature: feature, node: node}
} }
func (n *entitledNode) Render(ctx *Context) string { func (n *entitledNode) Render(ctx *Context) string {
if n == nil || n.node == nil {
return ""
}
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) { if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return "" return ""
} }
@ -293,23 +217,13 @@ type switchNode struct {
} }
// Switch renders based on runtime selector value. // Switch renders based on runtime selector value.
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
func Switch(selector func(*Context) string, cases map[string]Node) Node { func Switch(selector func(*Context) string, cases map[string]Node) Node {
return &switchNode{selector: selector, cases: cases} return &switchNode{selector: selector, cases: cases}
} }
func (n *switchNode) Render(ctx *Context) string { func (n *switchNode) Render(ctx *Context) string {
if n == nil || n.selector == nil {
return ""
}
key := n.selector(ctx) key := n.selector(ctx)
if n.cases == nil {
return ""
}
if node, ok := n.cases[key]; ok { if node, ok := n.cases[key]; ok {
if node == nil {
return ""
}
return node.Render(ctx) return node.Render(ctx)
} }
return "" return ""
@ -322,49 +236,20 @@ type eachNode[T any] struct {
fn func(T) Node fn func(T) Node
} }
type attrApplier interface {
applyAttr(key, value string)
}
// Each iterates items and renders each via fn. // Each iterates items and renders each via fn.
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
func Each[T any](items []T, fn func(T) Node) Node { func Each[T any](items []T, fn func(T) Node) Node {
return EachSeq(slices.Values(items), fn) return EachSeq(slices.Values(items), fn)
} }
// EachSeq iterates an iter.Seq and renders each via fn. // 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) })
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node { func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
return &eachNode[T]{items: items, fn: fn} return &eachNode[T]{items: items, fn: fn}
} }
func (n *eachNode[T]) Render(ctx *Context) string { func (n *eachNode[T]) Render(ctx *Context) string {
return n.renderWithLayoutPath(ctx, "") var b strings.Builder
}
func (n *eachNode[T]) applyAttr(key, value string) {
if n == nil || n.fn == nil {
return
}
prev := n.fn
n.fn = func(item T) Node {
return Attr(prev(item), key, value)
}
}
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
if n == nil || n.fn == nil || n.items == nil {
return ""
}
b := newTextBuilder()
for item := range n.items { for item := range n.items {
child := n.fn(item) b.WriteString(n.fn(item).Render(ctx))
if child == nil {
continue
}
b.WriteString(renderWithLayoutPath(child, ctx, path))
} }
return b.String() return b.String()
} }

View file

@ -1,13 +1,11 @@
package html package html
import ( import (
"strings"
"testing" "testing"
i18n "dappco.re/go/core/i18n"
"slices"
) )
func TestRawNode_Render_Good(t *testing.T) { func TestRawNode_Render(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Raw("hello") node := Raw("hello")
got := node.Render(ctx) got := node.Render(ctx)
@ -16,7 +14,7 @@ func TestRawNode_Render_Good(t *testing.T) {
} }
} }
func TestElNode_Render_Good(t *testing.T) { func TestElNode_Render(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("div", Raw("content")) node := El("div", Raw("content"))
got := node.Render(ctx) got := node.Render(ctx)
@ -26,7 +24,7 @@ func TestElNode_Render_Good(t *testing.T) {
} }
} }
func TestElNode_Nested_Good(t *testing.T) { func TestElNode_Nested(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("div", El("span", Raw("inner"))) node := El("div", El("span", Raw("inner")))
got := node.Render(ctx) got := node.Render(ctx)
@ -36,7 +34,7 @@ func TestElNode_Nested_Good(t *testing.T) {
} }
} }
func TestElNode_MultipleChildren_Good(t *testing.T) { func TestElNode_MultipleChildren(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("div", Raw("a"), Raw("b")) node := El("div", Raw("a"), Raw("b"))
got := node.Render(ctx) got := node.Render(ctx)
@ -46,7 +44,7 @@ func TestElNode_MultipleChildren_Good(t *testing.T) {
} }
} }
func TestElNode_VoidElement_Good(t *testing.T) { func TestElNode_VoidElement(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := El("br") node := El("br")
got := node.Render(ctx) got := node.Render(ctx)
@ -56,7 +54,7 @@ func TestElNode_VoidElement_Good(t *testing.T) {
} }
} }
func TestTextNode_Render_Good(t *testing.T) { func TestTextNode_Render(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Text("hello") node := Text("hello")
got := node.Render(ctx) got := node.Render(ctx)
@ -65,19 +63,19 @@ func TestTextNode_Render_Good(t *testing.T) {
} }
} }
func TestTextNode_Escapes_Good(t *testing.T) { func TestTextNode_Escapes(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Text("<script>alert('xss')</script>") node := Text("<script>alert('xss')</script>")
got := node.Render(ctx) got := node.Render(ctx)
if containsText(got, "<script>") { if strings.Contains(got, "<script>") {
t.Errorf("Text node must HTML-escape output, got %q", got) t.Errorf("Text node must HTML-escape output, got %q", got)
} }
if !containsText(got, "&lt;script&gt;") { if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got) t.Errorf("Text node should contain escaped script tag, got %q", got)
} }
} }
func TestIfNode_True_Good(t *testing.T) { func TestIfNode_True(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := If(func(*Context) bool { return true }, Raw("visible")) node := If(func(*Context) bool { return true }, Raw("visible"))
got := node.Render(ctx) got := node.Render(ctx)
@ -86,7 +84,7 @@ func TestIfNode_True_Good(t *testing.T) {
} }
} }
func TestIfNode_False_Good(t *testing.T) { func TestIfNode_False(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := If(func(*Context) bool { return false }, Raw("hidden")) node := If(func(*Context) bool { return false }, Raw("hidden"))
got := node.Render(ctx) got := node.Render(ctx)
@ -95,7 +93,7 @@ func TestIfNode_False_Good(t *testing.T) {
} }
} }
func TestUnlessNode_False_Good(t *testing.T) { func TestUnlessNode(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Unless(func(*Context) bool { return false }, Raw("visible")) node := Unless(func(*Context) bool { return false }, Raw("visible"))
got := node.Render(ctx) got := node.Render(ctx)
@ -104,7 +102,7 @@ func TestUnlessNode_False_Good(t *testing.T) {
} }
} }
func TestEntitledNode_Granted_Good(t *testing.T) { func TestEntitledNode_Granted(t *testing.T) {
ctx := NewContext() ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return feature == "premium" } ctx.Entitlements = func(feature string) bool { return feature == "premium" }
node := Entitled("premium", Raw("premium content")) node := Entitled("premium", Raw("premium content"))
@ -114,7 +112,7 @@ func TestEntitledNode_Granted_Good(t *testing.T) {
} }
} }
func TestEntitledNode_Denied_Bad(t *testing.T) { func TestEntitledNode_Denied(t *testing.T) {
ctx := NewContext() ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return false } ctx.Entitlements = func(feature string) bool { return false }
node := Entitled("premium", Raw("premium content")) node := Entitled("premium", Raw("premium content"))
@ -124,7 +122,7 @@ func TestEntitledNode_Denied_Bad(t *testing.T) {
} }
} }
func TestEntitledNode_NoFunc_Bad(t *testing.T) { func TestEntitledNode_NoFunc(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Entitled("premium", Raw("premium content")) node := Entitled("premium", Raw("premium content"))
got := node.Render(ctx) got := node.Render(ctx)
@ -133,7 +131,7 @@ func TestEntitledNode_NoFunc_Bad(t *testing.T) {
} }
} }
func TestEachNode_Render_Good(t *testing.T) { func TestEachNode(t *testing.T) {
ctx := NewContext() ctx := NewContext()
items := []string{"a", "b", "c"} items := []string{"a", "b", "c"}
node := Each(items, func(item string) Node { node := Each(items, func(item string) Node {
@ -146,7 +144,7 @@ func TestEachNode_Render_Good(t *testing.T) {
} }
} }
func TestEachNode_Empty_Good(t *testing.T) { func TestEachNode_Empty(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Each([]string{}, func(item string) Node { node := Each([]string{}, func(item string) Node {
return El("li", Raw(item)) return El("li", Raw(item))
@ -157,35 +155,7 @@ func TestEachNode_Empty_Good(t *testing.T) {
} }
} }
func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) { func TestElNode_Attr(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("item"))
node := Each([]Node{inner}, func(item Node) Node {
return item
})
got := NewLayout("C").C(node).Render(ctx)
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
if got != want {
t.Fatalf("Each nested layout render = %q, want %q", got, want)
}
}
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("item"))
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
return item
})
got := NewLayout("C").C(node).Render(ctx)
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
if got != want {
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
}
}
func TestElNode_Attr_Good(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(El("div", Raw("content")), "class", "container") node := Attr(El("div", Raw("content")), "class", "container")
got := node.Render(ctx) got := node.Render(ctx)
@ -195,70 +165,25 @@ func TestElNode_Attr_Good(t *testing.T) {
} }
} }
func TestElNode_AttrEscaping_Good(t *testing.T) { func TestElNode_AttrEscaping(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`) node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx) got := node.Render(ctx)
if !containsText(got, `alt="he said &#34;hello&#34;"`) { if !strings.Contains(got, `alt="he said &quot;hello&quot;"`) {
t.Errorf("Attr should escape attribute values, got %q", got) t.Errorf("Attr should escape attribute values, got %q", got)
} }
} }
func TestAriaLabel_Good(t *testing.T) { func TestElNode_MultipleAttrs(t *testing.T) {
node := AriaLabel(El("button", Raw("save")), "Save changes")
got := node.Render(NewContext())
want := `<button aria-label="Save changes">save</button>`
if got != want {
t.Errorf("AriaLabel() = %q, want %q", got, want)
}
}
func TestAltText_Good(t *testing.T) {
node := AltText(El("img"), "Profile photo")
got := node.Render(NewContext())
want := `<img alt="Profile photo">`
if got != want {
t.Errorf("AltText() = %q, want %q", got, want)
}
}
func TestTabIndex_Good(t *testing.T) {
node := TabIndex(El("button", Raw("save")), 0)
got := node.Render(NewContext())
want := `<button tabindex="0">save</button>`
if got != want {
t.Errorf("TabIndex() = %q, want %q", got, want)
}
}
func TestAutoFocus_Good(t *testing.T) {
node := AutoFocus(El("input"))
got := node.Render(NewContext())
want := `<input autofocus="autofocus">`
if got != want {
t.Errorf("AutoFocus() = %q, want %q", got, want)
}
}
func TestRole_Good(t *testing.T) {
node := Role(El("nav", Raw("links")), "navigation")
got := node.Render(NewContext())
want := `<nav role="navigation">links</nav>`
if got != want {
t.Errorf("Role() = %q, want %q", got, want)
}
}
func TestElNode_MultipleAttrs_Good(t *testing.T) {
ctx := NewContext() ctx := NewContext()
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav") node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
got := node.Render(ctx) got := node.Render(ctx)
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) { if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
t.Errorf("multiple Attr() calls should stack, got %q", got) t.Errorf("multiple Attr() calls should stack, got %q", got)
} }
} }
func TestAttr_NonElement_Ugly(t *testing.T) { func TestAttr_NonElement(t *testing.T) {
node := Attr(Raw("text"), "class", "x") node := Attr(Raw("text"), "class", "x")
got := node.Render(NewContext()) got := node.Render(NewContext())
if got != "text" { if got != "text" {
@ -266,106 +191,7 @@ func TestAttr_NonElement_Ugly(t *testing.T) {
} }
} }
func TestUnlessNode_True_Good(t *testing.T) { func TestSwitchNode(t *testing.T) {
ctx := NewContext()
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
got := node.Render(ctx)
if got != "" {
t.Errorf("Unless(true) = %q, want %q", got, "")
}
}
func TestAttr_ThroughIfNode_Good(t *testing.T) {
ctx := NewContext()
inner := El("div", Raw("content"))
node := If(func(*Context) bool { return true }, inner)
Attr(node, "class", "wrapped")
got := node.Render(ctx)
want := `<div class="wrapped">content</div>`
if got != want {
t.Errorf("Attr through If = %q, want %q", got, want)
}
}
func TestAttr_ThroughUnlessNode_Good(t *testing.T) {
ctx := NewContext()
inner := El("div", Raw("content"))
node := Unless(func(*Context) bool { return false }, inner)
Attr(node, "id", "test")
got := node.Render(ctx)
want := `<div id="test">content</div>`
if got != want {
t.Errorf("Attr through Unless = %q, want %q", got, want)
}
}
func TestAttr_ThroughEntitledNode_Good(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(string) bool { return true }
inner := El("div", Raw("content"))
node := Entitled("feature", inner)
Attr(node, "data-feat", "on")
got := node.Render(ctx)
want := `<div data-feat="on">content</div>`
if got != want {
t.Errorf("Attr through Entitled = %q, want %q", got, want)
}
}
func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
ctx := NewContext()
inner := El("div", Raw("content"))
node := Switch(func(*Context) string { return "match" }, map[string]Node{
"match": inner,
"miss": El("span", Raw("unused")),
})
Attr(node, "data-state", "active")
got := node.Render(ctx)
want := `<div data-state="active">content</div>`
if got != want {
t.Errorf("Attr through Switch = %q, want %q", got, want)
}
}
func TestAttr_ThroughEachNode_Good(t *testing.T) {
ctx := NewContext()
node := Each([]string{"a", "b"}, func(item string) Node {
return El("span", Raw(item))
})
Attr(node, "class", "item")
got := node.Render(ctx)
want := `<span class="item">a</span><span class="item">b</span>`
if got != want {
t.Errorf("Attr through Each = %q, want %q", got, want)
}
}
func TestAttr_ThroughEachSeqNode_Good(t *testing.T) {
ctx := NewContext()
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
return El("span", Raw(item))
})
Attr(node, "data-kind", "item")
got := node.Render(ctx)
want := `<span data-kind="item">a</span><span data-kind="item">b</span>`
if got != want {
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
}
}
func TestTextNode_WithService_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc)
node := Text("hello")
got := node.Render(ctx)
if got != "hello" {
t.Errorf("Text with service context = %q, want %q", got, "hello")
}
}
func TestSwitchNode_SelectsMatch_Good(t *testing.T) {
ctx := NewContext() ctx := NewContext()
cases := map[string]Node{ cases := map[string]Node{
"dark": Raw("dark theme"), "dark": Raw("dark theme"),

21
path.go
View file

@ -3,26 +3,21 @@ package html
import "strings" import "strings"
// ParseBlockID extracts the slot sequence from a data-block ID. // ParseBlockID extracts the slot sequence from a data-block ID.
// Usage example: slots := ParseBlockID("L-0-C-0")
// "L-0-C-0" → ['L', 'C'] // "L-0-C-0" → ['L', 'C']
func ParseBlockID(id string) []byte { func ParseBlockID(id string) []byte {
if id == "" { if id == "" {
return nil return nil
} }
// Valid IDs are exact sequences of "{slot}-0" segments, e.g. // Split on "-" and take every other element (the slot letters).
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID. // Format: "X-0" or "X-0-Y-0-Z-0"
parts := strings.Split(id, "-") var slots []byte
if len(parts)%2 != 0 { i := 0
return nil for part := range strings.SplitSeq(id, "-") {
} if i%2 == 0 && len(part) == 1 {
slots = append(slots, part[0])
slots := make([]byte, 0, len(parts)/2)
for i := 0; i < len(parts); i += 2 {
if len(parts[i]) != 1 || parts[i+1] != "0" {
return nil
} }
slots = append(slots, parts[i][0]) i++
} }
return slots return slots
} }

View file

@ -1,10 +1,11 @@
package html package html
import ( import (
"strings"
"testing" "testing"
) )
func TestNestedLayout_PathChain_Good(t *testing.T) { func TestNestedLayout_PathChain(t *testing.T) {
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f")) inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
outer := NewLayout("HLCRF"). outer := NewLayout("HLCRF").
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer")) H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
@ -12,33 +13,33 @@ func TestNestedLayout_PathChain_Good(t *testing.T) {
// Inner layout paths must be prefixed with parent block ID // Inner layout paths must be prefixed with parent block ID
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} { for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("nested layout missing %q in:\n%s", want, got) t.Errorf("nested layout missing %q in:\n%s", want, got)
} }
} }
// Outer layout must still have root-level paths // Outer layout must still have root-level paths
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} { for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("outer layout missing %q in:\n%s", want, got) t.Errorf("outer layout missing %q in:\n%s", want, got)
} }
} }
} }
func TestNestedLayout_DeepNesting_Ugly(t *testing.T) { func TestNestedLayout_DeepNesting(t *testing.T) {
deepest := NewLayout("C").C(Raw("deep")) deepest := NewLayout("C").C(Raw("deep"))
middle := NewLayout("C").C(deepest) middle := NewLayout("C").C(deepest)
outer := NewLayout("C").C(middle) outer := NewLayout("C").C(middle)
got := outer.Render(NewContext()) got := outer.Render(NewContext())
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} { for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("deep nesting missing %q in:\n%s", want, got) t.Errorf("deep nesting missing %q in:\n%s", want, got)
} }
} }
} }
func TestBlockID_BuildsPath_Good(t *testing.T) { func TestBlockID(t *testing.T) {
tests := []struct { tests := []struct {
path string path string
slot byte slot byte
@ -59,7 +60,7 @@ func TestBlockID_BuildsPath_Good(t *testing.T) {
} }
} }
func TestParseBlockID_ExtractsSlots_Good(t *testing.T) { func TestParseBlockID(t *testing.T) {
tests := []struct { tests := []struct {
id string id string
want []byte want []byte
@ -83,18 +84,3 @@ func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
} }
} }
} }
func TestParseBlockID_InvalidInput_Good(t *testing.T) {
tests := []string{
"L-1-C-0",
"L-0-C",
"L-0-",
"X",
}
for _, id := range tests {
if got := ParseBlockID(id); got != nil {
t.Errorf("ParseBlockID(%q) = %v, want nil", id, got)
}
}
}

View file

@ -3,17 +3,16 @@
package html package html
import ( import (
core "dappco.re/go/core" "strings"
"dappco.re/go/core/i18n/reversal" "forge.lthn.ai/core/go-i18n/reversal"
) )
// StripTags removes HTML tags from rendered output, returning plain text. // StripTags removes HTML tags from rendered output, returning plain text.
// Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
// Tag boundaries are collapsed into single spaces; result is trimmed. // Tag boundaries are collapsed into single spaces; result is trimmed.
// Does not handle script/style element content (go-html does not generate these). // Does not handle script/style element content (go-html does not generate these).
func StripTags(html string) string { func StripTags(html string) string {
b := core.NewBuilder() var b strings.Builder
inTag := false inTag := false
prevSpace := true // starts true to trim leading space prevSpace := true // starts true to trim leading space
for _, r := range html { for _, r := range html {
@ -41,20 +40,16 @@ func StripTags(html string) string {
} }
} }
} }
return core.Trim(b.String()) return strings.TrimSpace(b.String())
} }
// Imprint renders a node tree to HTML, strips tags, tokenises the text, // Imprint renders a node tree to HTML, strips tags, tokenises the text,
// and returns a GrammarImprint — the full render-reverse pipeline. // and returns a GrammarImprint — the full render-reverse pipeline.
// Usage example: imp := Imprint(Text("welcome"), NewContext())
func Imprint(node Node, ctx *Context) reversal.GrammarImprint { func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
if ctx == nil { if ctx == nil {
ctx = NewContext() ctx = NewContext()
} }
rendered := "" rendered := node.Render(ctx)
if node != nil {
rendered = node.Render(ctx)
}
text := StripTags(rendered) text := StripTags(rendered)
tok := reversal.NewTokeniser() tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text) tokens := tok.Tokenise(text)
@ -63,14 +58,10 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
// CompareVariants runs the imprint pipeline on each responsive variant independently // CompareVariants runs the imprint pipeline on each responsive variant independently
// and returns pairwise similarity scores. Key format: "name1:name2". // and returns pairwise similarity scores. Key format: "name1:name2".
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 { func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if ctx == nil { if ctx == nil {
ctx = NewContext() ctx = NewContext()
} }
if r == nil {
return make(map[string]float64)
}
type named struct { type named struct {
name string name string
@ -79,9 +70,6 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
var imprints []named var imprints []named
for _, v := range r.variants { for _, v := range r.variants {
if v.layout == nil {
continue
}
imp := Imprint(v.layout, ctx) imp := Imprint(v.layout, ctx)
imprints = append(imprints, named{name: v.name, imp: imp}) imprints = append(imprints, named{name: v.name, imp: imp})
} }

View file

@ -5,10 +5,10 @@ package html
import ( import (
"testing" "testing"
i18n "dappco.re/go/core/i18n" i18n "forge.lthn.ai/core/go-i18n"
) )
func TestStripTags_Simple_Good(t *testing.T) { func TestStripTags_Simple(t *testing.T) {
got := StripTags(`<div>hello</div>`) got := StripTags(`<div>hello</div>`)
want := "hello" want := "hello"
if got != want { if got != want {
@ -16,7 +16,7 @@ func TestStripTags_Simple_Good(t *testing.T) {
} }
} }
func TestStripTags_Nested_Good(t *testing.T) { func TestStripTags_Nested(t *testing.T) {
got := StripTags(`<header role="banner"><h1>Title</h1></header>`) got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
want := "Title" want := "Title"
if got != want { if got != want {
@ -24,7 +24,7 @@ func TestStripTags_Nested_Good(t *testing.T) {
} }
} }
func TestStripTags_MultipleRegions_Good(t *testing.T) { func TestStripTags_MultipleRegions(t *testing.T) {
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`) got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
want := "Head Body Foot" want := "Head Body Foot"
if got != want { if got != want {
@ -32,21 +32,21 @@ func TestStripTags_MultipleRegions_Good(t *testing.T) {
} }
} }
func TestStripTags_Empty_Ugly(t *testing.T) { func TestStripTags_Empty(t *testing.T) {
got := StripTags("") got := StripTags("")
if got != "" { if got != "" {
t.Errorf("StripTags(\"\") = %q, want empty", got) t.Errorf("StripTags(\"\") = %q, want empty", got)
} }
} }
func TestStripTags_NoTags_Good(t *testing.T) { func TestStripTags_NoTags(t *testing.T) {
got := StripTags("plain text") got := StripTags("plain text")
if got != "plain text" { if got != "plain text" {
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text") t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
} }
} }
func TestStripTags_Entities_Good(t *testing.T) { func TestStripTags_Entities(t *testing.T) {
got := StripTags(`&lt;script&gt;`) got := StripTags(`&lt;script&gt;`)
want := "&lt;script&gt;" want := "&lt;script&gt;"
if got != want { if got != want {
@ -54,7 +54,7 @@ func TestStripTags_Entities_Good(t *testing.T) {
} }
} }
func TestImprint_FromNode_Good(t *testing.T) { func TestImprint_FromNode(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -74,7 +74,7 @@ func TestImprint_FromNode_Good(t *testing.T) {
} }
} }
func TestImprint_SimilarPages_Good(t *testing.T) { func TestImprint_SimilarPages(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -102,7 +102,7 @@ func TestImprint_SimilarPages_Good(t *testing.T) {
} }
} }
func TestCompareVariants_SameContent_Good(t *testing.T) { func TestCompareVariants(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()

View file

@ -1,11 +1,7 @@
package html package html
// Render is a convenience function that renders a node tree to 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 { func Render(node Node, ctx *Context) string {
if node == nil {
return ""
}
if ctx == nil { if ctx == nil {
ctx = NewContext() ctx = NewContext()
} }

View file

@ -1,12 +1,13 @@
package html package html
import ( import (
"strings"
"testing" "testing"
i18n "dappco.re/go/core/i18n" i18n "forge.lthn.ai/core/go-i18n"
) )
func TestRender_FullPage_Good(t *testing.T) { func TestRender_FullPage(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -27,14 +28,14 @@ func TestRender_FullPage_Good(t *testing.T) {
// Contains semantic elements // Contains semantic elements
for _, want := range []string{"<header", "<main", "<footer"} { for _, want := range []string{"<header", "<main", "<footer"} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("full page missing semantic element %q in:\n%s", want, got) t.Errorf("full page missing semantic element %q in:\n%s", want, got)
} }
} }
// Content rendered // Content rendered
for _, want := range []string{"Dashboard", "Welcome", "Home"} { for _, want := range []string{"Dashboard", "Welcome", "Home"} {
if !containsText(got, want) { if !strings.Contains(got, want) {
t.Errorf("full page missing content %q in:\n%s", want, got) t.Errorf("full page missing content %q in:\n%s", want, got)
} }
} }
@ -43,13 +44,13 @@ func TestRender_FullPage_Good(t *testing.T) {
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} { for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
open := "<" + tag open := "<" + tag
close := "</" + tag + ">" close := "</" + tag + ">"
if countText(got, open) != countText(got, close) { if strings.Count(got, open) != strings.Count(got, close) {
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got) t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
} }
} }
} }
func TestRender_EntitlementGating_Good(t *testing.T) { func TestRender_EntitlementGating(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -66,18 +67,18 @@ func TestRender_EntitlementGating_Good(t *testing.T) {
got := page.Render(ctx) got := page.Render(ctx)
if !containsText(got, "public") { if !strings.Contains(got, "public") {
t.Errorf("entitlement gating should render public content, got:\n%s", got) t.Errorf("entitlement gating should render public content, got:\n%s", got)
} }
if !containsText(got, "admin-panel") { if !strings.Contains(got, "admin-panel") {
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got) t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
} }
if containsText(got, "premium-content") { if strings.Contains(got, "premium-content") {
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got) t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
} }
} }
func TestRender_XSSPrevention_Good(t *testing.T) { func TestRender_XSSPrevention(t *testing.T) {
svc, _ := i18n.New() svc, _ := i18n.New()
i18n.SetDefault(svc) i18n.SetDefault(svc)
ctx := NewContext() ctx := NewContext()
@ -87,10 +88,10 @@ func TestRender_XSSPrevention_Good(t *testing.T) {
got := page.Render(ctx) got := page.Render(ctx)
if containsText(got, "<script>") { if strings.Contains(got, "<script>") {
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got) t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
} }
if !containsText(got, "&lt;script&gt;") { if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got) t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
} }
} }

View file

@ -1,15 +1,8 @@
package html package html
import ( import "strings"
"strconv"
"strings"
)
// Compile-time interface check.
var _ Node = (*Responsive)(nil)
// Responsive wraps multiple Layout variants for breakpoint-aware rendering. // 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. // Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct { type Responsive struct {
variants []responsiveVariant variants []responsiveVariant
@ -21,38 +14,21 @@ type responsiveVariant struct {
} }
// NewResponsive creates a new multi-variant responsive compositor. // NewResponsive creates a new multi-variant responsive compositor.
// Usage example: r := NewResponsive()
func NewResponsive() *Responsive { func NewResponsive() *Responsive {
return &Responsive{} return &Responsive{}
} }
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile"). // Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
// Variants render in insertion order. // Variants render in insertion order.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive { func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
if r == nil {
r = NewResponsive()
}
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout}) r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
return r return r
} }
// Render produces HTML with each variant in a data-variant container. // 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 { func (r *Responsive) Render(ctx *Context) string {
if r == nil { var b strings.Builder
return ""
}
if ctx == nil {
ctx = NewContext()
}
b := newTextBuilder()
for _, v := range r.variants { for _, v := range r.variants {
if v.layout == nil {
continue
}
b.WriteString(`<div data-variant="`) b.WriteString(`<div data-variant="`)
b.WriteString(escapeAttr(v.name)) b.WriteString(escapeAttr(v.name))
b.WriteString(`">`) b.WriteString(`">`)
@ -61,36 +37,3 @@ func (r *Responsive) Render(ctx *Context) string {
} }
return b.String() return b.String()
} }
// VariantSelector returns a CSS attribute selector for a responsive variant.
// Usage example: selector := VariantSelector("desktop")
func VariantSelector(name string) string {
return `[data-variant="` + escapeCSSString(name) + `"]`
}
func escapeCSSString(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch r {
case '\\', '"':
b.WriteByte('\\')
b.WriteRune(r)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
for i := 0; i < len(esc); i++ {
b.WriteByte(esc[i])
}
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}

View file

@ -1,25 +1,26 @@
package html package html
import ( import (
"strings"
"testing" "testing"
) )
func TestResponsive_SingleVariant_Good(t *testing.T) { func TestResponsive_SingleVariant(t *testing.T) {
ctx := NewContext() ctx := NewContext()
r := NewResponsive(). r := NewResponsive().
Variant("desktop", NewLayout("HLCRF"). Variant("desktop", NewLayout("HLCRF").
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer"))) H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
got := r.Render(ctx) got := r.Render(ctx)
if !containsText(got, `data-variant="desktop"`) { if !strings.Contains(got, `data-variant="desktop"`) {
t.Errorf("responsive should contain data-variant, got:\n%s", got) t.Errorf("responsive should contain data-variant, got:\n%s", got)
} }
if !containsText(got, `data-block="H-0"`) { if !strings.Contains(got, `data-block="H-0"`) {
t.Errorf("responsive should contain layout content, got:\n%s", got) t.Errorf("responsive should contain layout content, got:\n%s", got)
} }
} }
func TestResponsive_MultiVariant_Good(t *testing.T) { func TestResponsive_MultiVariant(t *testing.T) {
ctx := NewContext() ctx := NewContext()
r := NewResponsive(). r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))). Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
@ -29,13 +30,13 @@ func TestResponsive_MultiVariant_Good(t *testing.T) {
got := r.Render(ctx) got := r.Render(ctx)
for _, v := range []string{"desktop", "tablet", "mobile"} { for _, v := range []string{"desktop", "tablet", "mobile"} {
if !containsText(got, `data-variant="`+v+`"`) { if !strings.Contains(got, `data-variant="`+v+`"`) {
t.Errorf("responsive missing variant %q in:\n%s", v, got) t.Errorf("responsive missing variant %q in:\n%s", v, got)
} }
} }
} }
func TestResponsive_VariantOrder_Good(t *testing.T) { func TestResponsive_VariantOrder(t *testing.T) {
ctx := NewContext() ctx := NewContext()
r := NewResponsive(). r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))). Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
@ -43,8 +44,8 @@ func TestResponsive_VariantOrder_Good(t *testing.T) {
got := r.Render(ctx) got := r.Render(ctx)
di := indexText(got, `data-variant="desktop"`) di := strings.Index(got, `data-variant="desktop"`)
mi := indexText(got, `data-variant="mobile"`) mi := strings.Index(got, `data-variant="mobile"`)
if di < 0 || mi < 0 { if di < 0 || mi < 0 {
t.Fatalf("missing variants in:\n%s", got) t.Fatalf("missing variants in:\n%s", got)
} }
@ -53,7 +54,7 @@ func TestResponsive_VariantOrder_Good(t *testing.T) {
} }
} }
func TestResponsive_NestedPaths_Good(t *testing.T) { func TestResponsive_NestedPaths(t *testing.T) {
ctx := NewContext() ctx := NewContext()
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if")) inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
r := NewResponsive(). r := NewResponsive().
@ -61,15 +62,15 @@ func TestResponsive_NestedPaths_Good(t *testing.T) {
got := r.Render(ctx) got := r.Render(ctx)
if !containsText(got, `data-block="C-0-H-0"`) { if !strings.Contains(got, `data-block="C-0-H-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got) t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
} }
if !containsText(got, `data-block="C-0-C-0"`) { if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got) t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
} }
} }
func TestResponsive_VariantsIndependent_Good(t *testing.T) { func TestResponsive_VariantsIndependent(t *testing.T) {
ctx := NewContext() ctx := NewContext()
r := NewResponsive(). r := NewResponsive().
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))). Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
@ -77,60 +78,12 @@ func TestResponsive_VariantsIndependent_Good(t *testing.T) {
got := r.Render(ctx) got := r.Render(ctx)
count := countText(got, `data-block="C-0"`) count := strings.Count(got, `data-block="C-0"`)
if count != 2 { if count != 2 {
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got) t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
} }
} }
func TestResponsive_ImplementsNode_Ugly(t *testing.T) { func TestResponsive_ImplementsNode(t *testing.T) {
var _ Node = NewResponsive() 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 != `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>` {
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 := `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>`
if got != want {
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
}
}
func TestVariantSelector_Good(t *testing.T) {
got := VariantSelector("desktop")
want := `[data-variant="desktop"]`
if got != want {
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
}
}
func TestVariantSelector_Escapes_Good(t *testing.T) {
got := VariantSelector("desk\"top\\wide")
want := `[data-variant="desk\"top\\wide"]`
if got != want {
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
}
}
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
got := VariantSelector("a\tb\nc\u0007")
want := `[data-variant="a\9 b\A c\7 "]`
if got != want {
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
}
}

View file

@ -1,11 +0,0 @@
# main
**Import:** `dappco.re/go/core/html/cmd/codegen`
**Files:** 1
## Types
None.
## Functions
None.

View file

@ -1,11 +0,0 @@
# main
**Import:** `dappco.re/go/core/html/cmd/wasm`
**Files:** 2
## Types
None.
## Functions
None.

View file

@ -1,34 +0,0 @@
# 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")

View file

@ -1,34 +0,0 @@
# 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")

View file

@ -1,225 +0,0 @@
# 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("<strong>trusted</strong>")
### `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("<main>Hello <strong>world</strong></main>")
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"))

View file

@ -1,48 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
func containsText(s, substr string) bool {
return core.Contains(s, substr)
}
func countText(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexText(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexText(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 itoaText(v int) string {
return core.Sprint(v)
}

View file

@ -1,38 +0,0 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
type builderOps interface {
WriteByte(byte) error
WriteRune(rune) (int, error)
WriteString(string) (int, error)
String() string
}
type textBuilder struct {
inner builderOps
}
func newTextBuilder() *textBuilder {
return &textBuilder{inner: core.NewBuilder()}
}
func (b *textBuilder) WriteByte(c byte) error {
return b.inner.WriteByte(c)
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
return b.inner.WriteRune(r)
}
func (b *textBuilder) WriteString(s string) (int, error) {
return b.inner.WriteString(s)
}
func (b *textBuilder) String() string {
return b.inner.String()
}

View file

@ -1,33 +0,0 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
type textBuilder struct {
buf []byte
}
func newTextBuilder() *textBuilder {
return &textBuilder{buf: make([]byte, 0, 128)}
}
func (b *textBuilder) WriteByte(c byte) error {
b.buf = append(b.buf, c)
return nil
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
s := string(r)
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) String() string {
return string(b.buf)
}

View file

@ -1,11 +0,0 @@
// 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...)
}

View file

@ -1,11 +0,0 @@
//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...)
}

View file

@ -1,9 +0,0 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateDefault(key string, _ ...any) string {
return key
}