Compare commits

...

60 commits
v0.1.3 ... dev

Author SHA1 Message Date
Snider
4a924b0be4 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:12 +01:00
Virgil
f543f02cc1 feat(html): add layout variant validation helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:19:30 +00:00
Virgil
8402485489 fix(html): use locale setter in render path
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:16:47 +00:00
Virgil
5784b76990 fix(wasm): harden renderToString arg handling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Add type guards for variant and locale inputs at the WASM boundary and a js+wasm integration test suite for renderToString behavior.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 07:51:45 +00:00
Virgil
70a3096518 chore: improve CSS selector escaping for control chars
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-03 07:37:07 +00:00
Virgil
8abd428227 fix(codegen): validate custom element tags
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 54s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:49:42 +00:00
Virgil
c088e5a5ac feat(codegen): emit module boundary in TypeScript definitions
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 58s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:54:40 +00:00
Virgil
1d11472136 feat(html): add role accessibility helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:51:01 +00:00
Virgil
2e2af31c1d feat(html): add locale setter for context translators
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m1s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:48:08 +00:00
Virgil
b9e2630da3 feat(html): allow swapping context translators
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 59s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:44:15 +00:00
Virgil
c2ff591ec9 feat(html): apply attrs through iterator wrappers
All checks were successful
Security Scan / security (push) Successful in 10s
Test / test (push) Successful in 59s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:40:44 +00:00
Virgil
60d8225a83 feat(html): preserve layout paths in iterators
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 59s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:27:50 +00:00
Virgil
8e9ca0091c feat(html): apply locale to context translators
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m1s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:25:09 +00:00
Virgil
cb901dbb71 feat(html): allow locale in context constructors
All checks were successful
Security Scan / security (push) Successful in 10s
Test / test (push) Successful in 56s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:20:19 +00:00
Virgil
4a3a69e8b7 fix(html): preserve switch wrapper paths
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 59s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:07:35 +00:00
Virgil
14c16b5385 feat(codegen): add watch mode for bundle generation
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 58s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:04:21 +00:00
Virgil
1f98026d04 feat(html): add layout variant validation sentinel
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 55s
Expose VariantError() on Layout and ErrInvalidLayoutVariant for invalid variant strings while preserving current render behaviour.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:01:28 +00:00
Virgil
8386c7e57d fix(html): preserve block paths through conditional wrappers
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 56s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:58:38 +00:00
Virgil
5d13a4028b fix(html): validate block ID parsing
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 56s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:55:44 +00:00
Virgil
a928d01b9e feat(codegen): add TypeScript CLI output
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 52s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:52:29 +00:00
Virgil
12a7d2497b feat(codegen): add TypeScript definitions generator
All checks were successful
Security Scan / security (push) Successful in 10s
Test / test (push) Successful in 56s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:48:15 +00:00
Virgil
c63f0a2cbe feat(html): add responsive variant selector helper
All checks were successful
Security Scan / security (push) Successful in 16s
Test / test (push) Successful in 1m4s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:44:30 +00:00
Virgil
c1852f86aa feat(html): add focus management helpers
All checks were successful
Security Scan / security (push) Successful in 15s
Test / test (push) Successful in 44s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:31:34 +00:00
Virgil
4ae93ce36f feat(html): add accessibility attribute helpers
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 55s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:28:53 +00:00
Virgil
65c0dd3e27 fix(html): default nil render contexts
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 48s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:22:02 +00:00
Virgil
f9f0aa197b fix(codegen): make bundle generation deterministic
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 50s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 05:34:47 +00:00
Virgil
714d7adc90 test(responsive): align nil variant output with semantic roles
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 47s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 01:18:03 +00:00
Virgil
911071d2b0 fix(core): harden layout and responsive nil chains
Some checks failed
Security Scan / security (push) Successful in 8s
Test / test (push) Failing after 31s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 00:28:03 +00:00
Virgil
c6fd135239 fix(core): harden remaining nil-safe rendering paths
All checks were successful
Security Scan / security (push) Successful in 10s
Test / test (push) Successful in 50s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 00:09:50 +00:00
Virgil
cae46f9c61 chore(codegen): remove panic exits from cli path
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 45s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 23:45:41 +00:00
Virgil
0318d73a12 fix(core): harden nil-safe rendering paths
All checks were successful
Security Scan / security (push) Successful in 15s
Test / test (push) Successful in 43s
- guard nil receivers and nodes in core render flows
- make Render() safe for nil input
- add compile-time Node contract for Responsive

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 23:10:48 +00:00
8c7a9de546 Merge pull request '[agent/codex] Update specs/codegen/RFC.md from codegen/*.go. Document ever...' (#13) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 56s
2026-03-27 21:50:48 +00:00
Virgil
33d9e0c516 docs(specs): add codegen RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 21:50:33 +00:00
adc9403883 Merge pull request '[agent/codex] A specs/ folder has been injected into this workspace with R...' (#12) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 44s
2026-03-27 19:56:49 +00:00
Virgil
f21562c555 docs: add generated package specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 19:56:29 +00:00
Claude
adcb98ee2f
chore: bump i18n v0.2.0 → v0.2.1
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 1m35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:26:33 +00:00
44e3478be0 Merge pull request '[agent/codex] Full AX v0.8.0 compliance review. Read CODEX.md and .core/re...' (#11) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 50s
2026-03-27 04:56:29 +00:00
Virgil
11f18a24d2 fix(tests): complete ax naming compliance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 04:55:58 +00:00
1c61fde5fc Merge pull request '[agent/codex] VERIFICATION PASS — report findings only. grep ALL .go fil...' (#10) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 46s
2026-03-27 03:17:33 +00:00
Virgil
df5035c3c4 chore: verification pass
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 03:16:36 +00:00
df19b84051 Merge pull request '[agent/codex] AX v0.8.0 polish pass. Fix ALL violations — banned imports...' (#9) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m31s
2026-03-26 18:12:42 +00:00
Virgil
3616ad3a76 chore: polish ax v0.8.0 conventions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 18:12:06 +00:00
2a5bd5cbba Merge pull request '[agent/codex] Upgrade this package to dappco.re/go/core v0.8.0-alpha.1. Re...' (#8) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m31s
2026-03-26 15:24:50 +00:00
Virgil
b8d06460d6 refactor(core): upgrade to v0.8.0-alpha.1
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 15:24:16 +00:00
Virgil
0e976b3a87 fix(wasm): keep server i18n out of js builds
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:31:31 +00:00
Virgil
8a3f28aff3 fix(conventions): isolate banned imports and clarify tests
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:15:24 +00:00
b3f622988d Merge pull request '[agent/claude] Update go.mod require lines from forge.lthn.ai to dappco.re ...' (#3) from agent/update-go-mod-require-lines-from-forge-l into main
Some checks failed
Security Scan / security (push) Successful in 9s
Test / test (push) Failing after 22s
2026-03-22 01:28:48 +00:00
Snider
c525437ed6 refactor(module): migrate module path from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (pull_request) Successful in 9s
Test / test (pull_request) Failing after 20s
Update module path from forge.lthn.ai/core/go-html to dappco.re/go/core/html.
Migrate all .go import paths, update dependency versions (core v0.5.0,
log v0.1.0, io v0.2.0), and add replace directives for local development.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:28:30 +00:00
Snider
666e3a68c6 Merge remote-tracking branch 'github/dev'
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m40s
2026-03-22 00:57:15 +00:00
Snider
913bbb555a Merge remote-tracking branch 'origin/main' 2026-03-22 00:57:15 +00:00
Snider
63714ec9a1 chore: sync dependencies for v0.1.8
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:55:33 +00:00
Snider
d8525255e0 chore: sync dependencies for v0.1.7
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:50:17 +00:00
Snider
0607c5b517 ci: add Core ecosystem CI workflow with CodeRabbit auto-fix
Uses dAppCore/build actions for test, auto-fix on CodeRabbit changes,
and auto-merge on CodeRabbit approval.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 14:05:43 +00:00
ba26232b27 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#1) from agent/dx-audit-and-fix--1--review-claude-md into main
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 48s
2026-03-17 08:21:27 +00:00
Snider
e532c219b9 fix(dx): update CLAUDE.md size gate, sync Makefile limit, add coverage tests
All checks were successful
Security Scan / security (pull_request) Successful in 7s
Test / test (pull_request) Successful in 38s
- CLAUDE.md: correct WASM raw size gate from 3 MB to 3.5 MB (matches size_test.go)
- CLAUDE.md: document error handling (log.E) and file I/O (coreio.Local) conventions
- Makefile: sync WASM_RAW_LIMIT to 3670016 (3.5 MB) to match size_test.go
- Tests: add coverage for NewContextWithService, Attr through wrapper nodes,
  Unless(true), and Text.Render with i18n service — core package 95.8% → 99.4%

No fmt.Errorf or os.ReadFile/os.WriteFile violations found.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:21:15 +00:00
Snider
44b3f77806 chore: sync dependencies for v0.1.6
All checks were successful
Security Scan / security (push) Successful in 12s
Test / test (push) Successful in 1m43s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:20:53 +00:00
Snider
6e59bf8bf8 refactor(conventions): replace os.ReadFile with coreio.Local, fmt.Errorf with log.E
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 1m33s
Replace os.ReadFile in cmd/wasm/size_test.go with coreio.Local.Read().
Replace fmt.Errorf/errors.New with log.E() in codegen, cmd/wasm/register,
and cmd/codegen. Add forge.lthn.ai/core/go-io as a dependency.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 19:17:34 +00:00
Snider
2d16ce9d69 fix(conventions): alias stdlib io, add interface checks, use go-log
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 1m34s
- Alias stdlib `io` as `goio` in cmd/codegen/main.go to follow project conventions
- Add compile-time Node interface checks for all node types in node.go
  and Layout in layout.go
- Replace fmt.Fprintf stderr logging with go-log in cmd/codegen/main.go
- Replace fmt.Println with go-log in cmd/wasm/register.go

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 16:40:39 +00:00
Snider
176ef74dfd chore: sync go.mod dependencies
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 1m58s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:36:04 +00:00
Snider
050f8d9967 chore: add .core/ and .idea/ to .gitignore
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 50s
2026-03-15 10:17:49 +00:00
52 changed files with 2331 additions and 334 deletions

54
.github/workflows/ci.yml vendored Normal file
View file

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

View file

@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Agent instructions for `go-html`. Module path: `forge.lthn.ai/core/go-html`
Agent instructions for `go-html`. Module path: `dappco.re/go/core/html`
## Commands
@ -27,7 +27,7 @@ See `docs/architecture.md` for full detail. Summary:
- **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 MB raw, < 1 MB gzip
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
## Server/Client Split
@ -40,8 +40,9 @@ Files guarded with `//go:build !js` are excluded from WASM:
## Dependencies
- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n)
- `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`)
@ -53,6 +54,8 @@ Files guarded with `//go:build !js` are excluded from WASM:
- 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

View file

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

View file

@ -4,7 +4,7 @@
# go-html
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
**Module**: `forge.lthn.ai/core/go-html`
**Licence**: EUPL-1.2

View file

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

View file

@ -1,43 +1,181 @@
// Package main provides a build-time CLI for generating Web Component JS bundles.
// Reads a JSON slot map from stdin, writes the generated JS to stdout.
//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 (
"encoding/json"
"fmt"
"io"
"context"
"errors"
"flag"
goio "io"
"os"
"os/signal"
"time"
"forge.lthn.ai/core/go-html/codegen"
core "dappco.re/go/core"
"dappco.re/go/core/html/codegen"
coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log"
)
func run(r io.Reader, w io.Writer) error {
data, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("codegen: reading stdin: %w", err)
}
func generate(data []byte, emitTypes bool) (string, error) {
var slots map[string]string
if err := json.Unmarshal(data, &slots); err != nil {
return fmt.Errorf("codegen: invalid JSON: %w", err)
if result := core.JSONUnmarshal(data, &slots); !result.OK {
err, _ := result.Value.(error)
return "", log.E("codegen", "invalid JSON", err)
}
js, err := codegen.GenerateBundle(slots)
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 = io.WriteString(w, js)
_, 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 errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-time.After(pollInterval):
}
}
}
func readLocalFile(path string) ([]byte, error) {
f, err := coreio.Local.Open(path)
if err != nil {
return nil, err
}
defer func() {
_ = f.Close()
}()
return goio.ReadAll(f)
}
func writeLocalFile(path, content string) error {
f, err := coreio.Local.Create(path)
if err != nil {
return err
}
defer func() {
_ = f.Close()
}()
_, err = goio.WriteString(f, content)
return err
}
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() {
if err := run(os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
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,51 +1,179 @@
//go:build !js
package main
import (
"bytes"
"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_Good(t *testing.T) {
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
var output bytes.Buffer
func TestRun_WritesBundle_Good(t *testing.T) {
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder()
err := run(input, &output)
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, strings.Count(js, "extends HTMLElement"))
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
}
func TestRun_Bad_InvalidJSON(t *testing.T) {
input := strings.NewReader(`not json`)
var output bytes.Buffer
func TestRun_InvalidJSON_Bad(t *testing.T) {
input := core.NewReader(`not json`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
func TestRun_Bad_InvalidTag(t *testing.T) {
input := strings.NewReader(`{"H":"notag"}`)
var output bytes.Buffer
func TestRun_InvalidTag_Bad(t *testing.T) {
input := core.NewReader(`{"H":"notag"}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hyphen")
}
func TestRun_Good_Empty(t *testing.T) {
input := strings.NewReader(`{}`)
var output bytes.Buffer
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
output := core.NewBuilder()
err := run(input, &output)
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

@ -5,7 +5,7 @@ package main
import (
"syscall/js"
html "forge.lthn.ai/core/go-html"
html "dappco.re/go/core/html"
)
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
@ -13,15 +13,19 @@ import (
// 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 {
if len(args) < 1 || args[0].Type() != js.TypeString {
return ""
}
variant := args[0].String()
if variant == "" {
return ""
}
ctx := html.NewContext()
if len(args) >= 2 {
ctx.Locale = args[1].String()
if len(args) >= 2 && args[1].Type() == js.TypeString {
ctx.SetLocale(args[1].String())
}
layout := html.NewLayout(variant)

55
cmd/wasm/main_test.go Normal file
View file

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

View file

@ -3,10 +3,10 @@
package main
import (
"encoding/json"
"fmt"
core "dappco.re/go/core"
"forge.lthn.ai/core/go-html/codegen"
"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.
@ -15,12 +15,13 @@ import (
// Use cmd/codegen/ CLI instead for build-time generation.
func buildComponentJS(slotsJSON string) (string, error) {
var slots map[string]string
if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
return "", fmt.Errorf("registerComponents: %w", err)
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() {
fmt.Println("go-html WASM module — build with GOOS=js GOARCH=wasm")
log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm")
}

View file

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

View file

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

View file

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

View file

@ -1,11 +1,40 @@
//go:build !js
package codegen
import (
"fmt"
"strings"
"sort"
"text/template"
core "dappco.re/go/core"
log "dappco.re/go/core/log"
)
// isValidCustomElementTag reports whether tag is a safe custom element name.
// The generator rejects values that would fail at customElements.define() time.
func isValidCustomElementTag(tag string) bool {
if tag == "" || !core.Contains(tag, "-") {
return false
}
if tag[0] < 'a' || tag[0] > 'z' {
return false
}
for i := range len(tag) {
ch := tag[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
case ch == '-' || ch == '.' || ch == '_':
default:
return false
}
}
return true
}
// wcTemplate is the Web Component class template.
// 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).
@ -29,12 +58,13 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
}`))
// 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 !strings.Contains(tag, "-") {
return "", fmt.Errorf("codegen: custom element tag %q must contain a hyphen", tag)
if !isValidCustomElementTag(tag) {
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
}
var b strings.Builder
err := wcTemplate.Execute(&b, struct {
b := core.NewBuilder()
err := wcTemplate.Execute(b, struct {
ClassName, Tag, Slot string
}{
ClassName: TagToClassName(tag),
@ -42,22 +72,24 @@ func GenerateClass(tag, slot string) (string, error) {
Slot: slot,
})
if err != nil {
return "", fmt.Errorf("codegen: template exec: %w", err)
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 fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
return `customElements.define("` + tag + `", ` + className + `);`
}
// TagToClassName converts a kebab-case tag to PascalCase class name.
// Usage example: className := TagToClassName("nav-bar")
func TagToClassName(tag string) string {
var b strings.Builder
for p := range strings.SplitSeq(tag, "-") {
b := core.NewBuilder()
for _, p := range core.Split(tag, "-") {
if len(p) > 0 {
b.WriteString(strings.ToUpper(p[:1]))
b.WriteString(core.Upper(p[:1]))
b.WriteString(p[1:])
}
}
@ -66,11 +98,18 @@ func TagToClassName(tag string) 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)
var b strings.Builder
b := core.NewBuilder()
keys := make([]string, 0, len(slots))
for slot := range slots {
keys = append(keys, slot)
}
sort.Strings(keys)
for slot, tag := range slots {
for _, slot := range keys {
tag := slots[slot]
if seen[tag] {
continue
}
@ -78,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
cls, err := GenerateClass(tag, slot)
if err != nil {
return "", err
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
}
b.WriteString(cls)
b.WriteByte('\n')

View file

@ -1,3 +1,5 @@
//go:build !js
package codegen
import (
@ -8,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestGenerateClass_Good(t *testing.T) {
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")
@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) {
assert.Contains(t, js, "photo-grid")
}
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
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")
}
func TestGenerateRegistration_Good(t *testing.T) {
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 TestTagToClassName_Good(t *testing.T) {
func TestTagToClassName_KebabCase_Good(t *testing.T) {
tests := []struct{ tag, want string }{
{"photo-grid", "PhotoGrid"},
{"nav-breadcrumb", "NavBreadcrumb"},
@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) {
}
}
func TestGenerateBundle_Good(t *testing.T) {
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, strings.Count(js, "extends HTMLElement"))
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 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
}

13
codegen/doc.go Normal file
View file

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

61
codegen/typescript.go Normal file
View file

@ -0,0 +1,61 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package codegen
import (
"sort"
core "dappco.re/go/core"
)
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
// a set of custom elements generated from HLCRF slot assignments.
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
func GenerateTypeScriptDefinitions(slots map[string]string) string {
seen := make(map[string]bool)
declared := make(map[string]bool)
b := core.NewBuilder()
keys := make([]string, 0, len(slots))
for slot := range slots {
keys = append(keys, slot)
}
sort.Strings(keys)
b.WriteString("declare global {\n")
b.WriteString(" interface HTMLElementTagNameMap {\n")
for _, slot := range keys {
tag := slots[slot]
if !isValidCustomElementTag(tag) || seen[tag] {
continue
}
seen[tag] = true
b.WriteString(" \"")
b.WriteString(tag)
b.WriteString("\": ")
b.WriteString(TagToClassName(tag))
b.WriteString(";\n")
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
for _, slot := range keys {
tag := slots[slot]
if !seen[tag] || declared[tag] {
continue
}
declared[tag] = true
b.WriteString("export declare class ")
b.WriteString(TagToClassName(tag))
b.WriteString(" extends HTMLElement {\n")
b.WriteString(" connectedCallback(): void;\n")
b.WriteString(" render(html: string): void;\n")
b.WriteString("}\n\n")
}
b.WriteString("export {};\n")
return b.String()
}

View file

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

90
context_test.go Normal file
View file

@ -0,0 +1,90 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
)
func TestNewContext_OptionalLocale_Good(t *testing.T) {
ctx := NewContext("en-GB")
if ctx == nil {
t.Fatal("NewContext returned nil")
}
if ctx.Locale != "en-GB" {
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
}
if ctx.Data == nil {
t.Fatal("NewContext should initialise Data")
}
}
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc, "fr-FR")
if ctx == nil {
t.Fatal("NewContextWithService returned nil")
}
if ctx.Locale != "fr-FR" {
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
}
if ctx.service == nil {
t.Fatal("NewContextWithService should set translator service")
}
}
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc, "fr-FR")
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
}
}
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContext("fr-FR")
if got := ctx.SetService(svc); got != ctx {
t.Fatal("SetService should return the same context for chaining")
}
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
}
}
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
var ctx *Context
if got := ctx.SetService(nil); got != nil {
t.Fatal("SetService on nil context should return nil")
}
}
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc)
if got := ctx.SetLocale("fr-FR"); got != ctx {
t.Fatal("SetLocale should return the same context for chaining")
}
got := Text("prompt.yes").Render(ctx)
if got != "o" {
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
}
}
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
var ctx *Context
if got := ctx.SetLocale("en-GB"); got != nil {
t.Fatal("SetLocale on nil context should return nil")
}
}

12
doc.go Normal file
View file

@ -0,0 +1,12 @@
// 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

@ -17,13 +17,18 @@ type Node interface {
}
```
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers:
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`, and `Entitled` wrappers. Returns the node for chaining. |
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
| `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. |
@ -50,16 +55,16 @@ type Context struct {
Locale string // BCP 47 locale string
Entitlements func(feature string) bool // feature gate callback
Data map[string]any // arbitrary per-request data
service *i18n.Service // unexported; set via constructor
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 a specific `i18n.Service` instance.
- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction.
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
@ -161,6 +166,8 @@ html.NewResponsive().
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic.
`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.

View file

@ -66,7 +66,7 @@ go test ./cmd/codegen/
go test ./cmd/wasm/
```
The WASM size gate test (`TestWASMBinarySize_Good`) 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.
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
@ -145,6 +145,24 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
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
@ -278,7 +296,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
### Codegen Tests with Testify
```go
func TestGenerateClass_Good(t *testing.T) {
func TestGenerateClass_ValidTag(t *testing.T) {
js, err := GenerateClass("photo-grid", "C")
require.NoError(t, err)
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
@ -291,6 +309,6 @@ func TestGenerateClass_Good(t *testing.T) {
- `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 i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction.
- `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()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.

View file

@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
### Size gate test (`aae5d21`)
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
`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).
@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
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.
@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
- **Accessibility helpers**`aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
- **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

@ -39,9 +39,9 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| Path | Purpose |
|------|---------|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled` |
| `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) |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
| `render.go` | `Render()` convenience function |
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
@ -52,11 +52,11 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
## 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, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`).
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), and accessibility helpers (`AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, `Role`).
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting.
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.

View file

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

13
go.mod
View file

@ -1,16 +1,21 @@
module forge.lthn.ai/core/go-html
module dappco.re/go/core/html
go 1.26.0
require (
forge.lthn.ai/core/go-i18n v0.1.0
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.0 // indirect
dappco.re/go/core/inference v0.1.4 // indirect
dappco.re/go/core/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.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

22
go.sum
View file

@ -1,7 +1,17 @@
forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
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=
@ -14,8 +24,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=

View file

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

187
layout.go
View file

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

View file

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

143
node.go
View file

@ -5,16 +5,31 @@ import (
"iter"
"maps"
"slices"
"strings"
i18n "forge.lthn.ai/core/go-i18n"
"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,
@ -44,11 +59,15 @@ type rawNode struct {
}
// 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
}
@ -61,6 +80,7 @@ type elNode struct {
}
// 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,
@ -70,8 +90,13 @@ func El(tag string, children ...Node) Node {
}
// Attr sets an attribute on an El node. Returns the node for chaining.
// It recursively traverses through wrappers like If, Unless, and Entitled.
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
// It recursively traverses through wrappers like If, Unless, Entitled, and Each.
func Attr(n Node, key, value string) Node {
if n == nil {
return n
}
switch t := n.(type) {
case *elNode:
t.attrs[key] = value
@ -81,12 +106,52 @@ func Attr(n Node, key, value string) Node {
Attr(t.node, key, value)
case *entitledNode:
Attr(t.node, key, value)
case *switchNode:
for _, child := range t.cases {
Attr(child, key, value)
}
case attrApplier:
t.applyAttr(key, value)
}
return n
}
// 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 {
var b strings.Builder
if n == nil {
return ""
}
b := newTextBuilder()
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
@ -109,6 +174,9 @@ func (n *elNode) Render(ctx *Context) string {
}
for i := range len(n.children) {
if n.children[i] == nil {
continue
}
b.WriteString(n.children[i].Render(ctx))
}
@ -134,19 +202,17 @@ type textNode struct {
}
// 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 {
var text string
if ctx != nil && ctx.service != nil {
text = ctx.service.T(n.key, n.args...)
} else {
text = i18n.T(n.key, n.args...)
if n == nil {
return ""
}
return escapeHTML(text)
return escapeHTML(translateText(ctx, n.key, n.args...))
}
// --- ifNode ---
@ -157,11 +223,15 @@ type ifNode struct {
}
// 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)
}
@ -176,11 +246,15 @@ type unlessNode struct {
}
// 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)
}
@ -195,12 +269,16 @@ type entitledNode struct {
}
// 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 ""
}
@ -215,13 +293,23 @@ type switchNode struct {
}
// 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 ""
@ -234,20 +322,49 @@ type eachNode[T any] struct {
fn func(T) Node
}
type attrApplier interface {
applyAttr(key, value string)
}
// 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 EachSeq(slices.Values(items), 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]{items: items, fn: fn}
}
func (n *eachNode[T]) Render(ctx *Context) string {
var b strings.Builder
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 || n.items == nil {
return ""
}
b := newTextBuilder()
for item := range n.items {
b.WriteString(n.fn(item).Render(ctx))
child := n.fn(item)
if child == nil {
continue
}
b.WriteString(renderWithLayoutPath(child, ctx, path))
}
return b.String()
}

View file

@ -1,11 +1,13 @@
package html
import (
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
"slices"
)
func TestRawNode_Render(t *testing.T) {
func TestRawNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := Raw("hello")
got := node.Render(ctx)
@ -14,7 +16,7 @@ func TestRawNode_Render(t *testing.T) {
}
}
func TestElNode_Render(t *testing.T) {
func TestElNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("content"))
got := node.Render(ctx)
@ -24,7 +26,7 @@ func TestElNode_Render(t *testing.T) {
}
}
func TestElNode_Nested(t *testing.T) {
func TestElNode_Nested_Good(t *testing.T) {
ctx := NewContext()
node := El("div", El("span", Raw("inner")))
got := node.Render(ctx)
@ -34,7 +36,7 @@ func TestElNode_Nested(t *testing.T) {
}
}
func TestElNode_MultipleChildren(t *testing.T) {
func TestElNode_MultipleChildren_Good(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("a"), Raw("b"))
got := node.Render(ctx)
@ -44,7 +46,7 @@ func TestElNode_MultipleChildren(t *testing.T) {
}
}
func TestElNode_VoidElement(t *testing.T) {
func TestElNode_VoidElement_Good(t *testing.T) {
ctx := NewContext()
node := El("br")
got := node.Render(ctx)
@ -54,7 +56,7 @@ func TestElNode_VoidElement(t *testing.T) {
}
}
func TestTextNode_Render(t *testing.T) {
func TestTextNode_Render_Good(t *testing.T) {
ctx := NewContext()
node := Text("hello")
got := node.Render(ctx)
@ -63,19 +65,19 @@ func TestTextNode_Render(t *testing.T) {
}
}
func TestTextNode_Escapes(t *testing.T) {
func TestTextNode_Escapes_Good(t *testing.T) {
ctx := NewContext()
node := Text("<script>alert('xss')</script>")
got := node.Render(ctx)
if strings.Contains(got, "<script>") {
if containsText(got, "<script>") {
t.Errorf("Text node must HTML-escape output, got %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
if !containsText(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got)
}
}
func TestIfNode_True(t *testing.T) {
func TestIfNode_True_Good(t *testing.T) {
ctx := NewContext()
node := If(func(*Context) bool { return true }, Raw("visible"))
got := node.Render(ctx)
@ -84,7 +86,7 @@ func TestIfNode_True(t *testing.T) {
}
}
func TestIfNode_False(t *testing.T) {
func TestIfNode_False_Good(t *testing.T) {
ctx := NewContext()
node := If(func(*Context) bool { return false }, Raw("hidden"))
got := node.Render(ctx)
@ -93,7 +95,7 @@ func TestIfNode_False(t *testing.T) {
}
}
func TestUnlessNode(t *testing.T) {
func TestUnlessNode_False_Good(t *testing.T) {
ctx := NewContext()
node := Unless(func(*Context) bool { return false }, Raw("visible"))
got := node.Render(ctx)
@ -102,7 +104,7 @@ func TestUnlessNode(t *testing.T) {
}
}
func TestEntitledNode_Granted(t *testing.T) {
func TestEntitledNode_Granted_Good(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
node := Entitled("premium", Raw("premium content"))
@ -112,7 +114,7 @@ func TestEntitledNode_Granted(t *testing.T) {
}
}
func TestEntitledNode_Denied(t *testing.T) {
func TestEntitledNode_Denied_Bad(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return false }
node := Entitled("premium", Raw("premium content"))
@ -122,7 +124,7 @@ func TestEntitledNode_Denied(t *testing.T) {
}
}
func TestEntitledNode_NoFunc(t *testing.T) {
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
ctx := NewContext()
node := Entitled("premium", Raw("premium content"))
got := node.Render(ctx)
@ -131,7 +133,7 @@ func TestEntitledNode_NoFunc(t *testing.T) {
}
}
func TestEachNode(t *testing.T) {
func TestEachNode_Render_Good(t *testing.T) {
ctx := NewContext()
items := []string{"a", "b", "c"}
node := Each(items, func(item string) Node {
@ -144,7 +146,7 @@ func TestEachNode(t *testing.T) {
}
}
func TestEachNode_Empty(t *testing.T) {
func TestEachNode_Empty_Good(t *testing.T) {
ctx := NewContext()
node := Each([]string{}, func(item string) Node {
return El("li", Raw(item))
@ -155,7 +157,35 @@ func TestEachNode_Empty(t *testing.T) {
}
}
func TestElNode_Attr(t *testing.T) {
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-0"><main role="main" data-block="C-0-C-0">item</main></main>`
if got != want {
t.Fatalf("Each nested layout render = %q, want %q", got, want)
}
}
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("item"))
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
return item
})
got := NewLayout("C").C(node).Render(ctx)
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
if got != want {
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
}
}
func TestElNode_Attr_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("content")), "class", "container")
got := node.Render(ctx)
@ -165,25 +195,70 @@ func TestElNode_Attr(t *testing.T) {
}
}
func TestElNode_AttrEscaping(t *testing.T) {
func TestElNode_AttrEscaping_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !strings.Contains(got, `alt="he said &#34;hello&#34;"`) {
if !containsText(got, `alt="he said &#34;hello&#34;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}
func TestElNode_MultipleAttrs(t *testing.T) {
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 !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
t.Errorf("multiple Attr() calls should stack, got %q", got)
}
}
func TestAttr_NonElement(t *testing.T) {
func TestAttr_NonElement_Ugly(t *testing.T) {
node := Attr(Raw("text"), "class", "x")
got := node.Render(NewContext())
if got != "text" {
@ -191,7 +266,106 @@ func TestAttr_NonElement(t *testing.T) {
}
}
func TestSwitchNode(t *testing.T) {
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_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"),

21
path.go
View file

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

View file

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

View file

@ -3,16 +3,17 @@
package html
import (
"strings"
core "dappco.re/go/core"
"forge.lthn.ai/core/go-i18n/reversal"
"dappco.re/go/core/i18n/reversal"
)
// 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 {
var b strings.Builder
b := core.NewBuilder()
inTag := false
prevSpace := true // starts true to trim leading space
for _, r := range html {
@ -40,16 +41,20 @@ func StripTags(html string) string {
}
}
}
return strings.TrimSpace(b.String())
return core.Trim(b.String())
}
// 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 := node.Render(ctx)
rendered := ""
if node != nil {
rendered = node.Render(ctx)
}
text := StripTags(rendered)
tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text)
@ -58,10 +63,14 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
// 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
@ -70,6 +79,9 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
var imprints []named
for _, v := range r.variants {
if v.layout == nil {
continue
}
imp := Imprint(v.layout, ctx)
imprints = append(imprints, named{name: v.name, imp: imp})
}

View file

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

View file

@ -1,7 +1,11 @@
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()
}

View file

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

View file

@ -1,8 +1,15 @@
package html
import "strings"
import (
"strconv"
"strings"
)
// Compile-time interface check.
var _ Node = (*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
@ -14,21 +21,38 @@ type responsiveVariant struct {
}
// 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.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
if r == nil {
r = NewResponsive()
}
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
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 {
var b strings.Builder
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))
b.WriteString(`">`)
@ -37,3 +61,36 @@ func (r *Responsive) Render(ctx *Context) string {
}
return b.String()
}
// VariantSelector returns a CSS attribute selector for a responsive variant.
// Usage example: selector := VariantSelector("desktop")
func VariantSelector(name string) string {
return `[data-variant="` + escapeCSSString(name) + `"]`
}
func escapeCSSString(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch r {
case '\\', '"':
b.WriteByte('\\')
b.WriteRune(r)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
for i := 0; i < len(esc); i++ {
b.WriteByte(esc[i])
}
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}

View file

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

11
specs/cmd/codegen.md Normal file
View file

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

11
specs/cmd/wasm.md Normal file
View file

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

34
specs/codegen.md Normal file
View file

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

34
specs/codegen/RFC.md Normal file
View file

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

225
specs/root.md Normal file
View file

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

48
test_helpers_test.go Normal file
View file

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

38
text_builder_default.go Normal file
View file

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

33
text_builder_js.go Normal file
View file

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

11
text_translate.go Normal file
View file

@ -0,0 +1,11 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateText(ctx *Context, key string, args ...any) string {
if ctx != nil && ctx.service != nil {
return ctx.service.T(key, args...)
}
return translateDefault(key, args...)
}

11
text_translate_default.go Normal file
View file

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

9
text_translate_js.go Normal file
View file

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