fix(html): stabilize nested layout block ids

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Codex 2026-04-15 03:11:19 +01:00
parent 9757d661ed
commit 3b2900838b
65 changed files with 22 additions and 6597 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,12 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{md,yml,yaml,json,txt}]
indent_style = space
indent_size = 2

View file

@ -1,12 +0,0 @@
name: Security Scan
on:
push:
branches: [main, dev, 'feat/*']
pull_request:
branches: [main]
jobs:
security:
uses: core/go-devops/.forgejo/workflows/security-scan.yml@main
secrets: inherit

View file

@ -1,14 +0,0 @@
name: Test
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
test:
uses: core/go-devops/.forgejo/workflows/go-test.yml@main
with:
race: true
coverage: true

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,5 +0,0 @@
.idea/
.vscode/
*.log
.core/
dist/

View file

@ -1,22 +0,0 @@
run:
timeout: 5m
go: "1.26"
linters:
enable:
- govet
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gocritic
- gofmt
disable:
- exhaustive
- wrapcheck
issues:
exclude-use-default: false
max-same-issues: 0

View file

@ -1,68 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Agent instructions for `go-html`. Module path: `dappco.re/go/core/html`
## Commands
```bash
go test ./... # Run all tests
go test -run TestName ./... # Single test
go test -short ./... # Skip slow WASM build test
go test -bench . ./... # Benchmarks
go test -bench . -benchmem ./... # Benchmarks with alloc stats
go vet ./... # Static analysis
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ # WASM build
make wasm # WASM build with size gate
echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI
```
## Architecture
See `docs/architecture.md` for full detail. Summary:
- **Node interface**: `Render(ctx *Context) string` — El, Text, Raw, If, Unless, Each[T], EachSeq[T], Switch, Entitled
- **HLCRF Layout**: Header/Left/Content/Right/Footer compositor with ARIA roles and deterministic `data-block` IDs. Variant string (e.g. "HCF", "HLCRF", "C") controls which slots render. Layouts nest via clone-on-render (thread-safe).
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only)
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
## Server/Client Split
Files guarded with `//go:build !js` are excluded from WASM:
- `pipeline.go` — Imprint/CompareVariants use `go-i18n/reversal` (server-side only)
- `cmd/wasm/register.go` — encoding/json + codegen (replaced by `cmd/codegen/` CLI)
**Critical WASM constraint**: Never import `encoding/json`, `text/template`, or `fmt` in WASM-linked code (files without a `!js` build tag). Use string concatenation instead of `fmt.Sprintf` in `layout.go`, `node.go`, `responsive.go`, `render.go`, `path.go`, and `context.go`. The `fmt` package alone adds ~500 KB to the WASM binary.
## Dependencies
- `dappco.re/go/core/i18n` (replace directive → local go-i18n)
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n; not yet migrated)
- `forge.lthn.ai/core/go-log` (indirect, via go-i18n; not yet migrated)
- Both `go-i18n` and `go-inference` must be cloned alongside this repo for builds
- Go 1.26+ required (uses `range` over integers, `iter.Seq`, `maps.Keys`, `slices.Collect`)
## Coding Standards
- UK English (colour, organisation, centre, behaviour, licence, serialise)
- All types annotated; use `any` not `interface{}`
- Tests use `testify` assert/require
- Licence: EUPL-1.2 — add `// SPDX-Licence-Identifier: EUPL-1.2` to new files
- Safe-by-default: HTML escaping via `html.EscapeString()` on Text nodes and attribute values, void element handling, entitlement deny-by-default
- Deterministic output: sorted attributes on El nodes, reproducible block ID paths
- Errors: use `log.E("scope", "message", err)` from `go-log`, never `fmt.Errorf`
- File I/O: use `coreio.Local` from `go-io`, never `os.ReadFile`/`os.WriteFile`
- Commits: conventional commits + `Co-Authored-By: Virgil <virgil@lethean.io>`
## Test Conventions
Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must initialise i18n before rendering:
```go
svc, _ := i18n.New()
i18n.SetDefault(svc)
```

View file

@ -1,35 +0,0 @@
# Contributing
Thank you for your interest in contributing!
## Requirements
- **Go Version**: 1.26 or higher is required.
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
## Development Workflow
1. **Testing**: Ensure all tests pass before submitting changes.
```bash
go test ./...
```
2. **Code Style**: All code must follow standard Go formatting.
```bash
gofmt -w .
go vet ./...
```
3. **Linting**: We use `golangci-lint` to maintain code quality.
```bash
golangci-lint run ./...
```
## Commit Message Format
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation changes
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `chore`: Changes to the build process or auxiliary tools and libraries
Example: `feat: add new endpoint for health check`
## License
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.

View file

@ -1,30 +0,0 @@
.PHONY: wasm test clean
WASM_OUT := dist/go-html.wasm
# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth)
WASM_RAW_LIMIT := 3670016
# Gzip transfer size limit: 1MB (what users actually download)
WASM_GZ_LIMIT := 1048576
test:
go test ./...
wasm: $(WASM_OUT)
$(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
@mkdir -p dist
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm/
@RAW=$$(stat -c%s "$(WASM_OUT)" 2>/dev/null || stat -f%z "$(WASM_OUT)"); \
GZ=$$(gzip -c "$(WASM_OUT)" | wc -c); \
echo "WASM size: $${RAW} bytes raw, $${GZ} bytes gzip"; \
if [ "$$GZ" -gt $(WASM_GZ_LIMIT) ]; then \
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
exit 1; \
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \
else \
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \
fi
clean:
rm -rf dist/

View file

@ -1,48 +0,0 @@
[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/html.svg)](https://pkg.go.dev/dappco.re/go/core/html)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md)
[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod)
# go-html
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText, TabIndex, AutoFocus, Role), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
**Module**: `dappco.re/go/core/html`
**Licence**: EUPL-1.2
**Language**: Go 1.26
## Quick Start
```go
import html "dappco.re/go/core/html"
page := html.NewLayout("HCF").
H(html.El("nav", html.Text("i18n.label.navigation"))).
C(html.El("main",
html.El("h1", html.Text("i18n.label.welcome")),
html.Each(items, func(item Item) html.Node {
return html.El("li", html.Text(item.Name))
}),
)).
F(html.El("footer", html.Text("i18n.label.copyright")))
rendered := page.Render(html.NewContext("en-GB"))
```
## Documentation
- [Architecture](docs/architecture.md) — node interface, HLCRF layout, responsive compositor, grammar pipeline, WASM module, codegen CLI
- [Development Guide](docs/development.md) — building, testing, WASM build, server/client split rules
- [Project History](docs/history.md) — completed phases and known limitations
## Build & Test
```bash
go test ./...
go test -bench . ./...
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
go build ./...
```
## Licence
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.

View file

@ -1,65 +0,0 @@
# Session Brief: core/go-html
**Repo**: `forge.lthn.ai/core/go-html` (clone at `/tmp/core-go-html`)
**Module**: `forge.lthn.ai/core/go-html`
**Status**: Current tests pass; WASM build is within budget and codegen emits JS plus TypeScript defs
**Wiki**: https://forge.lthn.ai/core/go-html/wiki (6 pages)
## What This Is
HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composable node trees with:
- **Node interface**: El, Text, Raw, If, Unless, Each[T], Switch, Entitled
- **HLCRF Layout**: Header/Left/Content/Right/Footer with ARIA roles
- **Responsive**: Multi-variant breakpoint rendering
- **Pipeline**: Render → strip tags → tokenise via go-i18n/reversal → GrammarImprint
- **WASM target**: `cmd/wasm/` exposes `renderToString()` to JS
- **Codegen**: Web Component classes with closed Shadow DOM plus `.d.ts` generation
## Current State
| Area | Status |
|------|--------|
| Core (node, layout, responsive, pipeline) | SOLID — all tested, clean API |
| Tests | Passing |
| go vet | Clean |
| TODOs/FIXMEs | None |
| WASM build | PASS — within the 1 MB gzip gate |
| Codegen | Working — generates WC classes and `.d.ts` definitions |
## Dependencies
- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
- `github.com/stretchr/testify` v1.11.1
- `golang.org/x/text` v0.33.0
## Priority Work
No active blockers recorded here. See `docs/history.md` for the remaining design choices and deferred ideas that were captured during earlier implementation phases.
## File Map
```
/tmp/core-go-html/
├── node.go (254) + node_test.go (206)
├── layout.go (119) + layout_test.go (116)
├── pipeline.go (83) + pipeline_test.go (128)
├── responsive.go (39) + responsive_test.go (89)
├── context.go (27)
├── render.go (9) + render_test.go (97)
├── path.go (22) + path_test.go (86)
├── integration_test.go (52)
├── cmd/wasm/
│ ├── main.go (78) — WASM entry point
│ ├── register.go (18) + register_test.go (24)
├── codegen/
│ ├── codegen.go (90) + codegen_test.go (54)
├── go.mod
└── Makefile
```
## Conventions
- UK English (colour, organisation)
- `declare(strict_types=1)` equivalent: all types annotated
- Tests: testify assert/require
- Licence: EUPL-1.2

View file

@ -1,289 +0,0 @@
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
func init() {
svc, _ := i18n.New()
i18n.SetDefault(svc)
}
// --- BenchmarkRender ---
// buildTree creates an El tree of the given depth with branching factor 3.
func buildTree(depth int) Node {
if depth <= 0 {
return Raw("leaf")
}
children := make([]Node, 3)
for i := range children {
children[i] = buildTree(depth - 1)
}
return El("div", children...)
}
func BenchmarkRender_Depth1(b *testing.B) {
node := buildTree(1)
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
node.Render(ctx)
}
}
func BenchmarkRender_Depth3(b *testing.B) {
node := buildTree(3)
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
node.Render(ctx)
}
}
func BenchmarkRender_Depth5(b *testing.B) {
node := buildTree(5)
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
node.Render(ctx)
}
}
func BenchmarkRender_Depth7(b *testing.B) {
node := buildTree(7)
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
node.Render(ctx)
}
}
func BenchmarkRender_FullPage(b *testing.B) {
page := NewLayout("HCF").
H(El("h1", Text("Dashboard"))).
C(
El("div",
El("p", Text("Welcome")),
Each([]string{"Home", "Settings", "Profile"}, func(item string) Node {
return El("a", Raw(item))
}),
),
).
F(El("small", Text("Footer")))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
page.Render(ctx)
}
}
// --- BenchmarkImprint ---
func BenchmarkImprint_Small(b *testing.B) {
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
Imprint(page, ctx)
}
}
func BenchmarkImprint_Large(b *testing.B) {
items := make([]string, 20)
for i := range items {
items[i] = "Item " + itoaText(i) + " was created successfully"
}
page := NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
L(El("nav", Each(items[:5], func(s string) Node { return El("a", Text(s)) }))).
C(El("div", Each(items, func(s string) Node { return El("p", Text(s)) }))).
R(El("aside", Text("Completed rendering operation"))).
F(El("small", Text("Finished processing all items")))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
Imprint(page, ctx)
}
}
// --- BenchmarkCompareVariants ---
func BenchmarkCompareVariants_TwoVariants(b *testing.B) {
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed"))))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
CompareVariants(r, ctx)
}
}
func BenchmarkCompareVariants_ThreeVariants(b *testing.B) {
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
L(El("nav", Text("Navigation links"))).
C(El("p", Text("Files deleted successfully"))).
R(El("aside", Text("Sidebar content"))).
F(El("small", Text("Completed")))).
Variant("tablet", NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("C").
C(El("p", Text("Files deleted successfully"))))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
CompareVariants(r, ctx)
}
}
// --- BenchmarkLayout ---
func BenchmarkLayout_ContentOnly(b *testing.B) {
layout := NewLayout("C").C(Raw("content"))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
layout.Render(ctx)
}
}
func BenchmarkLayout_HCF(b *testing.B) {
layout := NewLayout("HCF").
H(Raw("header")).C(Raw("main")).F(Raw("footer"))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
layout.Render(ctx)
}
}
func BenchmarkLayout_HLCRF(b *testing.B) {
layout := NewLayout("HLCRF").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
layout.Render(ctx)
}
}
func BenchmarkLayout_Nested(b *testing.B) {
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
layout := NewLayout("HLCRF").
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
layout.Render(ctx)
}
}
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
nodes := make([]Node, 50)
for i := range nodes {
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
}
layout := NewLayout("HLCRF").
H(Raw("header")).
C(nodes...).
F(Raw("footer"))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
layout.Render(ctx)
}
}
// --- BenchmarkEach ---
func BenchmarkEach_10(b *testing.B) {
benchEach(b, 10)
}
func BenchmarkEach_100(b *testing.B) {
benchEach(b, 100)
}
func BenchmarkEach_1000(b *testing.B) {
benchEach(b, 1000)
}
func benchEach(b *testing.B, n int) {
b.Helper()
items := make([]int, n)
for i := range items {
items[i] = i
}
node := Each(items, func(i int) Node {
return El("li", Raw("item-"+itoaText(i)))
})
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
node.Render(ctx)
}
}
// --- BenchmarkResponsive ---
func BenchmarkResponsive_ThreeVariants(b *testing.B) {
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
Variant("mobile", NewLayout("C").C(Raw("c")))
ctx := NewContext()
b.ResetTimer()
for b.Loop() {
r.Render(ctx)
}
}
// --- BenchmarkStripTags ---
func BenchmarkStripTags_Short(b *testing.B) {
input := `<div>hello</div>`
for b.Loop() {
StripTags(input)
}
}
func BenchmarkStripTags_Long(b *testing.B) {
layout := NewLayout("HLCRF").
H(Raw("header content")).L(Raw("left sidebar")).
C(Raw("main body content with multiple words")).
R(Raw("right sidebar")).F(Raw("footer content"))
input := layout.Render(NewContext())
b.ResetTimer()
for b.Loop() {
StripTags(input)
}
}

View file

@ -1,180 +0,0 @@
//go:build !js
// Package main provides a build-time CLI for generating Web Component bundles.
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
//
// Usage:
//
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
package main
import (
"context"
"flag"
goio "io"
"os"
"os/signal"
"time"
core "dappco.re/go/core"
"dappco.re/go/core/html/codegen"
coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log"
)
func generate(data []byte, emitTypes bool) (string, error) {
var slots map[string]string
if result := core.JSONUnmarshal(data, &slots); !result.OK {
err, _ := result.Value.(error)
return "", log.E("codegen", "invalid JSON", err)
}
if emitTypes {
return codegen.GenerateTypeScriptDefinitions(slots), nil
}
out, err := codegen.GenerateBundle(slots)
if err != nil {
return "", log.E("codegen", "generate bundle", err)
}
return out, nil
}
func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
data, err := goio.ReadAll(r)
if err != nil {
return log.E("codegen", "reading stdin", err)
}
out, err := generate(data, emitTypes)
if err != nil {
return err
}
_, err = goio.WriteString(w, out)
if err != nil {
return log.E("codegen", "writing output", err)
}
return nil
}
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
if inputPath == "" {
return log.E("codegen", "watch mode requires -input", nil)
}
if outputPath == "" {
return log.E("codegen", "watch mode requires -output", nil)
}
if pollInterval <= 0 {
pollInterval = 250 * time.Millisecond
}
var lastInput []byte
for {
input, err := readLocalFile(inputPath)
if err != nil {
return log.E("codegen", "reading input file", err)
}
if !sameBytes(input, lastInput) {
out, err := generate(input, emitTypes)
if err != nil {
return err
}
if err := writeLocalFile(outputPath, out); err != nil {
return log.E("codegen", "writing output file", err)
}
lastInput = append(lastInput[:0], input...)
}
select {
case <-ctx.Done():
if core.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-time.After(pollInterval):
}
}
}
func readLocalFile(path string) ([]byte, error) {
f, err := coreio.Local.Open(path)
if err != nil {
return nil, err
}
defer func() {
_ = f.Close()
}()
return goio.ReadAll(f)
}
func writeLocalFile(path, content string) error {
f, err := coreio.Local.Create(path)
if err != nil {
return err
}
defer func() {
_ = f.Close()
}()
_, err = goio.WriteString(f, content)
return err
}
func sameBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range len(a) {
if a[i] != b[i] {
return false
}
}
return true
}
func main() {
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
flag.Parse()
if *emitWatch {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
log.Error("codegen failed", "scope", "codegen.main", "err", err)
os.Exit(1)
}
return
}
stdin, err := coreio.Local.Open("/dev/stdin")
if err != nil {
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
os.Exit(1)
}
stdout, err := coreio.Local.Create("/dev/stdout")
if err != nil {
_ = stdin.Close()
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
os.Exit(1)
}
defer func() {
_ = stdin.Close()
_ = stdout.Close()
}()
if err := run(stdin, stdout, *emitTypes); err != nil {
log.Error("codegen failed", "scope", "codegen.main", "err", err)
os.Exit(1)
}
}

View file

@ -1,179 +0,0 @@
//go:build !js
package main
import (
"context"
goio "io"
"path/filepath"
"strings"
"testing"
"time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun_WritesBundle_Good(t *testing.T) {
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder()
err := run(input, output, false)
require.NoError(t, err)
js := output.String()
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Contains(t, js, "customElements.define")
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
}
func TestRun_InvalidJSON_Bad(t *testing.T) {
input := core.NewReader(`not json`)
output := core.NewBuilder()
err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
func TestRun_InvalidTag_Bad(t *testing.T) {
input := core.NewReader(`{"H":"notag"}`)
output := core.NewBuilder()
err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hyphen")
}
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
output := core.NewBuilder()
err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "lowercase hyphenated name")
}
func TestRun_EmptySlots_Good(t *testing.T) {
input := core.NewReader(`{}`)
output := core.NewBuilder()
err := run(input, output, false)
require.NoError(t, err)
assert.Empty(t, output.String())
}
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder()
err := run(input, output, true)
require.NoError(t, err)
dts := output.String()
assert.Contains(t, dts, "declare global")
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.Contains(t, dts, `"main-content": MainContent;`)
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
}
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "slots.json")
outputPath := filepath.Join(dir, "bundle.js")
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
}()
require.Eventually(t, func() bool {
got, err := readTextFile(outputPath)
if err != nil {
return false
}
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
}, time.Second, 10*time.Millisecond)
cancel()
require.NoError(t, <-done)
}
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
require.Error(t, err)
assert.Contains(t, err.Error(), "watch mode requires -input")
}
func countSubstr(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexSubstr(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexSubstr(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func writeTextFile(path, content string) error {
f, err := coreio.Local.Create(path)
if err != nil {
return err
}
defer func() {
_ = f.Close()
}()
_, err = goio.WriteString(f, content)
return err
}
func readTextFile(path string) (string, error) {
f, err := coreio.Local.Open(path)
if err != nil {
return "", err
}
defer func() {
_ = f.Close()
}()
data, err := goio.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}

View file

@ -1,54 +0,0 @@
//go:build js && wasm
package main
import (
"syscall/js"
)
// Keep the callback alive for the lifetime of the WASM module.
var renderToStringFunc js.Func
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
// Slot content is injected via Raw() — the caller is responsible for sanitisation.
// This is intentional: the WASM module is a rendering engine for trusted content
// produced server-side or by the application's own templates.
func renderToString(_ js.Value, args []js.Value) any {
if len(args) < 1 || args[0].Type() != js.TypeString {
return ""
}
variant := args[0].String()
if variant == "" {
return ""
}
locale := ""
if len(args) >= 2 && args[1].Type() == js.TypeString {
locale = args[1].String()
}
slots := make(map[string]string)
if len(args) >= 3 && args[2].Type() == js.TypeObject {
jsSlots := args[2]
for _, slot := range []string{"H", "L", "C", "R", "F"} {
content := jsSlots.Get(slot)
if content.Type() == js.TypeString {
slots[slot] = content.String()
}
}
}
return renderLayout(variant, locale, slots)
}
func main() {
renderToStringFunc = js.FuncOf(renderToString)
api := js.Global().Get("Object").New()
api.Set("renderToString", renderToStringFunc)
js.Global().Set("gohtml", api)
select {}
}

View file

@ -1,73 +0,0 @@
//go:build js && wasm
package main
import (
"testing"
"syscall/js"
)
func TestRenderToString_Good(t *testing.T) {
gotAny := renderToString(js.Value{}, []js.Value{
js.ValueOf("C"),
js.ValueOf("en-GB"),
js.ValueOf(map[string]any{"C": "<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"><strong>hello</strong></main>`
if got != want {
t.Fatalf("renderToString(...) = %q, want %q", got, want)
}
}
func TestRenderToString_EmptySlot_Good(t *testing.T) {
gotAny := renderToString(js.Value{}, []js.Value{
js.ValueOf("C"),
js.ValueOf("en-GB"),
js.ValueOf(map[string]any{"C": ""}),
})
got, ok := gotAny.(string)
if !ok {
t.Fatalf("renderToString should return string, got %T", gotAny)
}
want := `<main role="main" data-block="C"></main>`
if got != want {
t.Fatalf("renderToString empty slot = %q, want %q", got, want)
}
}
func TestRenderToString_VariantTypeGuard(t *testing.T) {
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
t.Fatalf("non-string variant should be empty, got %q", got)
}
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
t.Fatalf("missing variant should be empty, got %q", got)
}
}
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
gotAny := renderToString(js.Value{}, []js.Value{
js.ValueOf("C"),
js.ValueOf(123),
js.ValueOf(map[string]any{"C": "x"}),
})
got, ok := gotAny.(string)
if !ok {
t.Fatalf("renderToString should return string, got %T", gotAny)
}
want := `<main role="main" data-block="C">x</main>`
if got != want {
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
}
}

View file

@ -1,27 +0,0 @@
//go:build !js
package main
import (
core "dappco.re/go/core"
"dappco.re/go/core/html/codegen"
log "dappco.re/go/core/log"
)
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
// This is the pure-Go part testable without WASM.
// Excluded from WASM builds — encoding/json and text/template are too heavy.
// Use cmd/codegen/ CLI instead for build-time generation.
func buildComponentJS(slotsJSON string) (string, error) {
var slots map[string]string
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
err, _ := result.Value.(error)
return "", log.E("buildComponentJS", "unmarshal JSON", err)
}
return codegen.GenerateBundle(slots)
}
func main() {
log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm")
}

View file

@ -1,24 +0,0 @@
//go:build !js
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
js, err := buildComponentJS(slotsJSON)
require.NoError(t, err)
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Contains(t, js, "customElements.define")
}
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
_, err := buildComponentJS("not json")
assert.Error(t, err)
}

View file

@ -1,44 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import html "dappco.re/go/core/html"
// renderLayout renders an HLCRF layout from a slot map.
//
// Empty string values are meaningful: they create an explicit empty slot
// container rather than being treated as absent input.
func renderLayout(variant, locale string, slots map[string]string) string {
if variant == "" {
return ""
}
ctx := html.NewContext()
if locale != "" {
ctx.SetLocale(locale)
}
layout := html.NewLayout(variant)
for _, slot := range []string{"H", "L", "C", "R", "F"} {
content, ok := slots[slot]
if !ok {
continue
}
switch slot {
case "H":
layout.H(html.Raw(content))
case "L":
layout.L(html.Raw(content))
case "C":
layout.C(html.Raw(content))
case "R":
layout.R(html.Raw(content))
case "F":
layout.F(html.Raw(content))
}
}
return layout.Render(ctx)
}

View file

@ -1,15 +0,0 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package main
import "testing"
func TestRenderLayout_EmptyStringSlot_Good(t *testing.T) {
got := renderLayout("C", "en-GB", map[string]string{"C": ""})
want := `<main role="main" data-block="C"></main>`
if got != want {
t.Fatalf("renderLayout with empty slot = %q, want %q", got, want)
}
}

View file

@ -1,63 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
//go:build !js
package main
import (
"compress/gzip"
"context"
"testing"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
process "dappco.re/go/core/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
wasmGzLimit = 1_048_576 // 1 MB gzip transfer size limit
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
)
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping WASM build test in short mode")
}
dir := t.TempDir()
out := core.Path(dir, "gohtml.wasm")
factory := process.NewService(process.Options{})
serviceValue, err := factory(core.New())
require.NoError(t, err)
svc, ok := serviceValue.(*process.Service)
require.True(t, ok, "process service factory returned %T", serviceValue)
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
Command: "go",
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
Dir: ".",
Env: []string{"GOOS=js", "GOARCH=wasm"},
})
require.NoError(t, err, "WASM build failed: %s", output)
rawStr, err := coreio.Local.Read(out)
require.NoError(t, err)
rawBytes := []byte(rawStr)
buf := core.NewBuilder()
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
require.NoError(t, err)
_, err = gz.Write(rawBytes)
require.NoError(t, err)
require.NoError(t, gz.Close())
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
assert.Less(t, buf.Len(), wasmGzLimit,
"WASM gzip size %d exceeds 1MB limit", buf.Len())
assert.Less(t, len(rawBytes), wasmRawLimit,
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
}

View file

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>go-html WASM Test</title>
<style>
body { font-family: monospace; margin: 2em; }
pre { background: #f5f5f5; padding: 1em; overflow: auto; }
</style>
</head>
<body>
<h1>go-html WASM Test</h1>
<div>
<label>Variant: <input id="variant" value="HLCRF"></label>
</div>
<div>
<label>H: <input id="slot-H" value="Header"></label>
</div>
<div>
<label>C: <input id="slot-C" value="Main content"></label>
</div>
<div>
<label>F: <input id="slot-F" value="Footer"></label>
</div>
<button id="render-btn">Render</button>
<h2>Raw HTML Output</h2>
<pre id="raw"></pre>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("go-html.wasm"), go.importObject)
.then(result => {
go.run(result.instance);
document.title += " (loaded)";
});
document.getElementById("render-btn").addEventListener("click", function() {
const variant = document.getElementById("variant").value;
const slots = {
H: document.getElementById("slot-H").value,
C: document.getElementById("slot-C").value,
F: document.getElementById("slot-F").value,
};
const result = gohtml.renderToString(variant, "en", slots);
document.getElementById("raw").textContent = result;
});
</script>
</body>
</html>

View file

@ -1,48 +0,0 @@
//go:build !js
package codegen
import "testing"
func BenchmarkGenerateClass(b *testing.B) {
for b.Loop() {
GenerateClass("photo-grid", "C")
}
}
func BenchmarkTagToClassName(b *testing.B) {
for b.Loop() {
TagToClassName("my-super-widget-component")
}
}
func BenchmarkGenerateBundle_Small(b *testing.B) {
slots := map[string]string{
"H": "nav-bar",
"C": "main-content",
}
b.ResetTimer()
for b.Loop() {
GenerateBundle(slots)
}
}
func BenchmarkGenerateBundle_Full(b *testing.B) {
slots := map[string]string{
"H": "nav-bar",
"L": "side-panel",
"C": "main-content",
"R": "aside-widget",
"F": "page-footer",
}
b.ResetTimer()
for b.Loop() {
GenerateBundle(slots)
}
}
func BenchmarkGenerateRegistration(b *testing.B) {
for b.Loop() {
GenerateRegistration("photo-grid", "PhotoGrid")
}
}

View file

@ -1,221 +0,0 @@
//go:build !js
package codegen
import (
"sort"
"text/template"
"unicode"
"unicode/utf8"
core "dappco.re/go/core"
log "dappco.re/go/core/log"
)
var reservedCustomElementNames = map[string]struct{}{
"annotation-xml": {},
"color-profile": {},
"font-face": {},
"font-face-src": {},
"font-face-uri": {},
"font-face-format": {},
"font-face-name": {},
"missing-glyph": {},
}
// isValidCustomElementTag reports whether tag is a valid custom element name.
// The generator rejects values that would fail at customElements.define() time.
func isValidCustomElementTag(tag string) bool {
if tag == "" || !core.Contains(tag, "-") {
return false
}
if !utf8.ValidString(tag) {
return false
}
if _, reserved := reservedCustomElementNames[tag]; reserved {
return false
}
first, _ := utf8.DecodeRuneInString(tag)
if first < 'a' || first > 'z' {
return false
}
for _, r := range tag {
if r >= 'A' && r <= 'Z' {
return false
}
switch r {
case 0, '/', '>', '\t', '\n', '\f', '\r', ' ':
return false
}
}
return true
}
type jsStringBuilder interface {
WriteByte(byte) error
WriteRune(rune) (int, error)
WriteString(string) (int, error)
String() string
}
// escapeJSStringLiteral escapes content for inclusion inside a double-quoted JS string.
func escapeJSStringLiteral(s string) string {
b := core.NewBuilder()
appendJSStringLiteral(b, s)
return b.String()
}
func appendJSStringLiteral(b jsStringBuilder, s string) {
for _, r := range s {
switch r {
case '\\':
b.WriteString(`\\`)
case '"':
b.WriteString(`\"`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case 0x2028:
b.WriteString(`\u2028`)
case 0x2029:
b.WriteString(`\u2029`)
default:
if r < 0x20 {
appendUnicodeEscape(b, r)
continue
}
if r > 0xFFFF {
rr := r - 0x10000
appendUnicodeEscape(b, rune(0xD800+(rr>>10)))
appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF)))
continue
}
_, _ = b.WriteRune(r)
}
}
}
func appendUnicodeEscape(b jsStringBuilder, r rune) {
const hex = "0123456789ABCDEF"
b.WriteString(`\u`)
b.WriteByte(hex[(r>>12)&0xF])
b.WriteByte(hex[(r>>8)&0xF])
b.WriteByte(hex[(r>>4)&0xF])
b.WriteByte(hex[r&0xF])
}
// wcTemplate is the Web Component class template.
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
// DOM API using trusted go-html codegen output (never user input).
var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} extends HTMLElement {
#shadow;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "closed" });
}
connectedCallback() {
this.#shadow.textContent = "";
const slot = this.getAttribute("data-slot") || "{{.SlotLiteral}}";
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } }));
}
render(html) {
const tpl = document.createElement("template");
tpl.innerHTML = html;
this.#shadow.textContent = "";
this.#shadow.appendChild(tpl.content.cloneNode(true));
}
}`))
// GenerateClass produces a JS class definition for a custom element.
// Usage example: js, err := GenerateClass("nav-bar", "H")
func GenerateClass(tag, slot string) (string, error) {
if !isValidCustomElementTag(tag) {
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
}
b := core.NewBuilder()
tagLiteral := escapeJSStringLiteral(tag)
slotLiteral := escapeJSStringLiteral(slot)
err := wcTemplate.Execute(b, struct {
ClassName, TagLiteral, SlotLiteral string
}{
ClassName: TagToClassName(tag),
TagLiteral: tagLiteral,
SlotLiteral: slotLiteral,
})
if err != nil {
return "", log.E("codegen.GenerateClass", "template exec", err)
}
return b.String(), nil
}
// GenerateRegistration produces the customElements.define() call.
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
func GenerateRegistration(tag, className string) string {
return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);`
}
// TagToClassName converts a custom element tag to PascalCase class name.
// Usage example: className := TagToClassName("nav-bar")
func TagToClassName(tag string) string {
b := core.NewBuilder()
upperNext := true
for _, r := range tag {
switch {
case unicode.IsLetter(r):
if upperNext {
_, _ = b.WriteRune(unicode.ToUpper(r))
} else {
_, _ = b.WriteRune(r)
}
upperNext = false
case unicode.IsDigit(r):
_, _ = b.WriteRune(r)
upperNext = false
default:
upperNext = true
}
}
return b.String()
}
// GenerateBundle produces all WC class definitions and registrations
// for a set of HLCRF slot assignments.
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool)
b := core.NewBuilder()
keys := make([]string, 0, len(slots))
for slot := range slots {
keys = append(keys, slot)
}
sort.Strings(keys)
for _, slot := range keys {
tag := slots[slot]
if seen[tag] {
continue
}
seen[tag] = true
cls, err := GenerateClass(tag, slot)
if err != nil {
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
}
b.WriteString(cls)
b.WriteByte('\n')
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
b.WriteByte('\n')
}
return b.String(), nil
}

View file

@ -1,194 +0,0 @@
//go:build !js
package codegen
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateClass_ValidTag_Good(t *testing.T) {
js, err := GenerateClass("photo-grid", "C")
require.NoError(t, err)
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
assert.Contains(t, js, "attachShadow")
assert.Contains(t, js, `mode: "closed"`)
assert.Contains(t, js, "photo-grid")
}
func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
_, err := GenerateClass("invalid", "C")
assert.Error(t, err, "custom element names must contain a hyphen")
_, err = GenerateClass("Nav-Bar", "C")
assert.Error(t, err, "custom element names must be lowercase")
_, err = GenerateClass("nav bar", "C")
assert.Error(t, err, "custom element names must reject spaces")
_, err = GenerateClass("annotation-xml", "C")
assert.Error(t, err, "reserved custom element names must be rejected")
}
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
js := GenerateRegistration("photo-grid", "PhotoGrid")
assert.Contains(t, js, "customElements.define")
assert.Contains(t, js, `"photo-grid"`)
assert.Contains(t, js, "PhotoGrid")
}
func TestGenerateClass_ValidExtendedTag_Good(t *testing.T) {
tests := []struct {
tag string
wantClass string
}{
{tag: "foo.bar-baz", wantClass: "FooBarBaz"},
{tag: "math-α", wantClass: "MathΑ"},
}
for _, tt := range tests {
t.Run(tt.tag, func(t *testing.T) {
js, err := GenerateClass(tt.tag, "C")
require.NoError(t, err)
assert.Contains(t, js, "class "+tt.wantClass+" extends HTMLElement")
assert.Contains(t, js, `tag: "`+tt.tag+`"`)
assert.Contains(t, js, `slot = this.getAttribute("data-slot") || "C";`)
})
}
}
func TestTagToClassName_KebabCase_Good(t *testing.T) {
tests := []struct{ tag, want string }{
{"photo-grid", "PhotoGrid"},
{"nav-breadcrumb", "NavBreadcrumb"},
{"my-super-widget", "MySuperWidget"},
{"nav_bar", "NavBar"},
{"nav.bar", "NavBar"},
{"nav--bar", "NavBar"},
{"math-α", "MathΑ"},
}
for _, tt := range tests {
got := TagToClassName(tt.tag)
assert.Equal(t, tt.want, got, "TagToClassName(%q)", tt.tag)
}
}
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "main-content",
"F": "nav-bar",
}
js, err := GenerateBundle(slots)
require.NoError(t, err)
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
}
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
slots := map[string]string{
"Z": "zed-panel",
"A": "alpha-panel",
"M": "main-content",
}
js, err := GenerateBundle(slots)
require.NoError(t, err)
alpha := strings.Index(js, "class AlphaPanel")
main := strings.Index(js, "class MainContent")
zed := strings.Index(js, "class ZedPanel")
assert.NotEqual(t, -1, alpha)
assert.NotEqual(t, -1, main)
assert.NotEqual(t, -1, zed)
assert.Less(t, alpha, main)
assert.Less(t, main, zed)
assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
assert.Equal(t, 3, countSubstr(js, "customElements.define"))
}
func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
slots := map[string]string{
"Z": "zed-panel",
"A": "alpha-panel",
"M": "alpha-panel",
}
dts := GenerateTypeScriptDefinitions(slots)
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
assert.Contains(t, dts, "export {};")
assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
}
func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "Nav-Bar",
"F": "nav bar",
}
dts := GenerateTypeScriptDefinitions(slots)
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.NotContains(t, dts, "Nav-Bar")
assert.NotContains(t, dts, "nav bar")
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
}
func TestGenerateTypeScriptDefinitions_ValidExtendedTag_Good(t *testing.T) {
slots := map[string]string{
"H": "foo.bar-baz",
}
dts := GenerateTypeScriptDefinitions(slots)
assert.Contains(t, dts, `"foo.bar-baz": FooBarBaz;`)
assert.Contains(t, dts, `export declare class FooBarBaz extends HTMLElement`)
}
func countSubstr(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexSubstr(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexSubstr(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

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(escapeJSStringLiteral(tag))
b.WriteString("\": ")
b.WriteString(TagToClassName(tag))
b.WriteString(";\n")
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
for _, slot := range keys {
tag := slots[slot]
if !seen[tag] || declared[tag] {
continue
}
declared[tag] = true
b.WriteString("export declare class ")
b.WriteString(TagToClassName(tag))
b.WriteString(" extends HTMLElement {\n")
b.WriteString(" connectedCallback(): void;\n")
b.WriteString(" render(html: string): void;\n")
b.WriteString("}\n\n")
}
b.WriteString("export {};\n")
return b.String()
}

View file

@ -1,129 +0,0 @@
package html
import "reflect"
// Translator provides Text() lookups for a rendering context.
// Usage example: ctx := NewContextWithService(myTranslator)
//
// The default server build uses go-i18n. Alternate builds, including WASM,
// can provide any implementation with the same T() method.
type Translator interface {
T(key string, args ...any) string
}
// Context carries rendering state through the node tree.
// Usage example: ctx := NewContext()
//
// Metadata is an alias for Data — both fields reference the same underlying map.
// Treat them as interchangeable; use whichever reads best in context.
type Context struct {
Identity string
Locale string
Entitlements func(feature string) bool
Data map[string]any
Metadata map[string]any
service Translator
}
func applyLocaleToService(svc Translator, locale string) {
if svc == nil || locale == "" {
return
}
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
base := locale
for i := 0; i < len(base); i++ {
if base[i] == '-' || base[i] == '_' {
base = base[:i]
break
}
}
_ = setter.SetLanguage(base)
}
}
// NewContext creates a new rendering context with sensible defaults.
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
func NewContext(locale ...string) *Context {
data := make(map[string]any)
ctx := &Context{
Data: data,
Metadata: data, // alias — same underlying map
}
if len(locale) > 0 {
ctx.SetLocale(locale[0])
}
return ctx
}
// NewContextWithService creates a rendering context backed by a specific translator.
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
func NewContextWithService(svc Translator, locale ...string) *Context {
ctx := NewContext(locale...)
ctx.SetService(svc)
return ctx
}
// SetService swaps the translator used by the context.
// Usage example: ctx.SetService(myTranslator)
func (ctx *Context) SetService(svc Translator) *Context {
if ctx == nil {
return nil
}
ctx.service = svc
applyLocaleToService(svc, ctx.Locale)
return ctx
}
// SetLocale updates the context locale and reapplies it to the active translator.
// Usage example: ctx.SetLocale("en-GB")
func (ctx *Context) SetLocale(locale string) *Context {
if ctx == nil {
return nil
}
ctx.Locale = locale
applyLocaleToService(ctx.service, ctx.Locale)
return ctx
}
func cloneContext(ctx *Context) *Context {
if ctx == nil {
return nil
}
clone := *ctx
// Preserve the shared Data/Metadata alias when callers pointed both fields
// at the same map.
if sameMetadataMap(ctx.Data, ctx.Metadata) {
shared := cloneMetadataMap(ctx.Data)
clone.Data = shared
clone.Metadata = shared
return &clone
}
clone.Data = cloneMetadataMap(ctx.Data)
clone.Metadata = cloneMetadataMap(ctx.Metadata)
return &clone
}
func cloneMetadataMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
func sameMetadataMap(a, b map[string]any) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer()
}

View file

@ -1,173 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import (
"reflect"
"testing"
i18n "dappco.re/go/core/i18n"
)
type recordingTranslator struct {
key string
args []any
}
func (r *recordingTranslator) T(key string, args ...any) string {
r.key = key
r.args = append(r.args[:0], args...)
return "translated"
}
func TestNewContext_OptionalLocale_Good(t *testing.T) {
ctx := NewContext("en-GB")
if ctx == nil {
t.Fatal("NewContext returned nil")
}
if ctx.Locale != "en-GB" {
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
}
if ctx.Data == nil {
t.Fatal("NewContext should initialise Data")
}
}
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc, "fr-FR")
if ctx == nil {
t.Fatal("NewContextWithService returned nil")
}
if ctx.Locale != "fr-FR" {
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
}
if ctx.service == nil {
t.Fatal("NewContextWithService should set translator service")
}
}
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc, "fr-FR")
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
}
}
func TestTextNode_UsesMetadataAliasWhenDataNil_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := &Context{
Metadata: map[string]any{"count": 1},
}
got := Text("i18n.count.file").Render(ctx)
if got != "1 file" {
t.Fatalf("Text with metadata-only count = %q, want %q", got, "1 file")
}
}
func TestTextNode_CustomTranslatorReceivesCountArgs_Good(t *testing.T) {
ctx := NewContextWithService(&recordingTranslator{})
ctx.Metadata["count"] = 3
got := Text("i18n.count.file", "ignored").Render(ctx)
if got != "translated" {
t.Fatalf("Text with custom translator = %q, want %q", got, "translated")
}
svc := ctx.service.(*recordingTranslator)
if svc.key != "i18n.count.file" {
t.Fatalf("custom translator key = %q, want %q", svc.key, "i18n.count.file")
}
wantArgs := []any{3, "ignored"}
if !reflect.DeepEqual(svc.args, wantArgs) {
t.Fatalf("custom translator args = %#v, want %#v", svc.args, wantArgs)
}
}
func TestTextNode_NonCountKey_DoesNotInjectCount_Good(t *testing.T) {
ctx := NewContextWithService(&recordingTranslator{})
ctx.Metadata["count"] = 3
got := Text("greeting.hello").Render(ctx)
if got != "translated" {
t.Fatalf("Text with non-count key = %q, want %q", got, "translated")
}
svc := ctx.service.(*recordingTranslator)
if len(svc.args) != 0 {
t.Fatalf("non-count key should not receive count args, got %#v", svc.args)
}
}
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContext("fr-FR")
if got := ctx.SetService(svc); got != ctx {
t.Fatal("SetService should return the same context for chaining")
}
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
}
}
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
var ctx *Context
if got := ctx.SetService(nil); got != nil {
t.Fatal("SetService on nil context should return nil")
}
}
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc)
if got := ctx.SetLocale("fr-FR"); got != ctx {
t.Fatal("SetLocale should return the same context for chaining")
}
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
}
}
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
var ctx *Context
if got := ctx.SetLocale("en-GB"); got != nil {
t.Fatal("SetLocale on nil context should return nil")
}
}
func TestCloneContext_PreservesMetadataAlias_Good(t *testing.T) {
ctx := NewContext()
ctx.Data["count"] = 3
clone := cloneContext(ctx)
if clone == nil {
t.Fatal("cloneContext returned nil")
}
if clone.Data == nil || clone.Metadata == nil {
t.Fatal("cloneContext should preserve non-nil metadata maps")
}
dataPtr := reflect.ValueOf(clone.Data).Pointer()
metadataPtr := reflect.ValueOf(clone.Metadata).Pointer()
if dataPtr != metadataPtr {
t.Fatalf("cloneContext should keep Data and Metadata aliased, got %x and %x", dataPtr, metadataPtr)
}
if clone.Data["count"] != 3 || clone.Metadata["count"] != 3 {
t.Fatalf("cloneContext should copy map contents, got Data=%v Metadata=%v", clone.Data, clone.Metadata)
}
}

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,309 +0,0 @@
---
title: Architecture
description: Internals of the go-html HLCRF DOM compositor, covering the node interface, layout system, responsive wrapper, grammar pipeline, WASM module, and codegen CLI.
---
# Architecture
`go-html` is structured around a single interface, a layout compositor, and a server-side analysis pipeline. Everything renders to `string` -- there is no virtual DOM, no diffing, and no retained state between renders.
## Node Interface
Every renderable unit implements one method:
```go
type Node interface {
Render(ctx *Context) string
}
```
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
| Constructor | Behaviour |
|-------------|-----------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Each`, `EachSeq`, `Switch`, `Layout`, and `Responsive` wrappers. Returns the node for chaining. |
| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. |
| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. |
| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. |
| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. |
| `Role(Node, role)` | Convenience helper that sets `role` on an element node. |
| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
| `Each[T](items, fn)` | Iterates a slice and renders each item via a mapping function. Generic over `T`. |
| `EachSeq[T](items, fn)` | Same as `Each` but accepts an `iter.Seq[T]` instead of a slice. |
| `Switch(selector, cases)` | Renders one of several named cases based on a runtime selector function. Returns empty string when no case matches. |
| `Entitled(feature, Node)` | Renders the child only when the context's entitlement function grants the named feature. Deny-by-default: returns empty string when no entitlement function is set. |
### Safety Guarantees
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
- **Attribute escaping**: Attribute values are escaped with `html.EscapeString()`, handling `&`, `<`, `>`, `"`, and `'`.
- **Deterministic output**: Attribute keys on `El` nodes are sorted alphabetically before rendering, producing identical output regardless of insertion order.
- **Void elements**: A lookup table of 13 void elements (`area`, `base`, `br`, `col`, `embed`, `hr`, `img`, `input`, `link`, `meta`, `source`, `track`, `wbr`) ensures these never emit a closing tag.
- **Deny-by-default entitlements**: `Entitled` returns an empty string when the context is nil, when no entitlement function is set, or when the function returns false. Content is absent from the DOM, not merely hidden.
## Rendering Context
The `Context` struct carries per-request state through the node tree during rendering:
```go
type Context struct {
Identity string // e.g. user ID or session identifier
Locale string // BCP 47 locale string
Entitlements func(feature string) bool // feature gate callback
Data map[string]any // arbitrary per-request data
Metadata map[string]any // alias of Data for alternate naming
service Translator // unexported; set via constructor
}
```
Two constructors are provided:
- `NewContext()` creates a context with sensible defaults and an empty `Data` map.
- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
`Data` and `Metadata` point at the same backing map when the context is created through `NewContext()`. Use whichever name is clearer in the calling code. `SetLocale()` and `SetService()` keep the active translator in sync when either value changes.
The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean.
## HLCRF Layout
The `Layout` type is a compositor for five named slots:
| Slot Letter | Semantic Element | ARIA Role | Accessor |
|-------------|-----------------|-----------|----------|
| H | `<header>` | `banner` | `layout.H(...)` |
| L | `<nav>` | `navigation` | `layout.L(...)` |
| C | `<main>` | `main` | `layout.C(...)` |
| R | `<aside>` | `complementary` | `layout.R(...)` |
| F | `<footer>` | `contentinfo` | `layout.F(...)` |
### Variant String
The variant string passed to `NewLayout()` determines which slots render and in which order:
```go
NewLayout("HLCRF") // all five slots
NewLayout("HCF") // header, content, footer (no sidebars)
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.
### Deterministic Block IDs
Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs use the slot letter itself:
```html
<header role="banner" data-block="H">...</header>
<main role="main" data-block="C">...</main>
<footer role="contentinfo" data-block="F">...</footer>
```
Block IDs are constructed by simple string concatenation (no `fmt.Sprintf`) to keep the `fmt` package out of the WASM import graph.
### Nested Layouts
`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts retain the parent's block ID as a prefix. This produces hierarchical paths:
```go
inner := html.NewLayout("HCF").
H(html.Raw("nav")).
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`, `L.0.1`, `L.0.2`. At 10 levels of nesting, the deepest block ID becomes `C.0.0.0.0.0.0.0.0.0` (tested in `edge_test.go`).
The clone-on-render approach means the original layout is never mutated. This is safe for concurrent use.
### Fluent Builder
All slot methods return `*Layout` for chaining. Multiple nodes can be appended to the same slot across multiple calls:
```go
html.NewLayout("HCF").
H(html.El("h1", html.Text("page.title"))).
C(html.El("p", html.Text("intro"))).
C(html.El("p", html.Text("body"))). // appends to the same C slot
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("L-0-C-0") // legacy hyphenated form, also returns ['L', 'C']
ParseBlockID("C.0.C.0.C.0") // returns ['C', 'C', 'C']
ParseBlockID("H") // 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` wraps multiple named `Layout` variants for breakpoint-aware rendering:
```go
html.NewResponsive().
Variant("desktop", html.NewLayout("HLCRF").
H(html.Raw("header")).L(html.Raw("nav")).C(html.Raw("main")).
R(html.Raw("aside")).F(html.Raw("footer"))).
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. When supplied, `Responsive.Add(name, layout, media)` also emits `data-media="..."` on the wrapper so downstream CSS can reflect the breakpoint hint. CSS media queries or JavaScript can target these containers for show/hide logic.
`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.
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values.
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.
## Grammar Pipeline (Server-Side Only)
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.
### StripTags
```go
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
```go
func Imprint(node Node, ctx *Context) reversal.GrammarImprint
```
Runs the full render-to-analysis pipeline:
1. Renders the node tree to HTML via `node.Render(ctx)`.
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
```go
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"`.
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.
A single-variant `Responsive` produces an empty score map (no pairs to compare).
## WASM Module
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function:
```js
gohtml.renderToString(variant, locale, slots)
```
**Parameters:**
- `variant` (string): HLCRF variant string, e.g. `"HCF"`.
- `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 Budget
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
| Metric | Limit | Current |
|--------|-------|---------|
| 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
`cmd/codegen/main.go` generates Web Component JavaScript bundles from HLCRF slot assignments at build time:
```bash
echo '{"H":"nav-bar","C":"main-content","F":"page-footer"}' | 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:
1. A class extending `HTMLElement` with a private `#shadow` field.
2. `constructor()` attaching a closed shadow root (`mode: "closed"`).
3. `connectedCallback()` dispatching a `wc-ready` custom event with the tag name and slot.
4. `render(html)` method that sets shadow content from a `<template>` clone.
5. A `customElements.define()` registration call.
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`.
`GenerateBundle()` deduplicates tags -- if the same tag is assigned to multiple slots, only one class definition is emitted.
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.
## Data Flow Summary
```
Server-Side
+-------------------+
| |
Node tree -------> Render(ctx) |-----> HTML string
| |
| 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)
+-------------------+
```

View file

@ -1,314 +0,0 @@
---
title: Development Guide
description: How to build, test, and contribute to go-html, including WASM builds, benchmarks, coding standards, and test patterns.
---
# Development Guide
## Prerequisites
- **Go 1.26** or later. The module uses Go 1.26 features (e.g. `range` over integers, `iter.Seq`).
- **go-i18n** cloned alongside this repository at `../go-i18n` relative to the repo 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 workspace** (`go.work`): this module is part of a shared workspace. Run `go work sync` after cloning.
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.
## Directory Layout
```
go-html/
node.go Node interface and all node types
layout.go HLCRF compositor
pipeline.go StripTags, Imprint, CompareVariants (!js only)
responsive.go Multi-variant breakpoint wrapper
context.go Rendering context
render.go Render() convenience function
path.go ParseBlockID() for data-block path decoding
codegen/
codegen.go Web Component JS generation (server-side)
codegen_test.go Tests for codegen
bench_test.go Codegen benchmarks
cmd/
codegen/
main.go Build-time CLI (stdin JSON, stdout JS)
main_test.go CLI integration tests
wasm/
main.go WASM entry point (js+wasm build only)
register.go buildComponentJS helper (!js only)
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
```bash
# All tests
go test ./...
# Single test by name
go test -run TestElNode_Render .
# Skip the slow WASM build test
go test -short ./...
# Verbose output
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.
### Test Dependencies
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
```bash
# All benchmarks
go test -bench . ./...
# Specific benchmark
go test -bench BenchmarkRender_FullPage .
# With memory allocation statistics
go test -bench . -benchmem ./...
# Extended benchmark duration
go test -bench . -benchtime=5s ./...
```
Available benchmark groups:
| Group | Variants |
|-------|----------|
| `BenchmarkRender_*` | Depth 1, 3, 5, 7 element trees; full page with layout |
| `BenchmarkLayout_*` | Content-only, HCF, HLCRF, nested, 50-child slot |
| `BenchmarkEach_*` | 10, 100, 1000 items |
| `BenchmarkResponsive_*` | Three-variant compositor |
| `BenchmarkStripTags_*` | Short and long HTML inputs |
| `BenchmarkImprint_*` | Small and large page trees |
| `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
```bash
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.
The Makefile `wasm` target performs the build and checks the output size:
```bash
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.
To verify the gzip size manually:
```bash
gzip -c -9 gohtml.wasm | wc -c
```
## Codegen CLI
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout:
```bash
echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
| go run ./cmd/codegen/ \
> 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.
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:
```bash
go test ./cmd/codegen/
```
## Static Analysis
```bash
go vet ./...
```
The repository also includes a `.golangci.yml` configuration for `golangci-lint`.
## Coding Standards
### Language
UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used.
### Type Annotations
All exported and unexported functions carry full parameter and return type annotations. The `any` alias is used in preference to `interface{}`.
### HTML Safety
- Use `Text()` for any user-supplied or translated content. It escapes HTML automatically.
- Use `Raw()` only for content you control or have sanitised upstream. Its name explicitly signals "no escaping".
- 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
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.
### 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.
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
All new files should carry the EUPL-1.2 SPDX identifier:
```go
// SPDX-Licence-Identifier: EUPL-1.2
```
### Commit Format
Conventional commits with lowercase type and optional scope:
```
feat(codegen): add TypeScript type definition generation
fix(wasm): correct slot injection for empty strings
test: add edge case for Unicode surrogate pairs
docs: update architecture with pipeline diagram
```
Include a co-author trailer:
```
Co-Authored-By: Virgil <virgil@lethean.io>
```
## Test Patterns
### Standard Unit Test
```go
func TestElNode_Render(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("content"))
got := node.Render(ctx)
want := "<div>content</div>"
if got != want {
t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want)
}
}
```
### Table-Driven Subtest
```go
func TestStripTags_Unicode(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"},
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"},
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripTags(tt.input)
if got != tt.want {
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
```
### Integration Test with i18n
```go
func TestIntegration_RenderThenReverse(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
imp := Imprint(page, ctx)
if imp.UniqueVerbs == 0 {
t.Error("reversal found no verbs in rendered page")
}
}
```
### Codegen Tests with Testify
```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
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first.
- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services.
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.

View file

@ -1,120 +0,0 @@
# Project History
## Phase 1: Core Node Types (initial scaffolding)
Commits: `d7bb0b2` through `c724094`
The module was scaffolded with the Go module path `forge.lthn.ai/core/go-html`. The foundational work established:
- `d7bb0b2` — Module scaffold, `Node` interface with `Render(ctx *Context) string`.
- `3e76e72``Text` node wired to `go-i18n` grammar pipeline with HTML escaping.
- `c724094` — Conditional nodes (`If`, `Unless`), entitlement gating (`Entitled`, deny-by-default), runtime dispatch (`Switch`), and type-safe iteration (`Each[T]`).
The `Raw` escape hatch was present from the first commit. The decision to make `Text` always escape and `Raw` never escape was made at this stage and has not changed.
## Phase 2: HLCRF Layout and Pipeline
Commits: `946ea8d` through `ef77793`
- `946ea8d``Layout` type with HLCRF slot registry. Semantic HTML elements (`<header>`, `<main>`, `<aside>`, `<footer>`) and ARIA roles assigned per slot.
- `d75988a` — Nested layout path chains. Block IDs computed as `{slot}-0` at root, extended with `{parent}-{slot}-0` for nested layouts.
- `40da0d8` — Deterministic attribute sorting and thread-safe nested layout cloning (clone-on-render pattern).
- `f49ddbf``Attr()` helper for setting element attributes with chaining.
- `e041f76``Responsive` multi-variant compositor with `data-variant` containers.
- `8ac5123``StripTags` single-pass rune scanner for HTML-to-text stripping.
- `76cef5a``Imprint()` full render-reverse-imprint pipeline using `go-i18n/reversal`.
- `ef77793``CompareVariants()` pairwise semantic similarity scoring across responsive variants.
## Phase 3: WASM Entry Point
Commits: `456adce` through `9bc1fa7`
- `456adce` — Makefile with `wasm` target. Size gate: `WASM_GZ_LIMIT = 1048576` (1 MB). Initial measurement revealed the binary was already too large at this stage.
- `5acf63c` — WASM entry point `cmd/wasm/main.go` with `renderToString` exported to `window.gohtml`.
- `2fab89e` — Integration tests refactored to use `Imprint` pipeline.
- `e34c5c9` — WASM browser test harness added.
- `18d2933` — WASM binary size reporting improvements.
- `9bc1fa7` — Variant name escaping in `Responsive`, single-pass `StripTags` optimisation, WASM security contract documented in source.
## Phase 4: Codegen and Web Components
Commits: `937c08d` through `ab7ab92`
- `37b50ab`, `496513e` — Phase 4 design documents and implementation plan.
- `937c08d``codegen` package with `GenerateClass`, `GenerateBundle`, `TagToClassName`. Web Component classes with closed Shadow DOM.
- `dcd55a4``registerComponents` export added to `cmd/wasm/main.go`, bridging JSON slot config to WC bundle JS. This was the source of the subsequent binary size problem.
- `ab7ab92` — Transitive `replace` directive added for `go-inference`.
## WASM Binary Size Reduction
Commits: `6abda8b`, `4c65737`, `aae5d21`
The initial WASM binary measured 6.04 MB raw / 1.58 MB gzip — 58% over the 1 MB gzip limit set in the Makefile. The root causes were three heavyweight stdlib imports pulled in by `registerComponents()` in the WASM binary:
| Import | Approx. gzip contribution |
|--------|--------------------------|
| `encoding/json` | ~200 KB |
| `text/template` | ~125 KB |
| `fmt` (via `layout.go`) | ~50 KB |
| `go-i18n/reversal` (via `pipeline.go`) | ~250 KB |
**Total bloat**: ~625 KB gzip over the core rendering requirement.
The fix was applied in three distinct steps:
### Step 1: Remove registerComponents from WASM (`4c65737`)
`cmd/wasm/register.go` received a `//go:build !js` build tag, completely excluding it from the WASM compilation unit. The `registerComponents` entry on the `gohtml` JS object was removed from `cmd/wasm/main.go`. The codegen function was moved to a standalone build-time CLI at `cmd/codegen/main.go`. This eliminated `encoding/json` and `text/template` from the WASM import graph.
### Step 2: Remove pipeline from WASM
`pipeline.go` received a `//go:build !js` build tag. The `Imprint()` and `CompareVariants()` functions depend on `go-i18n/reversal`, which is a heavyweight analysis library. These functions are server-side analysis tools and have no use in a client-side rendering module. The `renderToString` function in the WASM entry point never called them, so removal was non-breaking.
### Step 3: Eliminate fmt from WASM
`layout.go`'s `blockID()` method had used `fmt.Sprintf` for string construction. Replacing this with direct string concatenation (`l.path + string(slot) + "-0"`) removed `fmt` from the WASM import graph entirely.
**Result**: 2.90 MB raw, 842 KB gzip. 47% reduction in gzip size. Well within the 1 MB limit.
### 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:
- Gzip size < 1,048,576 bytes (1 MB).
- Raw size < 3,145,728 bytes (3 MB).
The test is skipped under `go test -short` and is guarded with `//go:build !js`.
## Test Coverage Milestones
- `7efd2ab` — Benchmarks added across all subsystems. Unicode edge case tests. Stress tests.
- `ab7ab92` — 53 passing tests across the package and sub-packages.
- `aae5d21` — 70+ tests passing (server-side); WASM size gate and codegen CLI tests added.
## Known Limitations (as of current HEAD)
These are not regressions; they are design choices or deferred work recorded for future consideration.
1. **Invalid layout variants are silently ignored.** `NewLayout("XYZ")` produces empty output, and the compatibility helpers `ValidateLayoutVariant()` / `VariantError()` now return `nil`.
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
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.
5. **TypeScript definitions are generated.** `codegen.GenerateTypeScriptDefinitions()` and the `cmd/codegen -types` flag emit `.d.ts` companions for generated Web Components.
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
## Future Considerations
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.
- **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()`.
- **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.
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.

View file

@ -1,83 +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:** `dappco.re/go/core/html`
**Go version:** 1.26
**Licence:** EUPL-1.2
## Quick Start
```go
package main
import html "dappco.re/go/core/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. The rendering context exposes both `Data` and `Metadata` as the same backing map, and locale/service setters keep translation wiring explicit.
## 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, data/metadata alias, 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), `<nav>` (L), `<main>` (C), `<aside>` (R), `<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`, `L.0.1`, and `L.0.2`.
**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, and `Responsive.Add(name, layout, media)` can also annotate the container with `data-media`. `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
```
dappco.re/go/core/html
dappco.re/go/core (direct, server builds only, !js)
dappco.re/go/core/i18n (direct, all builds)
forge.lthn.ai/core/go-inference (indirect, via core/i18n)
dappco.re/go/core/i18n/reversal (server builds only, !js)
dappco.re/go/core/io (direct, server builds only, !js)
dappco.re/go/core/log (direct, server builds only, !js)
github.com/stretchr/testify (test only)
```
WASM-linked files (layout.go, node.go, path.go, responsive.go, render.go, context.go, text_builder_js.go, text_translate_js.go) deliberately avoid `dappco.re/go/core` to respect the RFC §7 WASM size budget — core transitively pulls in fmt/os/log.
## 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

@ -1,620 +0,0 @@
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
// --- Unicode / RTL edge cases ---
func TestText_Emoji_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
tests := []struct {
name string
input string
}{
{"simple emoji", "\U0001F680"},
{"emoji sequence", "\U0001F468\u200D\U0001F4BB"},
{"mixed text and emoji", "Hello \U0001F30D World"},
{"flag emoji", "\U0001F1EC\U0001F1E7"},
{"emoji in sentence", "Status: \u2705 Complete"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := Text(tt.input)
got := node.Render(ctx)
if got == "" {
t.Error("Text with emoji should not produce empty output")
}
// Emoji should pass through (they are not HTML special chars)
if !containsText(got, tt.input) {
// Some chars may get escaped, but emoji bytes should survive
t.Logf("note: emoji text rendered as %q", got)
}
})
}
}
func TestEl_Emoji_Ugly(t *testing.T) {
ctx := NewContext()
node := El("span", Raw("\U0001F680 Launch"))
got := node.Render(ctx)
want := "<span>\U0001F680 Launch</span>"
if got != want {
t.Errorf("El with emoji = %q, want %q", got, want)
}
}
func TestText_RTL_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
tests := []struct {
name string
input string
}{
{"Arabic", "\u0645\u0631\u062D\u0628\u0627"},
{"Hebrew", "\u05E9\u05DC\u05D5\u05DD"},
{"mixed LTR and RTL", "Hello \u0645\u0631\u062D\u0628\u0627 World"},
{"Arabic with numbers", "\u0627\u0644\u0639\u062F\u062F 42"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := Text(tt.input)
got := node.Render(ctx)
if got == "" {
t.Error("Text with RTL content should not produce empty output")
}
})
}
}
func TestEl_RTL_Ugly(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
got := node.Render(ctx)
if !containsText(got, `dir="rtl"`) {
t.Errorf("RTL element missing dir attribute in: %s", got)
}
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
t.Errorf("RTL element missing Arabic text in: %s", got)
}
}
func TestText_ZeroWidth_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
tests := []struct {
name string
input string
}{
{"zero-width space", "hello\u200Bworld"},
{"zero-width joiner", "hello\u200Dworld"},
{"zero-width non-joiner", "hello\u200Cworld"},
{"soft hyphen", "super\u00ADcalifragilistic"},
{"BOM character", "\uFEFFhello"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := Text(tt.input)
got := node.Render(ctx)
if got == "" {
t.Error("Text with zero-width characters should not produce empty output")
}
})
}
}
func TestText_MixedScripts_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
tests := []struct {
name string
input string
}{
{"Latin + CJK", "Hello \u4F60\u597D"},
{"Latin + Cyrillic", "Hello \u041F\u0440\u0438\u0432\u0435\u0442"},
{"CJK + Arabic", "\u4F60\u597D \u0645\u0631\u062D\u0628\u0627"},
{"Latin + Devanagari", "Hello \u0928\u092E\u0938\u094D\u0924\u0947"},
{"Latin + Thai", "Hello \u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35"},
{"all scripts mixed", "EN \u4F60\u597D \u0645\u0631\u062D\u0628\u0627 \u041F\u0440\u0438\u0432\u0435\u0442 \U0001F30D"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := Text(tt.input)
got := node.Render(ctx)
if got == "" {
t.Error("Text with mixed scripts should not produce empty output")
}
})
}
}
func TestStripTags_Unicode_Ugly(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"},
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"},
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"},
{"mixed unicode regions", "<header>\U0001F680</header><main>\u4F60\u597D</main>", "\U0001F680 \u4F60\u597D"},
{"zero-width in tags", "<span>a\u200Bb</span>", "a\u200Bb"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripTags(tt.input)
if got != tt.want {
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
ctx := NewContext()
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
got := node.Render(ctx)
want := "title=\"\U0001F680 Rocket Launch\""
if !containsText(got, want) {
t.Errorf("attribute with emoji should be preserved, got: %s", got)
}
}
// --- Deep nesting stress tests ---
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
ctx := NewContext()
// Build 10 levels of nested layouts
current := NewLayout("C").C(Raw("deepest"))
for range 9 {
current = NewLayout("C").C(current)
}
got := current.Render(ctx)
// Should contain the deepest content
if !containsText(got, "deepest") {
t.Error("10 levels deep: missing leaf content")
}
// Should have 10 levels of C.0 nesting
expectedBlock := "C"
for i := 1; i < 10; i++ {
expectedBlock += ".0"
}
if !containsText(got, `data-block="`+expectedBlock+`"`) {
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
}
// Must have exactly 10 <main> tags
if count := countText(got, "<main"); count != 10 {
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
}
}
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
ctx := NewContext()
current := NewLayout("C").C(Raw("bottom"))
for range 19 {
current = NewLayout("C").C(current)
}
got := current.Render(ctx)
if !containsText(got, "bottom") {
t.Error("20 levels deep: missing leaf content")
}
if count := countText(got, "<main"); count != 20 {
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
}
}
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
ctx := NewContext()
// Alternate slot types at each level: C -> L -> C -> L -> ...
current := NewLayout("C").C(Raw("leaf"))
for i := range 5 {
if i%2 == 0 {
current = NewLayout("HLCRF").L(current)
} else {
current = NewLayout("HCF").C(current)
}
}
got := current.Render(ctx)
if !containsText(got, "leaf") {
t.Error("mixed deep nesting: missing leaf content")
}
}
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
ctx := NewContext()
items := make([]int, 1000)
for i := range items {
items[i] = i
}
node := Each(items, func(i int) Node {
return El("li", Raw(itoaText(i)))
})
got := node.Render(ctx)
if count := countText(got, "<li>"); count != 1000 {
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
}
if !containsText(got, "<li>0</li>") {
t.Error("Each with 1000 items: missing first item")
}
if !containsText(got, "<li>999</li>") {
t.Error("Each with 1000 items: missing last item")
}
}
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
ctx := NewContext()
items := make([]int, 5000)
for i := range items {
items[i] = i
}
node := Each(items, func(i int) Node {
return El("span", Raw(itoaText(i)))
})
got := node.Render(ctx)
if count := countText(got, "<span>"); count != 5000 {
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
}
}
func TestEach_NestedEach_Ugly(t *testing.T) {
ctx := NewContext()
rows := []int{0, 1, 2}
cols := []string{"a", "b", "c"}
node := Each(rows, func(row int) Node {
return El("tr", Each(cols, func(col string) Node {
return El("td", Raw(itoaText(row)+"-"+col))
}))
})
got := node.Render(ctx)
if count := countText(got, "<tr>"); count != 3 {
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
}
if count := countText(got, "<td>"); count != 9 {
t.Errorf("nested Each: expected 9 <td>, got %d", count)
}
if !containsText(got, "1-b") {
t.Error("nested Each: missing cell content '1-b'")
}
}
func TestEach_WrappedElement_PreservesItemPaths_Good(t *testing.T) {
ctx := NewContext()
node := Each([]string{"a", "b"}, func(item string) Node {
return If(func(*Context) bool { return true }, El("span", Raw(item)))
})
got := NewLayout("C").C(node).Render(ctx)
if !containsText(got, `data-block="C.0.0"`) {
t.Fatalf("wrapped Each element should preserve first item path, got:\n%s", got)
}
if !containsText(got, `data-block="C.0.1"`) {
t.Fatalf("wrapped Each element should preserve second item path, got:\n%s", got)
}
}
func TestEach_WrappedLayout_PreservesBlockPath_Good(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("item"))
node := Each([]Node{inner}, func(item Node) Node {
return If(func(*Context) bool { return true }, item)
})
got := NewLayout("C").C(node).Render(ctx)
want := `<main role="main" data-block="C"><main role="main" data-block="C.0">item</main></main>`
if got != want {
t.Fatalf("wrapped Each layout render = %q, want %q", got, want)
}
}
// --- Layout variant validation ---
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
ctx := NewContext()
tests := []struct {
name string
variant string
}{
{"all invalid", "XYZ"},
{"lowercase valid", "hlcrf"},
{"numbers", "123"},
{"special chars", "!@#"},
{"mixed valid and invalid", "HXC"},
{"empty string", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
layout := NewLayout(tt.variant).
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
got := layout.Render(ctx)
// Invalid variant chars should silently produce no output for those slots
// This documents the current behaviour: no panic, no error.
if tt.variant == "XYZ" || tt.variant == "hlcrf" || tt.variant == "123" ||
tt.variant == "!@#" || tt.variant == "" {
if got != "" {
t.Errorf("NewLayout(%q) with all invalid chars should produce empty output, got %q", tt.variant, got)
}
}
})
}
}
func TestLayout_VariantError_NoOp_Good(t *testing.T) {
tests := []struct {
name string
variant string
build func(*Layout)
wantRender string
}{
{
name: "valid variant",
variant: "HCF",
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
},
wantRender: `<header role="banner" data-block="H">header</header><main role="main" data-block="C">main</main><footer role="contentinfo" data-block="F">footer</footer>`,
},
{
name: "mixed invalid variant",
variant: "HXC",
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main"))
},
wantRender: `<header role="banner" data-block="H">header</header><main role="main" data-block="C">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 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_NoOp_Good(t *testing.T) {
tests := []struct {
name string
variant string
}{
{name: "valid", variant: "HCF"},
{name: "invalid", variant: "HXC"},
{name: "empty", variant: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLayoutVariant(tt.variant)
if err != nil {
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
}
})
}
}
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
ctx := NewContext()
// "HXC" — H and C are valid, X is not. Only H and C should render.
layout := NewLayout("HXC").
H(Raw("header")).C(Raw("main"))
got := layout.Render(ctx)
if !containsText(got, "header") {
t.Errorf("HXC variant should render H slot, got:\n%s", got)
}
if !containsText(got, "main") {
t.Errorf("HXC variant should render C slot, got:\n%s", got)
}
// Should only have 2 semantic elements
if count := countText(got, "data-block="); count != 2 {
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
}
}
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
ctx := NewContext()
// "CCC" — C appears three times. Should render C slot content three times.
layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx)
count := countText(got, "content")
if count != 3 {
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
}
}
func TestLayout_DuplicateVariantChars_UniqueBlockIDs_Good(t *testing.T) {
ctx := NewContext()
layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx)
for _, want := range []string{`data-block="C"`, `data-block="C.1"`, `data-block="C.2"`} {
if !containsText(got, want) {
t.Fatalf("CCC variant should assign unique block ID %q, got:\n%s", want, got)
}
}
}
func TestLayout_EmptySlots_Ugly(t *testing.T) {
ctx := NewContext()
// Variant includes all slots but none are populated — should produce empty output.
layout := NewLayout("HLCRF")
got := layout.Render(ctx)
if got != "" {
t.Errorf("layout with no slot content should produce empty output, got %q", got)
}
}
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"`) {
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"`) {
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
}
}
// --- Render convenience function edge cases ---
func TestRender_NilContext_Ugly(t *testing.T) {
node := Raw("test")
got := Render(node, nil)
if got != "test" {
t.Errorf("Render with nil context = %q, want %q", got, "test")
}
}
func TestImprint_NilContext_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
node := NewLayout("C").C(El("p", Text("Building project")))
imp := Imprint(node, nil)
if imp.TokenCount == 0 {
t.Error("Imprint with nil context should still produce tokens")
}
}
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
r := NewResponsive().
Variant("a", NewLayout("C").C(Text("Building project"))).
Variant("b", NewLayout("C").C(Text("Building project")))
scores := CompareVariants(r, nil)
if _, ok := scores["a:b"]; !ok {
t.Error("CompareVariants with nil context should still produce scores")
}
}
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
r := NewResponsive().
Variant("only", NewLayout("C").C(Text("Building project")))
scores := CompareVariants(r, NewContext())
if len(scores) != 0 {
t.Errorf("CompareVariants with single variant should produce no pairs, got %d", len(scores))
}
}
// --- escapeHTML / escapeAttr edge cases ---
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) {
ctx := NewContext()
node := Attr(El("div"), "data-val", `&<>"'`)
got := node.Render(ctx)
if containsText(got, `"&<>"'"`) {
t.Error("attribute value with special chars must be fully escaped")
}
if !containsText(got, "&amp;&lt;&gt;&#34;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
}
}
func TestElNode_EmptyTag_Ugly(t *testing.T) {
ctx := NewContext()
node := El("", Raw("content"))
got := node.Render(ctx)
// Empty tag is weird but should not panic
if !containsText(got, "content") {
t.Errorf("El with empty tag should still render children, got %q", got)
}
}
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
ctx := NewContext()
cases := map[string]Node{
"a": Raw("alpha"),
"b": Raw("beta"),
}
node := Switch(func(*Context) string { return "c" }, cases)
got := node.Render(ctx)
if got != "" {
t.Errorf("Switch with no matching case should produce empty string, got %q", got)
}
}
func TestEntitled_NilContext_Ugly(t *testing.T) {
node := Entitled("premium", Raw("content"))
got := node.Render(nil)
if got != "" {
t.Errorf("Entitled with nil context should produce empty string, got %q", got)
}
}

21
go.mod
View file

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

33
go.sum
View file

@ -1,33 +0,0 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,3 +0,0 @@
go 1.26.0
use .

View file

@ -1,52 +0,0 @@
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
imp := Imprint(page, ctx)
if imp.UniqueVerbs == 0 {
t.Error("reversal found no verbs in rendered page")
}
if imp.TokenCount == 0 {
t.Error("reversal produced empty imprint")
}
}
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
L(El("nav", Text("Deleted files"))).
C(El("p", Text("Files deleted successfully"))).
R(El("aside", Text("Completed"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("C").
C(El("p", Text("Files deleted successfully"))))
imp := Imprint(r, ctx)
if imp.TokenCount == 0 {
t.Error("responsive imprint produced zero tokens")
}
if imp.UniqueVerbs == 0 {
t.Error("responsive imprint found no verbs")
}
}

View file

@ -174,22 +174,24 @@ func (l *Layout) Render(ctx *Context) string {
}
b := newTextBuilder()
rendered := 0
slotCounts := make(map[byte]int)
slotOrdinal := 0
for i := range len(l.variant) {
slot := l.variant[i]
children := l.slots[slot]
if len(children) == 0 {
continue
}
meta, ok := slotRegistry[slot]
if !ok {
continue
}
count := rendered
count := slotOrdinal
slotOrdinal++
children := l.slots[slot]
if len(children) == 0 {
continue
}
if l.path == "" {
count = slotCounts[slot]
slotCounts[slot] = count + 1
@ -214,7 +216,6 @@ func (l *Layout) Render(ctx *Context) string {
b.WriteString("</")
b.WriteString(meta.tag)
b.WriteByte('>')
rendered++
}
return b.String()

View file

@ -1,149 +0,0 @@
package html
import (
"testing"
)
func TestLayout_HLCRF_Good(t *testing.T) {
ctx := NewContext()
layout := NewLayout("HLCRF").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
got := layout.Render(ctx)
// Must contain semantic elements
for _, want := range []string{"<header", "<nav", "<main", "<footer"} {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
}
}
// Must contain ARIA roles
for _, want := range []string{`role="banner"`, `role="navigation"`, `role="main"`, `role="contentinfo"`} {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
}
}
// Must contain data-block IDs
for _, want := range []string{`data-block="H"`, `data-block="L"`, `data-block="C"`, `data-block="R"`, `data-block="F"`} {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
}
}
// Must contain content
for _, want := range []string{"header", "left", "main", "right", "footer"} {
if !containsText(got, want) {
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
}
}
}
func TestLayout_HCF_Good(t *testing.T) {
ctx := NewContext()
layout := NewLayout("HCF").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
got := layout.Render(ctx)
// HCF should have header, main, footer
for _, want := range []string{`data-block="H"`, `data-block="C"`, `data-block="F"`} {
if !containsText(got, want) {
t.Errorf("HCF layout missing %q in:\n%s", want, got)
}
}
// HCF must NOT have L or R slots
for _, unwanted := range []string{`data-block="L"`, `data-block="R"`} {
if containsText(got, unwanted) {
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
}
}
}
func TestLayout_ContentOnly_Good(t *testing.T) {
ctx := NewContext()
layout := NewLayout("C").
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
got := layout.Render(ctx)
// Only C slot should render
if !containsText(got, `data-block="C"`) {
t.Errorf("C layout missing data-block=\"C\" in:\n%s", got)
}
if !containsText(got, "<main") {
t.Errorf("C layout missing <main in:\n%s", got)
}
// No other slots
for _, unwanted := range []string{`data-block="H"`, `data-block="L"`, `data-block="R"`, `data-block="F"`} {
if containsText(got, unwanted) {
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
}
}
}
func TestLayout_FluentAPI_Good(t *testing.T) {
layout := NewLayout("HLCRF")
// Fluent methods should return the same layout for chaining
result := layout.H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))
if result != layout {
t.Error("fluent methods must return the same *Layout for chaining")
}
got := layout.Render(NewContext())
if got == "" {
t.Error("fluent chain should produce non-empty output")
}
}
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
ctx := NewContext()
// "C" variant: populating L and R should have no effect
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
got := layout.Render(ctx)
if !containsText(got, "main") {
t.Errorf("C variant should render main content, got:\n%s", got)
}
if containsText(got, "left") {
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
}
if containsText(got, "right") {
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">content</main>`
if got != want {
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
}
}

553
node.go
View file

@ -1,553 +0,0 @@
package html
import (
"html"
"iter"
"maps"
"reflect"
"slices"
"strconv"
)
// Node is anything renderable.
// Usage example: var n Node = El("div", Text("welcome"))
type Node interface {
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.
var voidElements = map[string]bool{
"area": true,
"base": true,
"br": true,
"col": true,
"embed": true,
"hr": true,
"img": true,
"input": true,
"link": true,
"meta": true,
"source": true,
"track": true,
"wbr": true,
}
// escapeAttr escapes a string for use in an HTML attribute value.
func escapeAttr(s string) string {
return html.EscapeString(s)
}
// --- rawNode ---
type rawNode struct {
content string
}
// Raw creates a node that renders without escaping (escape hatch for trusted content).
// Usage example: Raw("<strong>trusted</strong>")
func Raw(content string) Node {
return &rawNode{content: content}
}
func (n *rawNode) Render(_ *Context) string {
if n == nil {
return ""
}
return n.content
}
func (n *rawNode) renderWithLayoutPath(_ *Context, _ string) string {
return n.Render(nil)
}
// --- elNode ---
type elNode struct {
tag string
children []Node
attrs map[string]string
}
// El creates an HTML element node with children.
// Usage example: El("section", Text("welcome"))
func El(tag string, children ...Node) Node {
return &elNode{
tag: tag,
children: children,
attrs: make(map[string]string),
}
}
// 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, Entitled, Each,
// EachSeq, Switch, Layout, and Responsive when present.
func Attr(n Node, key, value string) Node {
if isNilNode(n) {
return nil
}
switch t := n.(type) {
case *elNode:
if t == nil {
return nil
}
t.attrs[key] = value
case *ifNode:
if t == nil {
return nil
}
Attr(t.node, key, value)
case *unlessNode:
if t == nil {
return nil
}
Attr(t.node, key, value)
case *entitledNode:
if t == nil {
return nil
}
Attr(t.node, key, value)
case *switchNode:
if t == nil {
return nil
}
for _, child := range t.cases {
Attr(child, key, value)
}
case *Layout:
if t == nil {
return nil
}
if t.slots != nil {
for slot, children := range t.slots {
for i := range children {
children[i] = Attr(children[i], key, value)
}
t.slots[slot] = children
}
}
case *Responsive:
for i := range t.variants {
Attr(t.variants[i].layout, key, value)
}
case attrApplier:
t.applyAttr(key, value)
}
return n
}
func isNilNode(n Node) bool {
if n == nil {
return true
}
v := reflect.ValueOf(n)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// 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 {
return n.render(ctx, "")
}
func (n *elNode) renderWithLayoutPath(ctx *Context, path string) string {
return n.render(ctx, path)
}
func (n *elNode) render(ctx *Context, path string) string {
if n == nil {
return ""
}
b := newTextBuilder()
attrs := n.attrs
if path != "" {
attrs = make(map[string]string, len(n.attrs)+1)
for key, value := range n.attrs {
attrs[key] = value
}
attrs["data-block"] = path
}
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
// Sort attribute keys for deterministic output.
keys := slices.Collect(maps.Keys(attrs))
slices.Sort(keys)
for _, key := range keys {
b.WriteByte(' ')
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
b.WriteString(escapeAttr(attrs[key]))
b.WriteByte('"')
}
b.WriteByte('>')
if voidElements[n.tag] {
return b.String()
}
for i := range len(n.children) {
child := n.children[i]
if child == nil {
continue
}
if path == "" {
b.WriteString(child.Render(ctx))
continue
}
b.WriteString(renderWithLayoutPath(child, ctx, path+"."+strconv.Itoa(i)))
}
b.WriteString("</")
b.WriteString(escapeHTML(n.tag))
b.WriteByte('>')
return b.String()
}
// --- escapeHTML ---
// escapeHTML escapes a string for safe inclusion in HTML text content.
func escapeHTML(s string) string {
return html.EscapeString(s)
}
// --- textNode ---
type textNode struct {
key string
args []any
}
// 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.
func Text(key string, args ...any) Node {
return &textNode{key: key, args: args}
}
func (n *textNode) Render(ctx *Context) string {
if n == nil {
return ""
}
return escapeHTML(translateText(ctx, n.key, n.args...))
}
func (n *textNode) renderWithLayoutPath(ctx *Context, _ string) string {
return n.Render(ctx)
}
// --- ifNode ---
type ifNode struct {
cond func(*Context) bool
node Node
}
// 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 {
return &ifNode{cond: cond, node: node}
}
func (n *ifNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if n.cond(ctx) {
return n.node.Render(ctx)
}
return ""
}
func (n *ifNode) renderWithLayoutPath(ctx *Context, path string) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if n.cond(ctx) {
return renderWithLayoutPath(n.node, ctx, path)
}
return ""
}
// --- unlessNode ---
type unlessNode struct {
cond func(*Context) bool
node Node
}
// 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 {
return &unlessNode{cond: cond, node: node}
}
func (n *unlessNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if !n.cond(ctx) {
return n.node.Render(ctx)
}
return ""
}
func (n *unlessNode) renderWithLayoutPath(ctx *Context, path string) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
if !n.cond(ctx) {
return renderWithLayoutPath(n.node, ctx, path)
}
return ""
}
// --- entitledNode ---
type entitledNode struct {
feature string
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.
func Entitled(feature string, node Node) Node {
return &entitledNode{feature: feature, node: node}
}
func (n *entitledNode) Render(ctx *Context) string {
if n == nil || n.node == nil {
return ""
}
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return ""
}
return n.node.Render(ctx)
}
func (n *entitledNode) renderWithLayoutPath(ctx *Context, path string) string {
if n == nil || n.node == nil {
return ""
}
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return ""
}
return renderWithLayoutPath(n.node, ctx, path)
}
// --- switchNode ---
type switchNode struct {
selector func(*Context) string
cases map[string]Node
}
// 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 {
return &switchNode{selector: selector, cases: cases}
}
func (n *switchNode) Render(ctx *Context) string {
if n == nil || n.selector == nil {
return ""
}
key := n.selector(ctx)
if n.cases == nil {
return ""
}
if node, ok := n.cases[key]; ok {
if node == nil {
return ""
}
return node.Render(ctx)
}
return ""
}
func (n *switchNode) renderWithLayoutPath(ctx *Context, path string) string {
if n == nil || n.selector == nil {
return ""
}
key := n.selector(ctx)
if n.cases == nil {
return ""
}
node, ok := n.cases[key]
if !ok || node == nil {
return ""
}
return renderWithLayoutPath(node, ctx, path)
}
// --- eachNode ---
type eachNode[T any] struct {
items []T
seq iter.Seq[T]
fn func(T) Node
}
type attrApplier interface {
applyAttr(key, value string)
}
func nodePreservesLayoutPath(node Node, ctx *Context) bool {
switch n := node.(type) {
case *Layout, *Responsive:
return true
case *ifNode:
if n == nil || n.cond == nil || n.node == nil || !n.cond(ctx) {
return false
}
return nodePreservesLayoutPath(n.node, ctx)
case *unlessNode:
if n == nil || n.cond == nil || n.node == nil || n.cond(ctx) {
return false
}
return nodePreservesLayoutPath(n.node, ctx)
case *entitledNode:
if n == nil || n.node == nil {
return false
}
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return false
}
return nodePreservesLayoutPath(n.node, ctx)
case *switchNode:
if n == nil || n.selector == nil || n.cases == nil {
return false
}
child, ok := n.cases[n.selector(ctx)]
if !ok || child == nil {
return false
}
return nodePreservesLayoutPath(child, ctx)
default:
return false
}
}
// 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 {
return &eachNode[T]{items: items, fn: 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 {
return &eachNode[T]{seq: items, fn: fn}
}
func (n *eachNode[T]) Render(ctx *Context) string {
return n.renderWithLayoutPath(ctx, "")
}
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 {
return ""
}
items := n.materialiseItems()
if len(items) == 0 {
return ""
}
b := newTextBuilder()
total := len(items)
for idx, item := range items {
child := n.fn(item)
if child == nil {
continue
}
childPath := path
if path != "" && (!nodePreservesLayoutPath(child, ctx) || total > 1) {
childPath = path + "." + strconv.Itoa(idx)
}
b.WriteString(renderWithLayoutPath(child, ctx, childPath))
}
return b.String()
}
func (n *eachNode[T]) materialiseItems() []T {
if n == nil {
return nil
}
if n.seq == nil {
return n.items
}
items := make([]T, 0)
for item := range n.seq {
items = append(items, item)
}
return items
}

View file

@ -1,617 +0,0 @@
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
"slices"
)
func TestRawNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := Raw("hello")
got := node.Render(ctx)
if got != "hello" {
t.Errorf("Raw(\"hello\").Render() = %q, want %q", got, "hello")
}
}
func TestElNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("content"))
got := node.Render(ctx)
want := "<div>content</div>"
if got != want {
t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want)
}
}
func TestElNode_Nested_Good(t *testing.T) {
ctx := NewContext()
node := El("div", El("span", Raw("inner")))
got := node.Render(ctx)
want := "<div><span>inner</span></div>"
if got != want {
t.Errorf("nested El().Render() = %q, want %q", got, want)
}
}
func TestLayout_DirectElementBlockPath_Good(t *testing.T) {
ctx := NewContext()
got := NewLayout("C").C(El("div", Raw("content"))).Render(ctx)
if !containsText(got, `data-block="C.0"`) {
t.Fatalf("direct element inside layout should receive a block path, got:\n%s", got)
}
}
func TestLayout_EachElementBlockPaths_Good(t *testing.T) {
ctx := NewContext()
got := NewLayout("C").C(
Each([]string{"a", "b"}, func(item string) Node {
return El("span", Raw(item))
}),
).Render(ctx)
if !containsText(got, `data-block="C.0.0"`) {
t.Fatalf("first Each item should receive a block path, got:\n%s", got)
}
if !containsText(got, `data-block="C.0.1"`) {
t.Fatalf("second Each item should receive a block path, got:\n%s", got)
}
}
func TestElNode_MultipleChildren_Good(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("a"), Raw("b"))
got := node.Render(ctx)
want := "<div>ab</div>"
if got != want {
t.Errorf("El with multiple children = %q, want %q", got, want)
}
}
func TestElNode_VoidElement_Good(t *testing.T) {
ctx := NewContext()
node := El("br")
got := node.Render(ctx)
want := "<br>"
if got != want {
t.Errorf("El(\"br\").Render() = %q, want %q", got, want)
}
}
func TestTextNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := Text("hello")
got := node.Render(ctx)
if got != "hello" {
t.Errorf("Text(\"hello\").Render() = %q, want %q", got, "hello")
}
}
func TestTextNode_UsesContextDataForCount_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
tests := []struct {
name string
key string
data map[string]any
want string
}{
{
name: "capitalised count",
key: "i18n.count.file",
data: map[string]any{"Count": 5},
want: "5 files",
},
{
name: "lowercase count",
key: "i18n.count.file",
data: map[string]any{"count": 1},
want: "1 file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := NewContext()
for k, v := range tt.data {
ctx.Metadata[k] = v
}
got := Text(tt.key).Render(ctx)
if got != tt.want {
t.Fatalf("Text(%q).Render() = %q, want %q", tt.key, got, tt.want)
}
})
}
}
func TestTextNode_Escapes_Good(t *testing.T) {
ctx := NewContext()
node := Text("<script>alert('xss')</script>")
got := node.Render(ctx)
if containsText(got, "<script>") {
t.Errorf("Text node must HTML-escape output, got %q", got)
}
if !containsText(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got)
}
}
func TestIfNode_True_Good(t *testing.T) {
ctx := NewContext()
node := If(func(*Context) bool { return true }, Raw("visible"))
got := node.Render(ctx)
if got != "visible" {
t.Errorf("If(true) = %q, want %q", got, "visible")
}
}
func TestIfNode_False_Good(t *testing.T) {
ctx := NewContext()
node := If(func(*Context) bool { return false }, Raw("hidden"))
got := node.Render(ctx)
if got != "" {
t.Errorf("If(false) = %q, want %q", got, "")
}
}
func TestUnlessNode_False_Good(t *testing.T) {
ctx := NewContext()
node := Unless(func(*Context) bool { return false }, Raw("visible"))
got := node.Render(ctx)
if got != "visible" {
t.Errorf("Unless(false) = %q, want %q", got, "visible")
}
}
func TestEntitledNode_Granted_Good(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
node := Entitled("premium", Raw("premium content"))
got := node.Render(ctx)
if got != "premium content" {
t.Errorf("Entitled(granted) = %q, want %q", got, "premium content")
}
}
func TestEntitledNode_Denied_Bad(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return false }
node := Entitled("premium", Raw("premium content"))
got := node.Render(ctx)
if got != "" {
t.Errorf("Entitled(denied) = %q, want %q", got, "")
}
}
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
ctx := NewContext()
node := Entitled("premium", Raw("premium content"))
got := node.Render(ctx)
if got != "" {
t.Errorf("Entitled(no func) = %q, want %q (deny by default)", got, "")
}
}
func TestEachNode_Render_Good(t *testing.T) {
ctx := NewContext()
items := []string{"a", "b", "c"}
node := Each(items, func(item string) Node {
return El("li", Raw(item))
})
got := node.Render(ctx)
want := "<li>a</li><li>b</li><li>c</li>"
if got != want {
t.Errorf("Each([a,b,c]) = %q, want %q", got, want)
}
}
func TestEachNode_Empty_Good(t *testing.T) {
ctx := NewContext()
node := Each([]string{}, func(item string) Node {
return El("li", Raw(item))
})
got := node.Render(ctx)
if got != "" {
t.Errorf("Each([]) = %q, want %q", got, "")
}
}
func TestEachNode_NestedLayout_PreservesBlockPath_Good(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"><main role="main" data-block="C.0">item</main></main>`
if got != want {
t.Fatalf("Each nested layout render = %q, want %q", got, want)
}
}
func TestEachNode_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
ctx := NewContext()
first := NewLayout("C").C(Raw("one"))
second := NewLayout("C").C(Raw("two"))
node := Each([]Node{first, second}, func(item Node) Node {
return item
})
got := NewLayout("C").C(node).Render(ctx)
if !containsText(got, `data-block="C.0.0"`) {
t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
}
if !containsText(got, `data-block="C.0.1"`) {
t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
}
}
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"><main role="main" data-block="C.0">item</main></main>`
if got != want {
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
}
}
func TestEachSeq_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
ctx := NewContext()
first := NewLayout("C").C(Raw("one"))
second := NewLayout("C").C(Raw("two"))
node := EachSeq(slices.Values([]Node{first, second}), func(item Node) Node {
return item
})
got := NewLayout("C").C(node).Render(ctx)
if !containsText(got, `data-block="C.0.0"`) {
t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
}
if !containsText(got, `data-block="C.0.1"`) {
t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
}
}
func TestElNode_Attr_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("content")), "class", "container")
got := node.Render(ctx)
want := `<div class="container">content</div>`
if got != want {
t.Errorf("Attr() = %q, want %q", got, want)
}
}
func TestElNode_AttrRecursiveThroughEachSeq_Good(t *testing.T) {
ctx := NewContext()
node := Attr(
EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
return El("span", Raw(item))
}),
"data-kind",
"item",
)
got := NewLayout("C").C(node).Render(ctx)
if count := countText(got, `data-kind="item"`); count != 2 {
t.Fatalf("Attr through EachSeq should apply to every item, got %d in:\n%s", count, got)
}
}
func TestElNode_AttrRecursiveThroughSwitch_Good(t *testing.T) {
ctx := NewContext()
node := Attr(
Switch(
func(*Context) string { return "match" },
map[string]Node{
"match": El("span", Raw("visible")),
"miss": El("span", Raw("hidden")),
},
),
"data-state",
"selected",
)
got := node.Render(ctx)
if !containsText(got, `data-state="selected"`) {
t.Fatalf("Attr through Switch should reach the selected case, got:\n%s", got)
}
}
func TestAccessibilityHelpers_Good(t *testing.T) {
ctx := NewContext()
button := Role(
AriaLabel(
TabIndex(
AutoFocus(El("button", Raw("save"))),
3,
),
"Save changes",
),
"button",
)
got := button.Render(ctx)
for _, want := range []string{
`aria-label="Save changes"`,
`autofocus="autofocus"`,
`role="button"`,
`tabindex="3"`,
">save</button>",
} {
if !containsText(got, want) {
t.Fatalf("accessibility helpers missing %q in:\n%s", want, got)
}
}
img := AltText(El("img"), "Profile photo")
if got := img.Render(ctx); got != `<img alt="Profile photo">` {
t.Fatalf("AltText() = %q, want %q", got, `<img alt="Profile photo">`)
}
}
func TestSwitchNode_Good(t *testing.T) {
ctx := NewContext()
ctx.Locale = "en-GB"
node := Switch(
func(ctx *Context) string { return ctx.Locale },
map[string]Node{
"en-GB": Raw("hello"),
"fr-FR": Raw("bonjour"),
},
)
if got := node.Render(ctx); got != "hello" {
t.Fatalf("Switch matched case = %q, want %q", got, "hello")
}
if got := Switch(func(*Context) string { return "de-DE" }, map[string]Node{"en-GB": Raw("hello")}).Render(ctx); got != "" {
t.Fatalf("Switch missing case = %q, want empty", got)
}
}
func TestElNode_AttrEscaping_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !containsText(got, `alt="he said &#34;hello&#34;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}
func TestAriaLabel_Good(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()
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
got := node.Render(ctx)
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
t.Errorf("multiple Attr() calls should stack, got %q", got)
}
}
func TestAttr_NonElement_Ugly(t *testing.T) {
node := Attr(Raw("text"), "class", "x")
got := node.Render(NewContext())
if got != "text" {
t.Errorf("Attr on non-element should return unchanged, got %q", got)
}
}
func TestAttr_TypedNilWrappers_Ugly(t *testing.T) {
tests := []struct {
name string
node Node
}{
{name: "layout", node: (*Layout)(nil)},
{name: "responsive", node: (*Responsive)(nil)},
{name: "if", node: (*ifNode)(nil)},
{name: "unless", node: (*unlessNode)(nil)},
{name: "entitled", node: (*entitledNode)(nil)},
{name: "switch", node: (*switchNode)(nil)},
{name: "each", node: (*eachNode[string])(nil)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Attr(tt.node, "data-test", "x"); got != nil {
t.Fatalf("Attr on typed nil %s should return nil, got %#v", tt.name, got)
}
})
}
}
func TestUnlessNode_True_Good(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_ThroughLayout_Good(t *testing.T) {
ctx := NewContext()
layout := NewLayout("C").C(El("div", Raw("content")))
Attr(layout, "class", "page")
got := layout.Render(ctx)
want := `<main role="main" data-block="C"><div class="page" data-block="C.0">content</div></main>`
if got != want {
t.Errorf("Attr through Layout = %q, want %q", got, want)
}
}
func TestAttr_ThroughResponsive_Good(t *testing.T) {
ctx := NewContext()
resp := NewResponsive().Variant("mobile", NewLayout("C").C(El("div", Raw("content"))))
Attr(resp, "data-kind", "page")
got := resp.Render(ctx)
want := `<div data-variant="mobile"><main role="main" data-block="C"><div data-block="C.0" data-kind="page">content</div></main></div>`
if got != want {
t.Errorf("Attr through Responsive = %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()
cases := map[string]Node{
"dark": Raw("dark theme"),
"light": Raw("light theme"),
}
node := Switch(func(*Context) string { return "dark" }, cases)
got := node.Render(ctx)
want := "dark theme"
if got != want {
t.Errorf("Switch(\"dark\") = %q, want %q", got, want)
}
}

134
path.go
View file

@ -1,134 +0,0 @@
package html
// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
// stdlib strings is safe for WASM.
// ParseBlockID extracts the slot sequence from a data-block ID.
// Usage example: slots := ParseBlockID("C.0.1")
// It accepts the current dotted coordinate form and the older hyphenated
// form for compatibility. Mixed separators and malformed coordinates are
// rejected.
func ParseBlockID(id string) []byte {
if id == "" {
return nil
}
tokens := make([]string, 0, 4)
sepKind := byte(0)
for i := 0; i < len(id); {
start := i
for i < len(id) && id[i] != '.' && id[i] != '-' {
i++
}
token := id[start:i]
if token == "" {
return nil
}
tokens = append(tokens, token)
if i == len(id) {
break
}
sep := id[i]
if sepKind == 0 {
sepKind = sep
} else if sepKind != sep {
return nil
}
i++
if i == len(id) {
return nil
}
}
switch sepKind {
case 0, '.':
return parseDottedBlockID(tokens)
case '-':
return parseHyphenatedBlockID(tokens)
default:
return nil
}
}
func parseDottedBlockID(tokens []string) []byte {
if len(tokens) == 0 || !isSlotToken(tokens[0]) {
return nil
}
if len(tokens) > 1 && isSlotToken(tokens[len(tokens)-1]) {
return nil
}
slots := make([]byte, 0, len(tokens))
slots = append(slots, tokens[0][0])
prevWasSlot := true
for i := 1; i < len(tokens); i++ {
token := tokens[i]
if isSlotToken(token) {
if prevWasSlot {
return nil
}
slots = append(slots, token[0])
prevWasSlot = true
continue
}
if !allDigits(token) {
return nil
}
prevWasSlot = false
}
return slots
}
func parseHyphenatedBlockID(tokens []string) []byte {
if len(tokens) < 2 || len(tokens)%2 != 0 {
return nil
}
if !isSlotToken(tokens[0]) {
return nil
}
slots := make([]byte, 0, len(tokens)/2)
for i, token := range tokens {
switch {
case i%2 == 0:
if !isSlotToken(token) {
return nil
}
slots = append(slots, token[0])
case token != "0":
return nil
}
}
return slots
}
func isSlotToken(token string) bool {
if len(token) != 1 {
return false
}
_, ok := slotRegistry[token[0]]
return ok
}
func allDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
ch := s[i]
if ch < '0' || ch > '9' {
return false
}
}
return true
}

View file

@ -38,6 +38,19 @@ func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
}
}
func TestNestedLayout_StablePathsAcrossEmptySlots_Good(t *testing.T) {
inner := NewLayout("HCF").
C(Raw("body")).
F(Raw("links"))
outer := NewLayout("C").C(inner)
got := outer.Render(NewContext())
want := `<main role="main" data-block="C"><main role="main" data-block="C.0.1">body</main><footer role="contentinfo" data-block="C.0.2">links</footer></main>`
if got != want {
t.Fatalf("nested layout with empty leading slots = %q, want %q", got, want)
}
}
func TestBlockID_BuildsPath_Good(t *testing.T) {
tests := []struct {
path string

View file

@ -1,154 +0,0 @@
//go:build !js
package html
import (
core "dappco.re/go/core"
"dappco.re/go/core/i18n/reversal"
"unicode/utf8"
)
// 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).
func StripTags(html string) string {
b := core.NewBuilder()
prevSpace := true // starts true to trim leading space
for i := 0; i < len(html); {
r, size := utf8.DecodeRuneInString(html[i:])
if r == '<' {
next, nextSize := nextRune(html, i+size)
if nextSize > 0 && isTagStartRune(next) {
if end, ok := findTagCloser(html, i+size+nextSize); ok {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
i = end + 1
continue
}
}
}
switch r {
case ' ', '\t', '\n', '\r':
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
default:
_, _ = b.WriteString(html[i : i+size])
prevSpace = false
}
i += size
}
return core.Trim(b.String())
}
func nextRune(s string, i int) (rune, int) {
if i >= len(s) {
return 0, 0
}
return utf8.DecodeRuneInString(s[i:])
}
func isTagStartRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z':
return true
case r >= 'A' && r <= 'Z':
return true
case r == '/', r == '!', r == '?':
return true
default:
return false
}
}
func findTagCloser(s string, start int) (int, bool) {
inSingleQuote := false
inDoubleQuote := false
for i := start; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
switch r {
case '\'':
if !inDoubleQuote {
inSingleQuote = !inSingleQuote
}
case '"':
if !inSingleQuote {
inDoubleQuote = !inDoubleQuote
}
case '>':
if !inSingleQuote && !inDoubleQuote {
return i, true
}
}
i += size
}
return 0, false
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
// and returns a GrammarImprint — the full render-reverse pipeline.
// Usage example: imp := Imprint(Text("welcome"), NewContext())
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
if ctx == nil {
ctx = NewContext()
}
rendered := ""
if node != nil {
rendered = node.Render(ctx)
}
text := StripTags(rendered)
tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text)
return reversal.NewImprint(tokens)
}
// CompareVariants runs the imprint pipeline on each responsive variant independently
// and returns pairwise similarity scores. Key format: "name1:name2".
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if ctx == nil {
ctx = NewContext()
}
if r == nil {
return make(map[string]float64)
}
type named struct {
name string
imp reversal.GrammarImprint
}
var imprints []named
for _, v := range r.variants {
if v.layout == nil {
continue
}
imp := Imprint(v.layout, cloneContext(ctx))
imprints = append(imprints, named{name: v.name, imp: imp})
}
scores := make(map[string]float64)
for i := range len(imprints) {
for j := i + 1; j < len(imprints); j++ {
left := imprints[i].name
right := imprints[j].name
if right < left {
left, right = right, left
}
key := left + ":" + right
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
}
}
return scores
}

View file

@ -1,190 +0,0 @@
//go:build !js
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
func TestStripTags_Simple_Good(t *testing.T) {
got := StripTags(`<div>hello</div>`)
want := "hello"
if got != want {
t.Errorf("StripTags(<div>hello</div>) = %q, want %q", got, want)
}
}
func TestStripTags_Nested_Good(t *testing.T) {
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
want := "Title"
if got != want {
t.Errorf("StripTags(nested) = %q, want %q", got, want)
}
}
func TestStripTags_MultipleRegions_Good(t *testing.T) {
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
want := "Head Body Foot"
if got != want {
t.Errorf("StripTags(multi) = %q, want %q", got, want)
}
}
func TestStripTags_Empty_Ugly(t *testing.T) {
got := StripTags("")
if got != "" {
t.Errorf("StripTags(\"\") = %q, want empty", got)
}
}
func TestStripTags_NoTags_Good(t *testing.T) {
got := StripTags("plain text")
if got != "plain text" {
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
}
}
func TestStripTags_PreservesComparisonOperators_Good(t *testing.T) {
got := StripTags(`<p>1 < 2 and 3 > 2</p>`)
want := "1 < 2 and 3 > 2"
if got != want {
t.Errorf("StripTags(comparisons) = %q, want %q", got, want)
}
}
func TestStripTags_LiteralAngleBracket_Good(t *testing.T) {
got := StripTags(`a<b`)
want := `a<b`
if got != want {
t.Errorf("StripTags(literal angle) = %q, want %q", got, want)
}
}
func TestStripTags_Entities_Good(t *testing.T) {
got := StripTags(`&lt;script&gt;`)
want := "&lt;script&gt;"
if got != want {
t.Errorf("StripTags should preserve entities, got %q, want %q", got, want)
}
}
func TestStripTags_QuotedAttributes_Good(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "double quotes",
input: `<div title="1 > 0">answer</div>`,
want: "answer",
},
{
name: "single quotes",
input: `<div title='a > b'>answer</div>`,
want: "answer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripTags(tt.input)
if got != tt.want {
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestImprint_FromNode_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
imp := Imprint(page, ctx)
if imp.TokenCount == 0 {
t.Error("Imprint should produce non-zero token count")
}
if imp.UniqueVerbs == 0 {
t.Error("Imprint should find verbs in rendered content")
}
}
func TestImprint_SimilarPages_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page1 := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully")))
page2 := NewLayout("HCF").
H(El("h1", Text("Building system"))).
C(El("p", Text("Files removed successfully")))
different := NewLayout("HCF").
C(El("p", Raw("no grammar content here xyz abc")))
imp1 := Imprint(page1, ctx)
imp2 := Imprint(page2, ctx)
impDiff := Imprint(different, ctx)
sim := imp1.Similar(imp2)
diffSim := imp1.Similar(impDiff)
if sim <= diffSim {
t.Errorf("similar pages should score higher (%f) than different (%f)", sim, diffSim)
}
}
func TestCompareVariants_SameContent_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed"))))
scores := CompareVariants(r, ctx)
key := "desktop:mobile"
sim, ok := scores[key]
if !ok {
t.Fatalf("CompareVariants missing key %q, got keys: %v", key, scores)
}
if sim < 0.8 {
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
}
}
func TestCompareVariants_KeyOrderDeterministic_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
r := NewResponsive().
Variant("beta", NewLayout("C").C(El("p", Text("Building project")))).
Variant("alpha", NewLayout("C").C(El("p", Text("Building project"))))
scores := CompareVariants(r, ctx)
if _, ok := scores["alpha:beta"]; !ok {
t.Fatalf("CompareVariants should use deterministic key ordering, got keys: %v", scores)
}
}

View file

@ -1,13 +0,0 @@
package html
// Render is a convenience function that renders a node tree to HTML.
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
func Render(node Node, ctx *Context) string {
if node == nil {
return ""
}
if ctx == nil {
ctx = NewContext()
}
return node.Render(ctx)
}

View file

@ -1,96 +0,0 @@
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
func TestRender_FullPage_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Dashboard"))).
C(
El("div",
El("p", Text("Welcome")),
Each([]string{"Home", "Settings", "Profile"}, func(item string) Node {
return El("a", Raw(item))
}),
),
).
F(El("small", Text("Footer")))
got := page.Render(ctx)
// Contains semantic elements
for _, want := range []string{"<header", "<main", "<footer"} {
if !containsText(got, want) {
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
}
}
// Content rendered
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
if !containsText(got, want) {
t.Errorf("full page missing content %q in:\n%s", want, got)
}
}
// Basic tag balance check: every opening tag should have a closing tag.
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
open := "<" + tag
close := "</" + tag + ">"
if countText(got, open) != countText(got, close) {
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
}
}
}
func TestRender_EntitlementGating_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
ctx.Entitlements = func(f string) bool { return f == "admin" }
page := NewLayout("HCF").
H(Raw("header")).
C(
Raw("public"),
Entitled("admin", Raw(" admin-panel")),
Entitled("premium", Raw(" premium-content")),
).
F(Raw("footer"))
got := page.Render(ctx)
if !containsText(got, "public") {
t.Errorf("entitlement gating should render public content, got:\n%s", got)
}
if !containsText(got, "admin-panel") {
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
}
if containsText(got, "premium-content") {
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
}
}
func TestRender_XSSPrevention_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("C").
C(Text("<script>alert('xss')</script>"))
got := page.Render(ctx)
if containsText(got, "<script>") {
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
}
if !containsText(got, "&lt;script&gt;") {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
}
}

View file

@ -1,125 +0,0 @@
package html
// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
// The stdlib strings/strconv primitives are safe for WASM.
import (
"strconv"
"strings"
)
// Compile-time interface check.
var _ Node = (*Responsive)(nil)
var _ layoutPathRenderer = (*Responsive)(nil)
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
variants []responsiveVariant
}
type responsiveVariant struct {
name string
layout *Layout
media string // optional CSS media-query hint (e.g. "(min-width: 768px)")
}
// NewResponsive creates a new multi-variant responsive compositor.
// Usage example: r := NewResponsive()
func NewResponsive() *Responsive {
return &Responsive{}
}
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
// Variants render in insertion order.
// Variant is equivalent to Add(name, layout) with no media-query hint.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
return r.Add(name, layout)
}
// Add registers a responsive variant. The optional media argument carries a
// CSS media-query hint for downstream CSS generation (e.g. "(min-width: 768px)").
// When supplied, Render emits it on the container as data-media.
//
// Usage example: NewResponsive().Add("desktop", NewLayout("HLCRF"), "(min-width: 1024px)")
func (r *Responsive) Add(name string, layout *Layout, media ...string) *Responsive {
if r == nil {
r = NewResponsive()
}
variant := responsiveVariant{name: name, layout: layout}
if len(media) > 0 {
variant.media = media[0]
}
r.variants = append(r.variants, variant)
return r
}
// 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 {
return r.renderWithLayoutPath(ctx, "")
}
func (r *Responsive) renderWithLayoutPath(ctx *Context, path string) string {
if r == nil {
return ""
}
if ctx == nil {
ctx = NewContext()
}
b := newTextBuilder()
for _, v := range r.variants {
if v.layout == nil {
continue
}
b.WriteString(`<div data-variant="`)
b.WriteString(escapeAttr(v.name))
if v.media != "" {
b.WriteString(`" data-media="`)
b.WriteString(escapeAttr(v.media))
}
b.WriteString(`">`)
b.WriteString(renderWithLayoutPath(v.layout, ctx, path))
b.WriteString(`</div>`)
}
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,169 +0,0 @@
package html
import (
"testing"
)
func TestResponsive_SingleVariant_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
got := r.Render(ctx)
if !containsText(got, `data-variant="desktop"`) {
t.Errorf("responsive should contain data-variant, got:\n%s", got)
}
if !containsText(got, `data-block="H"`) {
t.Errorf("responsive should contain layout content, got:\n%s", got)
}
}
func TestResponsive_Add_MediaHint_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Add("desktop", NewLayout("C").C(Raw("content")), "(min-width: 1024px)")
got := r.Render(ctx)
if !containsText(got, `data-variant="desktop"`) {
t.Fatalf("responsive should still contain data-variant, got:\n%s", got)
}
if !containsText(got, `data-media="(min-width: 1024px)"`) {
t.Fatalf("responsive should expose media hint, got:\n%s", got)
}
}
func TestResponsive_MultiVariant_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
Variant("mobile", NewLayout("C").C(Raw("c")))
got := r.Render(ctx)
for _, v := range []string{"desktop", "tablet", "mobile"} {
if !containsText(got, `data-variant="`+v+`"`) {
t.Errorf("responsive missing variant %q in:\n%s", v, got)
}
}
}
func TestResponsive_VariantOrder_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
Variant("mobile", NewLayout("C").C(Raw("m")))
got := r.Render(ctx)
di := indexText(got, `data-variant="desktop"`)
mi := indexText(got, `data-variant="mobile"`)
if di < 0 || mi < 0 {
t.Fatalf("missing variants in:\n%s", got)
}
if di >= mi {
t.Errorf("desktop should appear before mobile (insertion order), desktop=%d mobile=%d", di, mi)
}
}
func TestResponsive_NestedPaths_Good(t *testing.T) {
ctx := NewContext()
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").C(inner))
got := r.Render(ctx)
if !containsText(got, `data-block="C.0"`) {
t.Errorf("nested layout in responsive variant missing C.0 in:\n%s", got)
}
if !containsText(got, `data-block="C.0.1"`) {
t.Errorf("nested layout in responsive variant missing C.0.1 in:\n%s", got)
}
if !containsText(got, `data-block="C.0.2"`) {
t.Errorf("nested layout in responsive variant missing C.0.2 in:\n%s", got)
}
}
func TestResponsive_NestedInsideLayout_PreservesBlockPath_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("mobile", NewLayout("C").C(Raw("content")))
got := NewLayout("C").C(r).Render(ctx)
if !containsText(got, `data-variant="mobile"`) {
t.Fatalf("responsive wrapper missing variant container in:\n%s", got)
}
if !containsText(got, `data-block="C.0"`) {
t.Fatalf("responsive wrapper should preserve outer layout path, got:\n%s", got)
}
}
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
Variant("b", NewLayout("HCF").C(Raw("content-b")))
got := r.Render(ctx)
count := countText(got, `data-block="C"`)
if count != 2 {
t.Errorf("expected 2 independent C blocks, got %d in:\n%s", count, got)
}
}
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
var _ Node = NewResponsive()
}
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
var r *Responsive
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
if got == nil {
t.Fatal("expected non-nil responsive from Variant on nil receiver")
}
if output := got.Render(NewContext()); output != `<div data-variant="mobile"><main role="main" data-block="C">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">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,14 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateText(ctx *Context, key string, args ...any) string {
if ctx != nil {
args = translationArgs(ctx, key, args)
if ctx.service != nil {
return ctx.service.T(key, args...)
}
}
return translateDefault(key, args...)
}

View file

@ -1,105 +0,0 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import (
"strconv"
"strings"
)
func translationArgs(ctx *Context, key string, args []any) []any {
if ctx == nil {
return args
}
if !strings.HasPrefix(key, "i18n.count.") {
return args
}
count, ok := contextCount(ctx)
if !ok {
return args
}
if len(args) == 0 {
return []any{count}
}
if !isCountLike(args[0]) {
return append([]any{count}, args...)
}
return args
}
func contextCount(ctx *Context) (int, bool) {
if ctx == nil {
return 0, false
}
if n, ok := contextCountMap(ctx.Data); ok {
return n, true
}
if n, ok := contextCountMap(ctx.Metadata); ok {
return n, true
}
return 0, false
}
func contextCountMap(data map[string]any) (int, bool) {
if len(data) == 0 {
return 0, false
}
if v, ok := data["Count"]; ok {
if n, ok := countInt(v); ok {
return n, true
}
}
if v, ok := data["count"]; ok {
if n, ok := countInt(v); ok {
return n, true
}
}
return 0, false
}
func countInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int8:
return int(n), true
case int16:
return int(n), true
case int32:
return int(n), true
case int64:
return int(n), true
case uint:
return int(n), true
case uint8:
return int(n), true
case uint16:
return int(n), true
case uint32:
return int(n), true
case uint64:
return int(n), true
case float32:
return int(n), true
case float64:
return int(n), true
case string:
n = strings.TrimSpace(n)
if n == "" {
return 0, false
}
if parsed, err := strconv.Atoi(n); err == nil {
return parsed, true
}
}
return 0, false
}
func isCountLike(v any) bool {
_, ok := countInt(v)
return ok
}

View file

@ -1,13 +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
}