fix(html): stabilize nested layout block ids
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
9757d661ed
commit
3b2900838b
65 changed files with 22 additions and 6597 deletions
|
|
@ -1,25 +0,0 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: go-html
|
||||
description: HTML templating engine
|
||||
main: ./cmd/wasm
|
||||
binary: core-html-wasm
|
||||
|
||||
build:
|
||||
cgo: false
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: linux
|
||||
arch: arm64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
- os: windows
|
||||
arch: amd64
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: go-html
|
||||
repository: core/go-html
|
||||
|
||||
publishers: []
|
||||
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
- refactor
|
||||
exclude:
|
||||
- chore
|
||||
- docs
|
||||
- style
|
||||
- test
|
||||
- ci
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{md,yml,yaml,json,txt}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
name: Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, 'feat/*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
security:
|
||||
uses: core/go-devops/.forgejo/workflows/security-scan.yml@main
|
||||
secrets: inherit
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: core/go-devops/.forgejo/workflows/go-test.yml@main
|
||||
with:
|
||||
race: true
|
||||
coverage: true
|
||||
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -1,54 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dAppCore/build/actions/build/core@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
run-vet: "true"
|
||||
|
||||
auto-fix:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'changes_requested'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: dAppCore/build/actions/fix@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Merge PR
|
||||
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.core/
|
||||
dist/
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
run:
|
||||
timeout: 5m
|
||||
go: "1.26"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosimple
|
||||
- ineffassign
|
||||
- typecheck
|
||||
- gocritic
|
||||
- gofmt
|
||||
disable:
|
||||
- exhaustive
|
||||
- wrapcheck
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
68
CLAUDE.md
68
CLAUDE.md
|
|
@ -1,68 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Agent instructions for `go-html`. Module path: `dappco.re/go/core/html`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go test ./... # Run all tests
|
||||
go test -run TestName ./... # Single test
|
||||
go test -short ./... # Skip slow WASM build test
|
||||
go test -bench . ./... # Benchmarks
|
||||
go test -bench . -benchmem ./... # Benchmarks with alloc stats
|
||||
go vet ./... # Static analysis
|
||||
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/ # WASM build
|
||||
make wasm # WASM build with size gate
|
||||
echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See `docs/architecture.md` for full detail. Summary:
|
||||
|
||||
- **Node interface**: `Render(ctx *Context) string` — El, Text, Raw, If, Unless, Each[T], EachSeq[T], Switch, Entitled
|
||||
- **HLCRF Layout**: Header/Left/Content/Right/Footer compositor with ARIA roles and deterministic `data-block` IDs. Variant string (e.g. "HCF", "HLCRF", "C") controls which slots render. Layouts nest via clone-on-render (thread-safe).
|
||||
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order
|
||||
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only)
|
||||
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
|
||||
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
|
||||
|
||||
## Server/Client Split
|
||||
|
||||
Files guarded with `//go:build !js` are excluded from WASM:
|
||||
|
||||
- `pipeline.go` — Imprint/CompareVariants use `go-i18n/reversal` (server-side only)
|
||||
- `cmd/wasm/register.go` — encoding/json + codegen (replaced by `cmd/codegen/` CLI)
|
||||
|
||||
**Critical WASM constraint**: Never import `encoding/json`, `text/template`, or `fmt` in WASM-linked code (files without a `!js` build tag). Use string concatenation instead of `fmt.Sprintf` in `layout.go`, `node.go`, `responsive.go`, `render.go`, `path.go`, and `context.go`. The `fmt` package alone adds ~500 KB to the WASM binary.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `dappco.re/go/core/i18n` (replace directive → local go-i18n)
|
||||
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n; not yet migrated)
|
||||
- `forge.lthn.ai/core/go-log` (indirect, via go-i18n; not yet migrated)
|
||||
- Both `go-i18n` and `go-inference` must be cloned alongside this repo for builds
|
||||
- Go 1.26+ required (uses `range` over integers, `iter.Seq`, `maps.Keys`, `slices.Collect`)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- UK English (colour, organisation, centre, behaviour, licence, serialise)
|
||||
- All types annotated; use `any` not `interface{}`
|
||||
- Tests use `testify` assert/require
|
||||
- Licence: EUPL-1.2 — add `// SPDX-Licence-Identifier: EUPL-1.2` to new files
|
||||
- Safe-by-default: HTML escaping via `html.EscapeString()` on Text nodes and attribute values, void element handling, entitlement deny-by-default
|
||||
- Deterministic output: sorted attributes on El nodes, reproducible block ID paths
|
||||
- Errors: use `log.E("scope", "message", err)` from `go-log`, never `fmt.Errorf`
|
||||
- File I/O: use `coreio.Local` from `go-io`, never `os.ReadFile`/`os.WriteFile`
|
||||
- Commits: conventional commits + `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||
|
||||
## Test Conventions
|
||||
|
||||
Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must initialise i18n before rendering:
|
||||
|
||||
```go
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
```
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
## Requirements
|
||||
- **Go Version**: 1.26 or higher is required.
|
||||
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
|
||||
|
||||
## Development Workflow
|
||||
1. **Testing**: Ensure all tests pass before submitting changes.
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
2. **Code Style**: All code must follow standard Go formatting.
|
||||
```bash
|
||||
gofmt -w .
|
||||
go vet ./...
|
||||
```
|
||||
3. **Linting**: We use `golangci-lint` to maintain code quality.
|
||||
```bash
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
## Commit Message Format
|
||||
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `refactor`: A code change that neither fixes a bug nor adds a feature
|
||||
- `chore`: Changes to the build process or auxiliary tools and libraries
|
||||
|
||||
Example: `feat: add new endpoint for health check`
|
||||
|
||||
## License
|
||||
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.
|
||||
30
Makefile
30
Makefile
|
|
@ -1,30 +0,0 @@
|
|||
.PHONY: wasm test clean
|
||||
|
||||
WASM_OUT := dist/go-html.wasm
|
||||
# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth)
|
||||
WASM_RAW_LIMIT := 3670016
|
||||
# Gzip transfer size limit: 1MB (what users actually download)
|
||||
WASM_GZ_LIMIT := 1048576
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
wasm: $(WASM_OUT)
|
||||
|
||||
$(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
|
||||
@mkdir -p dist
|
||||
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm/
|
||||
@RAW=$$(stat -c%s "$(WASM_OUT)" 2>/dev/null || stat -f%z "$(WASM_OUT)"); \
|
||||
GZ=$$(gzip -c "$(WASM_OUT)" | wc -c); \
|
||||
echo "WASM size: $${RAW} bytes raw, $${GZ} bytes gzip"; \
|
||||
if [ "$$GZ" -gt $(WASM_GZ_LIMIT) ]; then \
|
||||
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
|
||||
exit 1; \
|
||||
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
|
||||
echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \
|
||||
else \
|
||||
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -rf dist/
|
||||
48
README.md
48
README.md
|
|
@ -1,48 +0,0 @@
|
|||
[](https://pkg.go.dev/dappco.re/go/core/html)
|
||||
[](LICENSE.md)
|
||||
[](go.mod)
|
||||
|
||||
# go-html
|
||||
|
||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText, TabIndex, AutoFocus, Role), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||
|
||||
**Module**: `dappco.re/go/core/html`
|
||||
**Licence**: EUPL-1.2
|
||||
**Language**: Go 1.26
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import html "dappco.re/go/core/html"
|
||||
|
||||
page := html.NewLayout("HCF").
|
||||
H(html.El("nav", html.Text("i18n.label.navigation"))).
|
||||
C(html.El("main",
|
||||
html.El("h1", html.Text("i18n.label.welcome")),
|
||||
html.Each(items, func(item Item) html.Node {
|
||||
return html.El("li", html.Text(item.Name))
|
||||
}),
|
||||
)).
|
||||
F(html.El("footer", html.Text("i18n.label.copyright")))
|
||||
|
||||
rendered := page.Render(html.NewContext("en-GB"))
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) — node interface, HLCRF layout, responsive compositor, grammar pipeline, WASM module, codegen CLI
|
||||
- [Development Guide](docs/development.md) — building, testing, WASM build, server/client split rules
|
||||
- [Project History](docs/history.md) — completed phases and known limitations
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -bench . ./...
|
||||
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# Session Brief: core/go-html
|
||||
|
||||
**Repo**: `forge.lthn.ai/core/go-html` (clone at `/tmp/core-go-html`)
|
||||
**Module**: `forge.lthn.ai/core/go-html`
|
||||
**Status**: Current tests pass; WASM build is within budget and codegen emits JS plus TypeScript defs
|
||||
**Wiki**: https://forge.lthn.ai/core/go-html/wiki (6 pages)
|
||||
|
||||
## What This Is
|
||||
|
||||
HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composable node trees with:
|
||||
- **Node interface**: El, Text, Raw, If, Unless, Each[T], Switch, Entitled
|
||||
- **HLCRF Layout**: Header/Left/Content/Right/Footer with ARIA roles
|
||||
- **Responsive**: Multi-variant breakpoint rendering
|
||||
- **Pipeline**: Render → strip tags → tokenise via go-i18n/reversal → GrammarImprint
|
||||
- **WASM target**: `cmd/wasm/` exposes `renderToString()` to JS
|
||||
- **Codegen**: Web Component classes with closed Shadow DOM plus `.d.ts` generation
|
||||
|
||||
## Current State
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Core (node, layout, responsive, pipeline) | SOLID — all tested, clean API |
|
||||
| Tests | Passing |
|
||||
| go vet | Clean |
|
||||
| TODOs/FIXMEs | None |
|
||||
| WASM build | PASS — within the 1 MB gzip gate |
|
||||
| Codegen | Working — generates WC classes and `.d.ts` definitions |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
|
||||
- `github.com/stretchr/testify` v1.11.1
|
||||
- `golang.org/x/text` v0.33.0
|
||||
|
||||
## Priority Work
|
||||
|
||||
No active blockers recorded here. See `docs/history.md` for the remaining design choices and deferred ideas that were captured during earlier implementation phases.
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
/tmp/core-go-html/
|
||||
├── node.go (254) + node_test.go (206)
|
||||
├── layout.go (119) + layout_test.go (116)
|
||||
├── pipeline.go (83) + pipeline_test.go (128)
|
||||
├── responsive.go (39) + responsive_test.go (89)
|
||||
├── context.go (27)
|
||||
├── render.go (9) + render_test.go (97)
|
||||
├── path.go (22) + path_test.go (86)
|
||||
├── integration_test.go (52)
|
||||
├── cmd/wasm/
|
||||
│ ├── main.go (78) — WASM entry point
|
||||
│ ├── register.go (18) + register_test.go (24)
|
||||
├── codegen/
|
||||
│ ├── codegen.go (90) + codegen_test.go (54)
|
||||
├── go.mod
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- UK English (colour, organisation)
|
||||
- `declare(strict_types=1)` equivalent: all types annotated
|
||||
- Tests: testify assert/require
|
||||
- Licence: EUPL-1.2
|
||||
289
bench_test.go
289
bench_test.go
|
|
@ -1,289 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
}
|
||||
|
||||
// --- BenchmarkRender ---
|
||||
|
||||
// buildTree creates an El tree of the given depth with branching factor 3.
|
||||
func buildTree(depth int) Node {
|
||||
if depth <= 0 {
|
||||
return Raw("leaf")
|
||||
}
|
||||
children := make([]Node, 3)
|
||||
for i := range children {
|
||||
children[i] = buildTree(depth - 1)
|
||||
}
|
||||
return El("div", children...)
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth1(b *testing.B) {
|
||||
node := buildTree(1)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth3(b *testing.B) {
|
||||
node := buildTree(3)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth5(b *testing.B) {
|
||||
node := buildTree(5)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth7(b *testing.B) {
|
||||
node := buildTree(7)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_FullPage(b *testing.B) {
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Dashboard"))).
|
||||
C(
|
||||
El("div",
|
||||
El("p", Text("Welcome")),
|
||||
Each([]string{"Home", "Settings", "Profile"}, func(item string) Node {
|
||||
return El("a", Raw(item))
|
||||
}),
|
||||
),
|
||||
).
|
||||
F(El("small", Text("Footer")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
page.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkImprint ---
|
||||
|
||||
func BenchmarkImprint_Small(b *testing.B) {
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
Imprint(page, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImprint_Large(b *testing.B) {
|
||||
items := make([]string, 20)
|
||||
for i := range items {
|
||||
items[i] = "Item " + itoaText(i) + " was created successfully"
|
||||
}
|
||||
page := NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
L(El("nav", Each(items[:5], func(s string) Node { return El("a", Text(s)) }))).
|
||||
C(El("div", Each(items, func(s string) Node { return El("p", Text(s)) }))).
|
||||
R(El("aside", Text("Completed rendering operation"))).
|
||||
F(El("small", Text("Finished processing all items")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
Imprint(page, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkCompareVariants ---
|
||||
|
||||
func BenchmarkCompareVariants_TwoVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed"))))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
CompareVariants(r, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompareVariants_ThreeVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
L(El("nav", Text("Navigation links"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
R(El("aside", Text("Sidebar content"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("tablet", NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("C").
|
||||
C(El("p", Text("Files deleted successfully"))))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
CompareVariants(r, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkLayout ---
|
||||
|
||||
func BenchmarkLayout_ContentOnly(b *testing.B) {
|
||||
layout := NewLayout("C").C(Raw("content"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_HCF(b *testing.B) {
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_HLCRF(b *testing.B) {
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_Nested(b *testing.B) {
|
||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
||||
nodes := make([]Node, 50)
|
||||
for i := range nodes {
|
||||
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
|
||||
}
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).
|
||||
C(nodes...).
|
||||
F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkEach ---
|
||||
|
||||
func BenchmarkEach_10(b *testing.B) {
|
||||
benchEach(b, 10)
|
||||
}
|
||||
|
||||
func BenchmarkEach_100(b *testing.B) {
|
||||
benchEach(b, 100)
|
||||
}
|
||||
|
||||
func BenchmarkEach_1000(b *testing.B) {
|
||||
benchEach(b, 1000)
|
||||
}
|
||||
|
||||
func benchEach(b *testing.B, n int) {
|
||||
b.Helper()
|
||||
items := make([]int, n)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw("item-"+itoaText(i)))
|
||||
})
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkResponsive ---
|
||||
|
||||
func BenchmarkResponsive_ThreeVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
|
||||
Variant("mobile", NewLayout("C").C(Raw("c")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
r.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkStripTags ---
|
||||
|
||||
func BenchmarkStripTags_Short(b *testing.B) {
|
||||
input := `<div>hello</div>`
|
||||
for b.Loop() {
|
||||
StripTags(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStripTags_Long(b *testing.B) {
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header content")).L(Raw("left sidebar")).
|
||||
C(Raw("main body content with multiple words")).
|
||||
R(Raw("right sidebar")).F(Raw("footer content"))
|
||||
input := layout.Render(NewContext())
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
StripTags(input)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// Package main provides a build-time CLI for generating Web Component bundles.
|
||||
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
goio "io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/html/codegen"
|
||||
coreio "dappco.re/go/core/io"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
func generate(data []byte, emitTypes bool) (string, error) {
|
||||
var slots map[string]string
|
||||
if result := core.JSONUnmarshal(data, &slots); !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
return "", log.E("codegen", "invalid JSON", err)
|
||||
}
|
||||
|
||||
if emitTypes {
|
||||
return codegen.GenerateTypeScriptDefinitions(slots), nil
|
||||
}
|
||||
|
||||
out, err := codegen.GenerateBundle(slots)
|
||||
if err != nil {
|
||||
return "", log.E("codegen", "generate bundle", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
|
||||
data, err := goio.ReadAll(r)
|
||||
if err != nil {
|
||||
return log.E("codegen", "reading stdin", err)
|
||||
}
|
||||
|
||||
out, err := generate(data, emitTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = goio.WriteString(w, out)
|
||||
if err != nil {
|
||||
return log.E("codegen", "writing output", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
|
||||
if inputPath == "" {
|
||||
return log.E("codegen", "watch mode requires -input", nil)
|
||||
}
|
||||
if outputPath == "" {
|
||||
return log.E("codegen", "watch mode requires -output", nil)
|
||||
}
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 250 * time.Millisecond
|
||||
}
|
||||
|
||||
var lastInput []byte
|
||||
for {
|
||||
input, err := readLocalFile(inputPath)
|
||||
if err != nil {
|
||||
return log.E("codegen", "reading input file", err)
|
||||
}
|
||||
|
||||
if !sameBytes(input, lastInput) {
|
||||
out, err := generate(input, emitTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLocalFile(outputPath, out); err != nil {
|
||||
return log.E("codegen", "writing output file", err)
|
||||
}
|
||||
lastInput = append(lastInput[:0], input...)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if core.Is(ctx.Err(), context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readLocalFile(path string) ([]byte, error) {
|
||||
f, err := coreio.Local.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
return goio.ReadAll(f)
|
||||
}
|
||||
|
||||
func writeLocalFile(path, content string) error {
|
||||
f, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
_, err = goio.WriteString(f, content)
|
||||
return err
|
||||
}
|
||||
|
||||
func sameBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range len(a) {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func main() {
|
||||
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
|
||||
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
|
||||
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
|
||||
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
|
||||
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
||||
flag.Parse()
|
||||
|
||||
if *emitWatch {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := coreio.Local.Open("/dev/stdin")
|
||||
if err != nil {
|
||||
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stdout, err := coreio.Local.Create("/dev/stdout")
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
if err := run(stdin, stdout, *emitTypes); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
goio "io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_WritesBundle_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
js := output.String()
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
}
|
||||
|
||||
func TestRun_InvalidJSON_Bad(t *testing.T) {
|
||||
input := core.NewReader(`not json`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid JSON")
|
||||
}
|
||||
|
||||
func TestRun_InvalidTag_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"notag"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hyphen")
|
||||
}
|
||||
|
||||
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "lowercase hyphenated name")
|
||||
}
|
||||
|
||||
func TestRun_EmptySlots_Good(t *testing.T) {
|
||||
input := core.NewReader(`{}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, output.String())
|
||||
}
|
||||
|
||||
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
dts := output.String()
|
||||
assert.Contains(t, dts, "declare global")
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
||||
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
||||
}
|
||||
|
||||
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inputPath := filepath.Join(dir, "slots.json")
|
||||
outputPath := filepath.Join(dir, "bundle.js")
|
||||
|
||||
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got, err := readTextFile(outputPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
|
||||
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "watch mode requires -input")
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func writeTextFile(path, content string) error {
|
||||
f, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
_, err = goio.WriteString(f, content)
|
||||
return err
|
||||
}
|
||||
|
||||
func readTextFile(path string) (string, error) {
|
||||
f, err := coreio.Local.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
data, err := goio.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// Keep the callback alive for the lifetime of the WASM module.
|
||||
var renderToStringFunc js.Func
|
||||
|
||||
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
|
||||
// Slot content is injected via Raw() — the caller is responsible for sanitisation.
|
||||
// This is intentional: the WASM module is a rendering engine for trusted content
|
||||
// produced server-side or by the application's own templates.
|
||||
func renderToString(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 || args[0].Type() != js.TypeString {
|
||||
return ""
|
||||
}
|
||||
|
||||
variant := args[0].String()
|
||||
if variant == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
locale := ""
|
||||
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
||||
locale = args[1].String()
|
||||
}
|
||||
|
||||
slots := make(map[string]string)
|
||||
|
||||
if len(args) >= 3 && args[2].Type() == js.TypeObject {
|
||||
jsSlots := args[2]
|
||||
for _, slot := range []string{"H", "L", "C", "R", "F"} {
|
||||
content := jsSlots.Get(slot)
|
||||
if content.Type() == js.TypeString {
|
||||
slots[slot] = content.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderLayout(variant, locale, slots)
|
||||
}
|
||||
|
||||
func main() {
|
||||
renderToStringFunc = js.FuncOf(renderToString)
|
||||
|
||||
api := js.Global().Get("Object").New()
|
||||
api.Set("renderToString", renderToStringFunc)
|
||||
js.Global().Set("gohtml", api)
|
||||
|
||||
select {}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
func TestRenderToString_Good(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf("en-GB"),
|
||||
js.ValueOf(map[string]any{"C": "<strong>hello</strong>"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C"><strong>hello</strong></main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_EmptySlot_Good(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf("en-GB"),
|
||||
js.ValueOf(map[string]any{"C": ""}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C"></main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString empty slot = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_VariantTypeGuard(t *testing.T) {
|
||||
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
|
||||
t.Fatalf("non-string variant should be empty, got %q", got)
|
||||
}
|
||||
|
||||
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
|
||||
t.Fatalf("missing variant should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf(123),
|
||||
js.ValueOf(map[string]any{"C": "x"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C">x</main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"dappco.re/go/core/html/codegen"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
|
||||
// This is the pure-Go part testable without WASM.
|
||||
// Excluded from WASM builds — encoding/json and text/template are too heavy.
|
||||
// Use cmd/codegen/ CLI instead for build-time generation.
|
||||
func buildComponentJS(slotsJSON string) (string, error) {
|
||||
var slots map[string]string
|
||||
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
return "", log.E("buildComponentJS", "unmarshal JSON", err)
|
||||
}
|
||||
return codegen.GenerateBundle(slots)
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm")
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
|
||||
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
|
||||
js, err := buildComponentJS(slotsJSON)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
}
|
||||
|
||||
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
|
||||
_, err := buildComponentJS("not json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package main
|
||||
|
||||
import html "dappco.re/go/core/html"
|
||||
|
||||
// renderLayout renders an HLCRF layout from a slot map.
|
||||
//
|
||||
// Empty string values are meaningful: they create an explicit empty slot
|
||||
// container rather than being treated as absent input.
|
||||
func renderLayout(variant, locale string, slots map[string]string) string {
|
||||
if variant == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx := html.NewContext()
|
||||
if locale != "" {
|
||||
ctx.SetLocale(locale)
|
||||
}
|
||||
|
||||
layout := html.NewLayout(variant)
|
||||
|
||||
for _, slot := range []string{"H", "L", "C", "R", "F"} {
|
||||
content, ok := slots[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch slot {
|
||||
case "H":
|
||||
layout.H(html.Raw(content))
|
||||
case "L":
|
||||
layout.L(html.Raw(content))
|
||||
case "C":
|
||||
layout.C(html.Raw(content))
|
||||
case "R":
|
||||
layout.R(html.Raw(content))
|
||||
case "F":
|
||||
layout.F(html.Raw(content))
|
||||
}
|
||||
}
|
||||
|
||||
return layout.Render(ctx)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenderLayout_EmptyStringSlot_Good(t *testing.T) {
|
||||
got := renderLayout("C", "en-GB", map[string]string{"C": ""})
|
||||
want := `<main role="main" data-block="C"></main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderLayout with empty slot = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
process "dappco.re/go/core/process"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
wasmGzLimit = 1_048_576 // 1 MB gzip transfer size limit
|
||||
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
||||
)
|
||||
|
||||
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping WASM build test in short mode")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
out := core.Path(dir, "gohtml.wasm")
|
||||
|
||||
factory := process.NewService(process.Options{})
|
||||
serviceValue, err := factory(core.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, ok := serviceValue.(*process.Service)
|
||||
require.True(t, ok, "process service factory returned %T", serviceValue)
|
||||
|
||||
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
|
||||
Command: "go",
|
||||
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
|
||||
Dir: ".",
|
||||
Env: []string{"GOOS=js", "GOARCH=wasm"},
|
||||
})
|
||||
require.NoError(t, err, "WASM build failed: %s", output)
|
||||
|
||||
rawStr, err := coreio.Local.Read(out)
|
||||
require.NoError(t, err)
|
||||
rawBytes := []byte(rawStr)
|
||||
|
||||
buf := core.NewBuilder()
|
||||
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
|
||||
require.NoError(t, err)
|
||||
_, err = gz.Write(rawBytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
|
||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
|
||||
|
||||
assert.Less(t, buf.Len(), wasmGzLimit,
|
||||
"WASM gzip size %d exceeds 1MB limit", buf.Len())
|
||||
assert.Less(t, len(rawBytes), wasmRawLimit,
|
||||
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>go-html WASM Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; margin: 2em; }
|
||||
pre { background: #f5f5f5; padding: 1em; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>go-html WASM Test</h1>
|
||||
<div>
|
||||
<label>Variant: <input id="variant" value="HLCRF"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>H: <input id="slot-H" value="Header"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>C: <input id="slot-C" value="Main content"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>F: <input id="slot-F" value="Footer"></label>
|
||||
</div>
|
||||
<button id="render-btn">Render</button>
|
||||
<h2>Raw HTML Output</h2>
|
||||
<pre id="raw"></pre>
|
||||
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script>
|
||||
const go = new Go();
|
||||
WebAssembly.instantiateStreaming(fetch("go-html.wasm"), go.importObject)
|
||||
.then(result => {
|
||||
go.run(result.instance);
|
||||
document.title += " (loaded)";
|
||||
});
|
||||
|
||||
document.getElementById("render-btn").addEventListener("click", function() {
|
||||
const variant = document.getElementById("variant").value;
|
||||
const slots = {
|
||||
H: document.getElementById("slot-H").value,
|
||||
C: document.getElementById("slot-C").value,
|
||||
F: document.getElementById("slot-F").value,
|
||||
};
|
||||
const result = gohtml.renderToString(variant, "en", slots);
|
||||
document.getElementById("raw").textContent = result;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkGenerateClass(b *testing.B) {
|
||||
for b.Loop() {
|
||||
GenerateClass("photo-grid", "C")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTagToClassName(b *testing.B) {
|
||||
for b.Loop() {
|
||||
TagToClassName("my-super-widget-component")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateBundle_Small(b *testing.B) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GenerateBundle(slots)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateBundle_Full(b *testing.B) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"L": "side-panel",
|
||||
"C": "main-content",
|
||||
"R": "aside-widget",
|
||||
"F": "page-footer",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GenerateBundle(slots)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateRegistration(b *testing.B) {
|
||||
for b.Loop() {
|
||||
GenerateRegistration("photo-grid", "PhotoGrid")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"text/template"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
var reservedCustomElementNames = map[string]struct{}{
|
||||
"annotation-xml": {},
|
||||
"color-profile": {},
|
||||
"font-face": {},
|
||||
"font-face-src": {},
|
||||
"font-face-uri": {},
|
||||
"font-face-format": {},
|
||||
"font-face-name": {},
|
||||
"missing-glyph": {},
|
||||
}
|
||||
|
||||
// isValidCustomElementTag reports whether tag is a valid custom element name.
|
||||
// The generator rejects values that would fail at customElements.define() time.
|
||||
func isValidCustomElementTag(tag string) bool {
|
||||
if tag == "" || !core.Contains(tag, "-") {
|
||||
return false
|
||||
}
|
||||
if !utf8.ValidString(tag) {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, reserved := reservedCustomElementNames[tag]; reserved {
|
||||
return false
|
||||
}
|
||||
|
||||
first, _ := utf8.DecodeRuneInString(tag)
|
||||
if first < 'a' || first > 'z' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range tag {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return false
|
||||
}
|
||||
switch r {
|
||||
case 0, '/', '>', '\t', '\n', '\f', '\r', ' ':
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type jsStringBuilder interface {
|
||||
WriteByte(byte) error
|
||||
WriteRune(rune) (int, error)
|
||||
WriteString(string) (int, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
// escapeJSStringLiteral escapes content for inclusion inside a double-quoted JS string.
|
||||
func escapeJSStringLiteral(s string) string {
|
||||
b := core.NewBuilder()
|
||||
appendJSStringLiteral(b, s)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendJSStringLiteral(b jsStringBuilder, s string) {
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
case 0x2028:
|
||||
b.WriteString(`\u2028`)
|
||||
case 0x2029:
|
||||
b.WriteString(`\u2029`)
|
||||
default:
|
||||
if r < 0x20 {
|
||||
appendUnicodeEscape(b, r)
|
||||
continue
|
||||
}
|
||||
if r > 0xFFFF {
|
||||
rr := r - 0x10000
|
||||
appendUnicodeEscape(b, rune(0xD800+(rr>>10)))
|
||||
appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF)))
|
||||
continue
|
||||
}
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendUnicodeEscape(b jsStringBuilder, r rune) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
b.WriteString(`\u`)
|
||||
b.WriteByte(hex[(r>>12)&0xF])
|
||||
b.WriteByte(hex[(r>>8)&0xF])
|
||||
b.WriteByte(hex[(r>>4)&0xF])
|
||||
b.WriteByte(hex[r&0xF])
|
||||
}
|
||||
|
||||
// wcTemplate is the Web Component class template.
|
||||
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
|
||||
// DOM API using trusted go-html codegen output (never user input).
|
||||
var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} extends HTMLElement {
|
||||
#shadow;
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: "closed" });
|
||||
}
|
||||
connectedCallback() {
|
||||
this.#shadow.textContent = "";
|
||||
const slot = this.getAttribute("data-slot") || "{{.SlotLiteral}}";
|
||||
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } }));
|
||||
}
|
||||
render(html) {
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = html;
|
||||
this.#shadow.textContent = "";
|
||||
this.#shadow.appendChild(tpl.content.cloneNode(true));
|
||||
}
|
||||
}`))
|
||||
|
||||
// GenerateClass produces a JS class definition for a custom element.
|
||||
// Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
func GenerateClass(tag, slot string) (string, error) {
|
||||
if !isValidCustomElementTag(tag) {
|
||||
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
||||
}
|
||||
b := core.NewBuilder()
|
||||
tagLiteral := escapeJSStringLiteral(tag)
|
||||
slotLiteral := escapeJSStringLiteral(slot)
|
||||
err := wcTemplate.Execute(b, struct {
|
||||
ClassName, TagLiteral, SlotLiteral string
|
||||
}{
|
||||
ClassName: TagToClassName(tag),
|
||||
TagLiteral: tagLiteral,
|
||||
SlotLiteral: slotLiteral,
|
||||
})
|
||||
if err != nil {
|
||||
return "", log.E("codegen.GenerateClass", "template exec", err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// GenerateRegistration produces the customElements.define() call.
|
||||
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
func GenerateRegistration(tag, className string) string {
|
||||
return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);`
|
||||
}
|
||||
|
||||
// TagToClassName converts a custom element tag to PascalCase class name.
|
||||
// Usage example: className := TagToClassName("nav-bar")
|
||||
func TagToClassName(tag string) string {
|
||||
b := core.NewBuilder()
|
||||
upperNext := true
|
||||
for _, r := range tag {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
if upperNext {
|
||||
_, _ = b.WriteRune(unicode.ToUpper(r))
|
||||
} else {
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
upperNext = false
|
||||
case unicode.IsDigit(r):
|
||||
_, _ = b.WriteRune(r)
|
||||
upperNext = false
|
||||
default:
|
||||
upperNext = true
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GenerateBundle produces all WC class definitions and registrations
|
||||
// for a set of HLCRF slot assignments.
|
||||
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
func GenerateBundle(slots map[string]string) (string, error) {
|
||||
seen := make(map[string]bool)
|
||||
b := core.NewBuilder()
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
|
||||
cls, err := GenerateClass(tag, slot)
|
||||
if err != nil {
|
||||
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
||||
}
|
||||
b.WriteString(cls)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateClass_ValidTag_Good(t *testing.T) {
|
||||
js, err := GenerateClass("photo-grid", "C")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||
assert.Contains(t, js, "attachShadow")
|
||||
assert.Contains(t, js, `mode: "closed"`)
|
||||
assert.Contains(t, js, "photo-grid")
|
||||
}
|
||||
|
||||
func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
|
||||
_, err := GenerateClass("invalid", "C")
|
||||
assert.Error(t, err, "custom element names must contain a hyphen")
|
||||
|
||||
_, err = GenerateClass("Nav-Bar", "C")
|
||||
assert.Error(t, err, "custom element names must be lowercase")
|
||||
|
||||
_, err = GenerateClass("nav bar", "C")
|
||||
assert.Error(t, err, "custom element names must reject spaces")
|
||||
|
||||
_, err = GenerateClass("annotation-xml", "C")
|
||||
assert.Error(t, err, "reserved custom element names must be rejected")
|
||||
}
|
||||
|
||||
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
|
||||
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Contains(t, js, `"photo-grid"`)
|
||||
assert.Contains(t, js, "PhotoGrid")
|
||||
}
|
||||
|
||||
func TestGenerateClass_ValidExtendedTag_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
tag string
|
||||
wantClass string
|
||||
}{
|
||||
{tag: "foo.bar-baz", wantClass: "FooBarBaz"},
|
||||
{tag: "math-α", wantClass: "MathΑ"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tag, func(t *testing.T) {
|
||||
js, err := GenerateClass(tt.tag, "C")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "class "+tt.wantClass+" extends HTMLElement")
|
||||
assert.Contains(t, js, `tag: "`+tt.tag+`"`)
|
||||
assert.Contains(t, js, `slot = this.getAttribute("data-slot") || "C";`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagToClassName_KebabCase_Good(t *testing.T) {
|
||||
tests := []struct{ tag, want string }{
|
||||
{"photo-grid", "PhotoGrid"},
|
||||
{"nav-breadcrumb", "NavBreadcrumb"},
|
||||
{"my-super-widget", "MySuperWidget"},
|
||||
{"nav_bar", "NavBar"},
|
||||
{"nav.bar", "NavBar"},
|
||||
{"nav--bar", "NavBar"},
|
||||
{"math-α", "MathΑ"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := TagToClassName(tt.tag)
|
||||
assert.Equal(t, tt.want, got, "TagToClassName(%q)", tt.tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
"F": "nav-bar",
|
||||
}
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "main-content",
|
||||
}
|
||||
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
|
||||
alpha := strings.Index(js, "class AlphaPanel")
|
||||
main := strings.Index(js, "class MainContent")
|
||||
zed := strings.Index(js, "class ZedPanel")
|
||||
|
||||
assert.NotEqual(t, -1, alpha)
|
||||
assert.NotEqual(t, -1, main)
|
||||
assert.NotEqual(t, -1, zed)
|
||||
assert.Less(t, alpha, main)
|
||||
assert.Less(t, main, zed)
|
||||
assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 3, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "alpha-panel",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
|
||||
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
|
||||
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
|
||||
assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
|
||||
assert.Contains(t, dts, "export {};")
|
||||
assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "Nav-Bar",
|
||||
"F": "nav bar",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.NotContains(t, dts, "Nav-Bar")
|
||||
assert.NotContains(t, dts, "nav bar")
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_ValidExtendedTag_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "foo.bar-baz",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `"foo.bar-baz": FooBarBaz;`)
|
||||
assert.Contains(t, dts, `export declare class FooBarBaz extends HTMLElement`)
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package codegen generates Web Component bundles for go-html slot maps.
|
||||
//
|
||||
// Use it at build time, or through the cmd/codegen CLI:
|
||||
//
|
||||
// bundle, err := GenerateBundle(map[string]string{
|
||||
// "H": "site-header",
|
||||
// "C": "app-main",
|
||||
// })
|
||||
package codegen
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
|
||||
// a set of custom elements generated from HLCRF slot assignments.
|
||||
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
|
||||
func GenerateTypeScriptDefinitions(slots map[string]string) string {
|
||||
seen := make(map[string]bool)
|
||||
declared := make(map[string]bool)
|
||||
b := core.NewBuilder()
|
||||
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteString("declare global {\n")
|
||||
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !isValidCustomElementTag(tag) || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
b.WriteString(" \"")
|
||||
b.WriteString(escapeJSStringLiteral(tag))
|
||||
b.WriteString("\": ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(";\n")
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !seen[tag] || declared[tag] {
|
||||
continue
|
||||
}
|
||||
declared[tag] = true
|
||||
b.WriteString("export declare class ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(" extends HTMLElement {\n")
|
||||
b.WriteString(" connectedCallback(): void;\n")
|
||||
b.WriteString(" render(html: string): void;\n")
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("export {};\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
129
context.go
129
context.go
|
|
@ -1,129 +0,0 @@
|
|||
package html
|
||||
|
||||
import "reflect"
|
||||
|
||||
// Translator provides Text() lookups for a rendering context.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator)
|
||||
//
|
||||
// The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
// can provide any implementation with the same T() method.
|
||||
type Translator interface {
|
||||
T(key string, args ...any) string
|
||||
}
|
||||
|
||||
// Context carries rendering state through the node tree.
|
||||
// Usage example: ctx := NewContext()
|
||||
//
|
||||
// Metadata is an alias for Data — both fields reference the same underlying map.
|
||||
// Treat them as interchangeable; use whichever reads best in context.
|
||||
type Context struct {
|
||||
Identity string
|
||||
Locale string
|
||||
Entitlements func(feature string) bool
|
||||
Data map[string]any
|
||||
Metadata map[string]any
|
||||
service Translator
|
||||
}
|
||||
|
||||
func applyLocaleToService(svc Translator, locale string) {
|
||||
if svc == nil || locale == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
|
||||
base := locale
|
||||
for i := 0; i < len(base); i++ {
|
||||
if base[i] == '-' || base[i] == '_' {
|
||||
base = base[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = setter.SetLanguage(base)
|
||||
}
|
||||
}
|
||||
|
||||
// NewContext creates a new rendering context with sensible defaults.
|
||||
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
|
||||
func NewContext(locale ...string) *Context {
|
||||
data := make(map[string]any)
|
||||
ctx := &Context{
|
||||
Data: data,
|
||||
Metadata: data, // alias — same underlying map
|
||||
}
|
||||
if len(locale) > 0 {
|
||||
ctx.SetLocale(locale[0])
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// NewContextWithService creates a rendering context backed by a specific translator.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
|
||||
func NewContextWithService(svc Translator, locale ...string) *Context {
|
||||
ctx := NewContext(locale...)
|
||||
ctx.SetService(svc)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetService swaps the translator used by the context.
|
||||
// Usage example: ctx.SetService(myTranslator)
|
||||
func (ctx *Context) SetService(svc Translator) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.service = svc
|
||||
applyLocaleToService(svc, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetLocale updates the context locale and reapplies it to the active translator.
|
||||
// Usage example: ctx.SetLocale("en-GB")
|
||||
func (ctx *Context) SetLocale(locale string) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.Locale = locale
|
||||
applyLocaleToService(ctx.service, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cloneContext(ctx *Context) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := *ctx
|
||||
// Preserve the shared Data/Metadata alias when callers pointed both fields
|
||||
// at the same map.
|
||||
if sameMetadataMap(ctx.Data, ctx.Metadata) {
|
||||
shared := cloneMetadataMap(ctx.Data)
|
||||
clone.Data = shared
|
||||
clone.Metadata = shared
|
||||
return &clone
|
||||
}
|
||||
|
||||
clone.Data = cloneMetadataMap(ctx.Data)
|
||||
clone.Metadata = cloneMetadataMap(ctx.Metadata)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneMetadataMap(src map[string]any) map[string]any {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := make(map[string]any, len(src))
|
||||
for key, value := range src {
|
||||
dst[key] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func sameMetadataMap(a, b map[string]any) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
|
||||
return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer()
|
||||
}
|
||||
173
context_test.go
173
context_test.go
|
|
@ -1,173 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
type recordingTranslator struct {
|
||||
key string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (r *recordingTranslator) T(key string, args ...any) string {
|
||||
r.key = key
|
||||
r.args = append(r.args[:0], args...)
|
||||
return "translated"
|
||||
}
|
||||
|
||||
func TestNewContext_OptionalLocale_Good(t *testing.T) {
|
||||
ctx := NewContext("en-GB")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContext returned nil")
|
||||
}
|
||||
if ctx.Locale != "en-GB" {
|
||||
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
|
||||
}
|
||||
if ctx.Data == nil {
|
||||
t.Fatal("NewContext should initialise Data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContextWithService returned nil")
|
||||
}
|
||||
if ctx.Locale != "fr-FR" {
|
||||
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
|
||||
}
|
||||
if ctx.service == nil {
|
||||
t.Fatal("NewContextWithService should set translator service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_UsesMetadataAliasWhenDataNil_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
ctx := &Context{
|
||||
Metadata: map[string]any{"count": 1},
|
||||
}
|
||||
|
||||
got := Text("i18n.count.file").Render(ctx)
|
||||
if got != "1 file" {
|
||||
t.Fatalf("Text with metadata-only count = %q, want %q", got, "1 file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_CustomTranslatorReceivesCountArgs_Good(t *testing.T) {
|
||||
ctx := NewContextWithService(&recordingTranslator{})
|
||||
ctx.Metadata["count"] = 3
|
||||
|
||||
got := Text("i18n.count.file", "ignored").Render(ctx)
|
||||
if got != "translated" {
|
||||
t.Fatalf("Text with custom translator = %q, want %q", got, "translated")
|
||||
}
|
||||
|
||||
svc := ctx.service.(*recordingTranslator)
|
||||
if svc.key != "i18n.count.file" {
|
||||
t.Fatalf("custom translator key = %q, want %q", svc.key, "i18n.count.file")
|
||||
}
|
||||
|
||||
wantArgs := []any{3, "ignored"}
|
||||
if !reflect.DeepEqual(svc.args, wantArgs) {
|
||||
t.Fatalf("custom translator args = %#v, want %#v", svc.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_NonCountKey_DoesNotInjectCount_Good(t *testing.T) {
|
||||
ctx := NewContextWithService(&recordingTranslator{})
|
||||
ctx.Metadata["count"] = 3
|
||||
|
||||
got := Text("greeting.hello").Render(ctx)
|
||||
if got != "translated" {
|
||||
t.Fatalf("Text with non-count key = %q, want %q", got, "translated")
|
||||
}
|
||||
|
||||
svc := ctx.service.(*recordingTranslator)
|
||||
if len(svc.args) != 0 {
|
||||
t.Fatalf("non-count key should not receive count args, got %#v", svc.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContext("fr-FR")
|
||||
|
||||
if got := ctx.SetService(svc); got != ctx {
|
||||
t.Fatal("SetService should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetService(nil); got != nil {
|
||||
t.Fatal("SetService on nil context should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
|
||||
if got := ctx.SetLocale("fr-FR"); got != ctx {
|
||||
t.Fatal("SetLocale should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetLocale("en-GB"); got != nil {
|
||||
t.Fatal("SetLocale on nil context should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneContext_PreservesMetadataAlias_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Data["count"] = 3
|
||||
|
||||
clone := cloneContext(ctx)
|
||||
if clone == nil {
|
||||
t.Fatal("cloneContext returned nil")
|
||||
}
|
||||
if clone.Data == nil || clone.Metadata == nil {
|
||||
t.Fatal("cloneContext should preserve non-nil metadata maps")
|
||||
}
|
||||
|
||||
dataPtr := reflect.ValueOf(clone.Data).Pointer()
|
||||
metadataPtr := reflect.ValueOf(clone.Metadata).Pointer()
|
||||
if dataPtr != metadataPtr {
|
||||
t.Fatalf("cloneContext should keep Data and Metadata aliased, got %x and %x", dataPtr, metadataPtr)
|
||||
}
|
||||
if clone.Data["count"] != 3 || clone.Metadata["count"] != 3 {
|
||||
t.Fatalf("cloneContext should copy map contents, got Data=%v Metadata=%v", clone.Data, clone.Metadata)
|
||||
}
|
||||
}
|
||||
12
doc.go
12
doc.go
|
|
@ -1,12 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package html renders semantic HTML from composable node trees.
|
||||
//
|
||||
// A typical page combines Layout, El, Text, and Render:
|
||||
//
|
||||
// page := NewLayout("HCF").
|
||||
// H(El("h1", Text("page.title"))).
|
||||
// C(El("main", Text("page.body"))).
|
||||
// F(El("small", Text("page.footer")))
|
||||
// out := Render(page, NewContext())
|
||||
package html
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
---
|
||||
title: Architecture
|
||||
description: Internals of the go-html HLCRF DOM compositor, covering the node interface, layout system, responsive wrapper, grammar pipeline, WASM module, and codegen CLI.
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
`go-html` is structured around a single interface, a layout compositor, and a server-side analysis pipeline. Everything renders to `string` -- there is no virtual DOM, no diffing, and no retained state between renders.
|
||||
|
||||
## Node Interface
|
||||
|
||||
Every renderable unit implements one method:
|
||||
|
||||
```go
|
||||
type Node interface {
|
||||
Render(ctx *Context) string
|
||||
}
|
||||
```
|
||||
|
||||
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
|
||||
|
||||
| Constructor | Behaviour |
|
||||
|-------------|-----------|
|
||||
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
|
||||
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Each`, `EachSeq`, `Switch`, `Layout`, and `Responsive` wrappers. Returns the node for chaining. |
|
||||
| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. |
|
||||
| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. |
|
||||
| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. |
|
||||
| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. |
|
||||
| `Role(Node, role)` | Convenience helper that sets `role` on an element node. |
|
||||
| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. |
|
||||
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
|
||||
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
|
||||
| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
|
||||
| `Each[T](items, fn)` | Iterates a slice and renders each item via a mapping function. Generic over `T`. |
|
||||
| `EachSeq[T](items, fn)` | Same as `Each` but accepts an `iter.Seq[T]` instead of a slice. |
|
||||
| `Switch(selector, cases)` | Renders one of several named cases based on a runtime selector function. Returns empty string when no case matches. |
|
||||
| `Entitled(feature, Node)` | Renders the child only when the context's entitlement function grants the named feature. Deny-by-default: returns empty string when no entitlement function is set. |
|
||||
|
||||
### Safety Guarantees
|
||||
|
||||
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
|
||||
- **Attribute escaping**: Attribute values are escaped with `html.EscapeString()`, handling `&`, `<`, `>`, `"`, and `'`.
|
||||
- **Deterministic output**: Attribute keys on `El` nodes are sorted alphabetically before rendering, producing identical output regardless of insertion order.
|
||||
- **Void elements**: A lookup table of 13 void elements (`area`, `base`, `br`, `col`, `embed`, `hr`, `img`, `input`, `link`, `meta`, `source`, `track`, `wbr`) ensures these never emit a closing tag.
|
||||
- **Deny-by-default entitlements**: `Entitled` returns an empty string when the context is nil, when no entitlement function is set, or when the function returns false. Content is absent from the DOM, not merely hidden.
|
||||
|
||||
## Rendering Context
|
||||
|
||||
The `Context` struct carries per-request state through the node tree during rendering:
|
||||
|
||||
```go
|
||||
type Context struct {
|
||||
Identity string // e.g. user ID or session identifier
|
||||
Locale string // BCP 47 locale string
|
||||
Entitlements func(feature string) bool // feature gate callback
|
||||
Data map[string]any // arbitrary per-request data
|
||||
Metadata map[string]any // alias of Data for alternate naming
|
||||
service Translator // unexported; set via constructor
|
||||
}
|
||||
```
|
||||
|
||||
Two constructors are provided:
|
||||
|
||||
- `NewContext()` creates a context with sensible defaults and an empty `Data` map.
|
||||
- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
|
||||
|
||||
`Data` and `Metadata` point at the same backing map when the context is created through `NewContext()`. Use whichever name is clearer in the calling code. `SetLocale()` and `SetService()` keep the active translator in sync when either value changes.
|
||||
|
||||
The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean.
|
||||
|
||||
## HLCRF Layout
|
||||
|
||||
The `Layout` type is a compositor for five named slots:
|
||||
|
||||
| Slot Letter | Semantic Element | ARIA Role | Accessor |
|
||||
|-------------|-----------------|-----------|----------|
|
||||
| H | `<header>` | `banner` | `layout.H(...)` |
|
||||
| L | `<nav>` | `navigation` | `layout.L(...)` |
|
||||
| C | `<main>` | `main` | `layout.C(...)` |
|
||||
| R | `<aside>` | `complementary` | `layout.R(...)` |
|
||||
| F | `<footer>` | `contentinfo` | `layout.F(...)` |
|
||||
|
||||
### Variant String
|
||||
|
||||
The variant string passed to `NewLayout()` determines which slots render and in which order:
|
||||
|
||||
```go
|
||||
NewLayout("HLCRF") // all five slots
|
||||
NewLayout("HCF") // header, content, footer (no sidebars)
|
||||
NewLayout("C") // content only
|
||||
NewLayout("LC") // left sidebar and content
|
||||
```
|
||||
|
||||
Slot letters not present in the variant string are ignored, even if nodes have been appended to those slots. Unrecognised characters (lowercase, digits, special characters) are silently skipped -- no error is returned.
|
||||
|
||||
### Deterministic Block IDs
|
||||
|
||||
Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs use the slot letter itself:
|
||||
|
||||
```html
|
||||
<header role="banner" data-block="H">...</header>
|
||||
<main role="main" data-block="C">...</main>
|
||||
<footer role="contentinfo" data-block="F">...</footer>
|
||||
```
|
||||
|
||||
Block IDs are constructed by simple string concatenation (no `fmt.Sprintf`) to keep the `fmt` package out of the WASM import graph.
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts retain the parent's block ID as a prefix. This produces hierarchical paths:
|
||||
|
||||
```go
|
||||
inner := html.NewLayout("HCF").
|
||||
H(html.Raw("nav")).
|
||||
C(html.Raw("body")).
|
||||
F(html.Raw("links"))
|
||||
|
||||
outer := html.NewLayout("HLCRF").
|
||||
H(html.Raw("top")).
|
||||
L(inner). // inner layout nested in the Left slot
|
||||
C(html.Raw("main")).
|
||||
F(html.Raw("foot"))
|
||||
```
|
||||
|
||||
The inner layout's slots render with prefixed block IDs: `L.0`, `L.0.1`, `L.0.2`. At 10 levels of nesting, the deepest block ID becomes `C.0.0.0.0.0.0.0.0.0` (tested in `edge_test.go`).
|
||||
|
||||
The clone-on-render approach means the original layout is never mutated. This is safe for concurrent use.
|
||||
|
||||
### Fluent Builder
|
||||
|
||||
All slot methods return `*Layout` for chaining. Multiple nodes can be appended to the same slot across multiple calls:
|
||||
|
||||
```go
|
||||
html.NewLayout("HCF").
|
||||
H(html.El("h1", html.Text("page.title"))).
|
||||
C(html.El("p", html.Text("intro"))).
|
||||
C(html.El("p", html.Text("body"))). // appends to the same C slot
|
||||
F(html.El("small", html.Text("footer")))
|
||||
```
|
||||
|
||||
### Block ID Parsing
|
||||
|
||||
`ParseBlockID()` in `path.go` extracts the slot letter sequence from a `data-block` attribute value:
|
||||
|
||||
```go
|
||||
ParseBlockID("L.0.C.0") // returns ['L', 'C']
|
||||
ParseBlockID("L-0-C-0") // legacy hyphenated form, also returns ['L', 'C']
|
||||
ParseBlockID("C.0.C.0.C.0") // returns ['C', 'C', 'C']
|
||||
ParseBlockID("H") // returns ['H']
|
||||
ParseBlockID("") // returns nil
|
||||
```
|
||||
|
||||
This enables server-side or client-side code to locate a specific block in the rendered tree by its structural path.
|
||||
|
||||
## Responsive Compositor
|
||||
|
||||
`Responsive` wraps multiple named `Layout` variants for breakpoint-aware rendering:
|
||||
|
||||
```go
|
||||
html.NewResponsive().
|
||||
Variant("desktop", html.NewLayout("HLCRF").
|
||||
H(html.Raw("header")).L(html.Raw("nav")).C(html.Raw("main")).
|
||||
R(html.Raw("aside")).F(html.Raw("footer"))).
|
||||
Variant("tablet", html.NewLayout("HCF").
|
||||
H(html.Raw("header")).C(html.Raw("main")).F(html.Raw("footer"))).
|
||||
Variant("mobile", html.NewLayout("C").
|
||||
C(html.Raw("main")))
|
||||
```
|
||||
|
||||
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. When supplied, `Responsive.Add(name, layout, media)` also emits `data-media="..."` on the wrapper so downstream CSS can reflect the breakpoint hint. CSS media queries or JavaScript can target these containers for show/hide logic.
|
||||
|
||||
`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly.
|
||||
|
||||
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values.
|
||||
|
||||
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant.
|
||||
|
||||
## Grammar Pipeline (Server-Side Only)
|
||||
|
||||
The grammar pipeline is excluded from WASM builds via `//go:build !js` on `pipeline.go`. It bridges the rendering layer to the semantic analysis layer.
|
||||
|
||||
### StripTags
|
||||
|
||||
```go
|
||||
func StripTags(html string) string
|
||||
```
|
||||
|
||||
Converts rendered HTML to plain text. Tag boundaries are collapsed into single spaces; the result is trimmed. The implementation is a single-pass rune scanner with no regular expressions and no allocations beyond the output `strings.Builder`. It does not handle `<script>` or `<style>` content because `go-html` never generates those elements.
|
||||
|
||||
### Imprint
|
||||
|
||||
```go
|
||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint
|
||||
```
|
||||
|
||||
Runs the full render-to-analysis pipeline:
|
||||
|
||||
1. Renders the node tree to HTML via `node.Render(ctx)`.
|
||||
2. Strips HTML tags via `StripTags()` to extract plain text.
|
||||
3. Tokenises the text via `go-i18n/reversal.NewTokeniser().Tokenise()`.
|
||||
4. Wraps tokens in a `reversal.GrammarImprint` for structural analysis.
|
||||
|
||||
The resulting `GrammarImprint` exposes `TokenCount`, `UniqueVerbs`, and a `Similar()` method for pairwise semantic similarity scoring.
|
||||
|
||||
A nil context is handled gracefully: `Imprint` creates a default context internally.
|
||||
|
||||
### CompareVariants
|
||||
|
||||
```go
|
||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64
|
||||
```
|
||||
|
||||
Runs `Imprint` independently on each named layout variant in a `Responsive` and returns pairwise similarity scores. Keys are formatted as `"name1:name2"`.
|
||||
|
||||
This enables detection of semantically divergent responsive variants -- for example, a mobile layout that strips critical information present in the desktop variant. Same-content variants with different layout structures (e.g. `HLCRF` vs `HCF`) score above 0.8 similarity.
|
||||
|
||||
A single-variant `Responsive` produces an empty score map (no pairs to compare).
|
||||
|
||||
## WASM Module
|
||||
|
||||
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function:
|
||||
|
||||
```js
|
||||
gohtml.renderToString(variant, locale, slots)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `variant` (string): HLCRF variant string, e.g. `"HCF"`.
|
||||
- `locale` (string): BCP 47 locale string for i18n, e.g. `"en-GB"`.
|
||||
- `slots` (object): Optional keys `H`, `L`, `C`, `R`, `F` containing HTML strings.
|
||||
|
||||
Slot content is injected via `Raw()`. The caller is responsible for sanitisation -- the WASM module is a rendering engine for trusted content produced server-side or by the application's own templates.
|
||||
|
||||
### Size Budget
|
||||
|
||||
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
|
||||
|
||||
| Metric | Limit | Current |
|
||||
|--------|-------|---------|
|
||||
| Raw binary | 3.5 MB | ~2.90 MB |
|
||||
| Gzip compressed | 1 MB | ~842 KB |
|
||||
|
||||
The test builds the WASM binary as a subprocess and is skipped under `go test -short`. The Makefile `wasm` target performs the same build with size checking.
|
||||
|
||||
### Server/Client Split
|
||||
|
||||
The binary split is enforced by Go build tags:
|
||||
|
||||
| File | Build Tag | Reason for WASM Exclusion |
|
||||
|------|-----------|--------------------------|
|
||||
| `pipeline.go` | `!js` | Imports `go-i18n/reversal` |
|
||||
| `cmd/wasm/register.go` | `!js` | Imports `encoding/json` and `text/template` |
|
||||
|
||||
The WASM binary includes only: node types, layout, responsive, context, render, path, and `go-i18n` core translation. No codegen, no pipeline, no JSON, no templates, no `fmt`.
|
||||
|
||||
## Codegen CLI
|
||||
|
||||
`cmd/codegen/main.go` generates Web Component JavaScript bundles from HLCRF slot assignments at build time:
|
||||
|
||||
```bash
|
||||
echo '{"H":"nav-bar","C":"main-content","F":"page-footer"}' | go run ./cmd/codegen/ > components.js
|
||||
```
|
||||
|
||||
The `codegen` package (`codegen/codegen.go`) generates ES2022 class definitions with closed Shadow DOM. For each custom element tag, it produces:
|
||||
|
||||
1. A class extending `HTMLElement` with a private `#shadow` field.
|
||||
2. `constructor()` attaching a closed shadow root (`mode: "closed"`).
|
||||
3. `connectedCallback()` dispatching a `wc-ready` custom event with the tag name and slot.
|
||||
4. `render(html)` method that sets shadow content from a `<template>` clone.
|
||||
5. A `customElements.define()` registration call.
|
||||
|
||||
Tag names must contain a hyphen (Web Components specification requirement). `TagToClassName()` converts kebab-case to PascalCase: `nav-bar` becomes `NavBar`, `my-super-widget` becomes `MySuperWidget`.
|
||||
|
||||
`GenerateBundle()` deduplicates tags -- if the same tag is assigned to multiple slots, only one class definition is emitted.
|
||||
|
||||
The codegen CLI uses `encoding/json` and `text/template`, which are excluded from the WASM build. Consumers generate the JS bundle at build time and serve it as a static asset.
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
Server-Side
|
||||
+-------------------+
|
||||
| |
|
||||
Node tree -------> Render(ctx) |-----> HTML string
|
||||
| |
|
||||
| StripTags() |-----> plain text
|
||||
| |
|
||||
| Imprint() |-----> GrammarImprint
|
||||
| | .TokenCount
|
||||
| CompareVariants()| .UniqueVerbs
|
||||
| | .Similar()
|
||||
+-------------------+
|
||||
|
||||
WASM Client
|
||||
+-------------------+
|
||||
| |
|
||||
JS call ---------> renderToString() |-----> HTML string
|
||||
(variant, locale, | |
|
||||
slots object) +-------------------+
|
||||
|
||||
Build Time
|
||||
+-------------------+
|
||||
| |
|
||||
JSON slot map ---> cmd/codegen/ |-----> Web Component JS
|
||||
(stdin) | | (stdout)
|
||||
+-------------------+
|
||||
```
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
---
|
||||
title: Development Guide
|
||||
description: How to build, test, and contribute to go-html, including WASM builds, benchmarks, coding standards, and test patterns.
|
||||
---
|
||||
|
||||
# Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.26** or later. The module uses Go 1.26 features (e.g. `range` over integers, `iter.Seq`).
|
||||
- **go-i18n** cloned alongside this repository at `../go-i18n` relative to the repo root. The `go.mod` `replace` directive points there.
|
||||
- **go-inference** also resolved via `replace` directive at `../go-inference`. It is an indirect dependency pulled in by `go-i18n`.
|
||||
- **Go workspace** (`go.work`): this module is part of a shared workspace. Run `go work sync` after cloning.
|
||||
|
||||
No additional tools are required for server-side development. WASM builds require the standard Go cross-compilation support (`GOOS=js GOARCH=wasm`), included in all official Go distributions.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
go-html/
|
||||
node.go Node interface and all node types
|
||||
layout.go HLCRF compositor
|
||||
pipeline.go StripTags, Imprint, CompareVariants (!js only)
|
||||
responsive.go Multi-variant breakpoint wrapper
|
||||
context.go Rendering context
|
||||
render.go Render() convenience function
|
||||
path.go ParseBlockID() for data-block path decoding
|
||||
codegen/
|
||||
codegen.go Web Component JS generation (server-side)
|
||||
codegen_test.go Tests for codegen
|
||||
bench_test.go Codegen benchmarks
|
||||
cmd/
|
||||
codegen/
|
||||
main.go Build-time CLI (stdin JSON, stdout JS)
|
||||
main_test.go CLI integration tests
|
||||
wasm/
|
||||
main.go WASM entry point (js+wasm build only)
|
||||
register.go buildComponentJS helper (!js only)
|
||||
register_test.go Tests for register helper
|
||||
size_test.go WASM binary size gate test (!js only)
|
||||
dist/ WASM build output (gitignored)
|
||||
docs/ This documentation
|
||||
plans/ Phase design documents (historical)
|
||||
Makefile WASM build with size checking
|
||||
.core/build.yaml Build system configuration
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
go test ./...
|
||||
|
||||
# Single test by name
|
||||
go test -run TestElNode_Render .
|
||||
|
||||
# Skip the slow WASM build test
|
||||
go test -short ./...
|
||||
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Tests for a specific package
|
||||
go test ./codegen/
|
||||
go test ./cmd/codegen/
|
||||
go test ./cmd/wasm/
|
||||
```
|
||||
|
||||
The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
|
||||
|
||||
### Test Dependencies
|
||||
|
||||
Tests use the `testify` library (`assert` and `require` packages). Integration tests and benchmarks that exercise `Text` nodes must initialise the `go-i18n` default service before rendering:
|
||||
|
||||
```go
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
```
|
||||
|
||||
The `bench_test.go` file does this in an `init()` function. Individual integration tests do so explicitly.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
```bash
|
||||
# All benchmarks
|
||||
go test -bench . ./...
|
||||
|
||||
# Specific benchmark
|
||||
go test -bench BenchmarkRender_FullPage .
|
||||
|
||||
# With memory allocation statistics
|
||||
go test -bench . -benchmem ./...
|
||||
|
||||
# Extended benchmark duration
|
||||
go test -bench . -benchtime=5s ./...
|
||||
```
|
||||
|
||||
Available benchmark groups:
|
||||
|
||||
| Group | Variants |
|
||||
|-------|----------|
|
||||
| `BenchmarkRender_*` | Depth 1, 3, 5, 7 element trees; full page with layout |
|
||||
| `BenchmarkLayout_*` | Content-only, HCF, HLCRF, nested, 50-child slot |
|
||||
| `BenchmarkEach_*` | 10, 100, 1000 items |
|
||||
| `BenchmarkResponsive_*` | Three-variant compositor |
|
||||
| `BenchmarkStripTags_*` | Short and long HTML inputs |
|
||||
| `BenchmarkImprint_*` | Small and large page trees |
|
||||
| `BenchmarkCompareVariants_*` | Two and three variant comparison |
|
||||
| `BenchmarkGenerateClass` | Single Web Component class generation |
|
||||
| `BenchmarkGenerateBundle_*` | Small (2-slot) and full (5-slot) bundles |
|
||||
| `BenchmarkTagToClassName` | Kebab-to-PascalCase conversion |
|
||||
| `BenchmarkGenerateRegistration` | `customElements.define()` call generation |
|
||||
|
||||
## WASM Build
|
||||
|
||||
```bash
|
||||
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
|
||||
```
|
||||
|
||||
Strip flags (`-s -w`) are required. Without them, the binary is approximately 50% larger.
|
||||
|
||||
The Makefile `wasm` target performs the build and checks the output size:
|
||||
|
||||
```bash
|
||||
make wasm
|
||||
```
|
||||
|
||||
The Makefile enforces a 1 MB gzip transfer limit and a 3 MB raw size limit. Current measured output: approximately 2.90 MB raw, 842 KB gzip.
|
||||
|
||||
To verify the gzip size manually:
|
||||
|
||||
```bash
|
||||
gzip -c -9 gohtml.wasm | wc -c
|
||||
```
|
||||
|
||||
## Codegen CLI
|
||||
|
||||
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout:
|
||||
|
||||
```bash
|
||||
echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
|
||||
| go run ./cmd/codegen/ \
|
||||
> components.js
|
||||
```
|
||||
|
||||
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
|
||||
|
||||
Pass `-types` to emit ambient TypeScript declarations instead of JavaScript:
|
||||
|
||||
```bash
|
||||
echo '{"H":"site-header","C":"app-content"}' \
|
||||
| go run ./cmd/codegen/ -types \
|
||||
> components.d.ts
|
||||
```
|
||||
|
||||
For local development, `-watch` polls an input JSON file and rewrites the
|
||||
output file whenever the slot map changes:
|
||||
|
||||
```bash
|
||||
go run ./cmd/codegen/ \
|
||||
-watch \
|
||||
-input slots.json \
|
||||
-output components.js
|
||||
```
|
||||
|
||||
To test the CLI:
|
||||
|
||||
```bash
|
||||
go test ./cmd/codegen/
|
||||
```
|
||||
|
||||
## Static Analysis
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
The repository also includes a `.golangci.yml` configuration for `golangci-lint`.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Language
|
||||
|
||||
UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used.
|
||||
|
||||
### Type Annotations
|
||||
|
||||
All exported and unexported functions carry full parameter and return type annotations. The `any` alias is used in preference to `interface{}`.
|
||||
|
||||
### HTML Safety
|
||||
|
||||
- Use `Text()` for any user-supplied or translated content. It escapes HTML automatically.
|
||||
- Use `Raw()` only for content you control or have sanitised upstream. Its name explicitly signals "no escaping".
|
||||
- Never construct HTML by string concatenation in application code.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Errors are wrapped with context using `fmt.Errorf()`. The codegen package prefixes all errors with `codegen:`.
|
||||
|
||||
### Determinism
|
||||
|
||||
Output must be deterministic. `El` node attributes are sorted alphabetically before rendering. `map` iteration order in `codegen.GenerateBundle()` may vary across runs -- this is acceptable because Web Component registration order does not affect correctness.
|
||||
|
||||
### Build Tags
|
||||
|
||||
Files excluded from WASM use `//go:build !js` as the first line, before the `package` declaration. Files compiled only under WASM use `//go:build js && wasm`. The older `// +build` syntax is not used.
|
||||
|
||||
The `fmt` package must never be imported in files without a `!js` build tag, as it significantly inflates the WASM binary. Use string concatenation instead of `fmt.Sprintf` in layout and node code.
|
||||
|
||||
### Licence
|
||||
|
||||
All new files should carry the EUPL-1.2 SPDX identifier:
|
||||
|
||||
```go
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
```
|
||||
|
||||
### Commit Format
|
||||
|
||||
Conventional commits with lowercase type and optional scope:
|
||||
|
||||
```
|
||||
feat(codegen): add TypeScript type definition generation
|
||||
fix(wasm): correct slot injection for empty strings
|
||||
test: add edge case for Unicode surrogate pairs
|
||||
docs: update architecture with pipeline diagram
|
||||
```
|
||||
|
||||
Include a co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Standard Unit Test
|
||||
|
||||
```go
|
||||
func TestElNode_Render(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
want := "<div>content</div>"
|
||||
if got != want {
|
||||
t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Table-Driven Subtest
|
||||
|
||||
```go
|
||||
func TestStripTags_Unicode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"},
|
||||
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"},
|
||||
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripTags(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test with i18n
|
||||
|
||||
```go
|
||||
func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))
|
||||
|
||||
imp := Imprint(page, ctx)
|
||||
|
||||
if imp.UniqueVerbs == 0 {
|
||||
t.Error("reversal found no verbs in rendered page")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codegen Tests with Testify
|
||||
|
||||
```go
|
||||
func TestGenerateClass_ValidTag(t *testing.T) {
|
||||
js, err := GenerateClass("photo-grid", "C")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||
assert.Contains(t, js, "attachShadow")
|
||||
assert.Contains(t, js, `mode: "closed"`)
|
||||
}
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
|
||||
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first.
|
||||
- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services.
|
||||
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
|
||||
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.
|
||||
120
docs/history.md
120
docs/history.md
|
|
@ -1,120 +0,0 @@
|
|||
# Project History
|
||||
|
||||
## Phase 1: Core Node Types (initial scaffolding)
|
||||
|
||||
Commits: `d7bb0b2` through `c724094`
|
||||
|
||||
The module was scaffolded with the Go module path `forge.lthn.ai/core/go-html`. The foundational work established:
|
||||
|
||||
- `d7bb0b2` — Module scaffold, `Node` interface with `Render(ctx *Context) string`.
|
||||
- `3e76e72` — `Text` node wired to `go-i18n` grammar pipeline with HTML escaping.
|
||||
- `c724094` — Conditional nodes (`If`, `Unless`), entitlement gating (`Entitled`, deny-by-default), runtime dispatch (`Switch`), and type-safe iteration (`Each[T]`).
|
||||
|
||||
The `Raw` escape hatch was present from the first commit. The decision to make `Text` always escape and `Raw` never escape was made at this stage and has not changed.
|
||||
|
||||
## Phase 2: HLCRF Layout and Pipeline
|
||||
|
||||
Commits: `946ea8d` through `ef77793`
|
||||
|
||||
- `946ea8d` — `Layout` type with HLCRF slot registry. Semantic HTML elements (`<header>`, `<main>`, `<aside>`, `<footer>`) and ARIA roles assigned per slot.
|
||||
- `d75988a` — Nested layout path chains. Block IDs computed as `{slot}-0` at root, extended with `{parent}-{slot}-0` for nested layouts.
|
||||
- `40da0d8` — Deterministic attribute sorting and thread-safe nested layout cloning (clone-on-render pattern).
|
||||
- `f49ddbf` — `Attr()` helper for setting element attributes with chaining.
|
||||
- `e041f76` — `Responsive` multi-variant compositor with `data-variant` containers.
|
||||
- `8ac5123` — `StripTags` single-pass rune scanner for HTML-to-text stripping.
|
||||
- `76cef5a` — `Imprint()` full render-reverse-imprint pipeline using `go-i18n/reversal`.
|
||||
- `ef77793` — `CompareVariants()` pairwise semantic similarity scoring across responsive variants.
|
||||
|
||||
## Phase 3: WASM Entry Point
|
||||
|
||||
Commits: `456adce` through `9bc1fa7`
|
||||
|
||||
- `456adce` — Makefile with `wasm` target. Size gate: `WASM_GZ_LIMIT = 1048576` (1 MB). Initial measurement revealed the binary was already too large at this stage.
|
||||
- `5acf63c` — WASM entry point `cmd/wasm/main.go` with `renderToString` exported to `window.gohtml`.
|
||||
- `2fab89e` — Integration tests refactored to use `Imprint` pipeline.
|
||||
- `e34c5c9` — WASM browser test harness added.
|
||||
- `18d2933` — WASM binary size reporting improvements.
|
||||
- `9bc1fa7` — Variant name escaping in `Responsive`, single-pass `StripTags` optimisation, WASM security contract documented in source.
|
||||
|
||||
## Phase 4: Codegen and Web Components
|
||||
|
||||
Commits: `937c08d` through `ab7ab92`
|
||||
|
||||
- `37b50ab`, `496513e` — Phase 4 design documents and implementation plan.
|
||||
- `937c08d` — `codegen` package with `GenerateClass`, `GenerateBundle`, `TagToClassName`. Web Component classes with closed Shadow DOM.
|
||||
- `dcd55a4` — `registerComponents` export added to `cmd/wasm/main.go`, bridging JSON slot config to WC bundle JS. This was the source of the subsequent binary size problem.
|
||||
- `ab7ab92` — Transitive `replace` directive added for `go-inference`.
|
||||
|
||||
## WASM Binary Size Reduction
|
||||
|
||||
Commits: `6abda8b`, `4c65737`, `aae5d21`
|
||||
|
||||
The initial WASM binary measured 6.04 MB raw / 1.58 MB gzip — 58% over the 1 MB gzip limit set in the Makefile. The root causes were three heavyweight stdlib imports pulled in by `registerComponents()` in the WASM binary:
|
||||
|
||||
| Import | Approx. gzip contribution |
|
||||
|--------|--------------------------|
|
||||
| `encoding/json` | ~200 KB |
|
||||
| `text/template` | ~125 KB |
|
||||
| `fmt` (via `layout.go`) | ~50 KB |
|
||||
| `go-i18n/reversal` (via `pipeline.go`) | ~250 KB |
|
||||
|
||||
**Total bloat**: ~625 KB gzip over the core rendering requirement.
|
||||
|
||||
The fix was applied in three distinct steps:
|
||||
|
||||
### Step 1: Remove registerComponents from WASM (`4c65737`)
|
||||
|
||||
`cmd/wasm/register.go` received a `//go:build !js` build tag, completely excluding it from the WASM compilation unit. The `registerComponents` entry on the `gohtml` JS object was removed from `cmd/wasm/main.go`. The codegen function was moved to a standalone build-time CLI at `cmd/codegen/main.go`. This eliminated `encoding/json` and `text/template` from the WASM import graph.
|
||||
|
||||
### Step 2: Remove pipeline from WASM
|
||||
|
||||
`pipeline.go` received a `//go:build !js` build tag. The `Imprint()` and `CompareVariants()` functions depend on `go-i18n/reversal`, which is a heavyweight analysis library. These functions are server-side analysis tools and have no use in a client-side rendering module. The `renderToString` function in the WASM entry point never called them, so removal was non-breaking.
|
||||
|
||||
### Step 3: Eliminate fmt from WASM
|
||||
|
||||
`layout.go`'s `blockID()` method had used `fmt.Sprintf` for string construction. Replacing this with direct string concatenation (`l.path + string(slot) + "-0"`) removed `fmt` from the WASM import graph entirely.
|
||||
|
||||
**Result**: 2.90 MB raw, 842 KB gzip. 47% reduction in gzip size. Well within the 1 MB limit.
|
||||
|
||||
### Size gate test (`aae5d21`)
|
||||
|
||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||
|
||||
- Gzip size < 1,048,576 bytes (1 MB).
|
||||
- Raw size < 3,145,728 bytes (3 MB).
|
||||
|
||||
The test is skipped under `go test -short` and is guarded with `//go:build !js`.
|
||||
|
||||
## Test Coverage Milestones
|
||||
|
||||
- `7efd2ab` — Benchmarks added across all subsystems. Unicode edge case tests. Stress tests.
|
||||
- `ab7ab92` — 53 passing tests across the package and sub-packages.
|
||||
- `aae5d21` — 70+ tests passing (server-side); WASM size gate and codegen CLI tests added.
|
||||
|
||||
## Known Limitations (as of current HEAD)
|
||||
|
||||
These are not regressions; they are design choices or deferred work recorded for future consideration.
|
||||
|
||||
1. **Invalid layout variants are silently ignored.** `NewLayout("XYZ")` produces empty output, and the compatibility helpers `ValidateLayoutVariant()` / `VariantError()` now return `nil`.
|
||||
|
||||
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
|
||||
|
||||
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
||||
|
||||
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
|
||||
|
||||
5. **TypeScript definitions are generated.** `codegen.GenerateTypeScriptDefinitions()` and the `cmd/codegen -types` flag emit `.d.ts` companions for generated Web Components.
|
||||
|
||||
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
|
||||
|
||||
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
||||
|
||||
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
|
||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||
- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
|
||||
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
||||
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
---
|
||||
title: go-html
|
||||
description: HLCRF DOM compositor with grammar pipeline integration for type-safe server-side HTML generation and optional WASM client rendering.
|
||||
---
|
||||
|
||||
# go-html
|
||||
|
||||
`go-html` is a pure-Go library for building HTML documents as type-safe node trees and rendering them to string output. It provides a five-slot layout compositor (Header, Left, Content, Right, Footer -- abbreviated HLCRF), a responsive multi-variant wrapper, a server-side grammar analysis pipeline, a Web Component code generator, and an optional WASM module for client-side rendering.
|
||||
|
||||
**Module path:** `dappco.re/go/core/html`
|
||||
**Go version:** 1.26
|
||||
**Licence:** EUPL-1.2
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import html "dappco.re/go/core/html"
|
||||
|
||||
func main() {
|
||||
page := html.NewLayout("HCF").
|
||||
H(html.El("nav", html.Text("nav.label"))).
|
||||
C(html.El("article",
|
||||
html.El("h1", html.Text("page.title")),
|
||||
html.Each(items, func(item Item) html.Node {
|
||||
return html.El("li", html.Text(item.Name))
|
||||
}),
|
||||
)).
|
||||
F(html.El("footer", html.Text("footer.copyright")))
|
||||
|
||||
output := page.Render(html.NewContext())
|
||||
}
|
||||
```
|
||||
|
||||
This builds a Header-Content-Footer layout with semantic HTML elements (`<header>`, `<main>`, `<footer>`), ARIA roles, and deterministic `data-block` path identifiers. Text nodes pass through the `go-i18n` translation layer and are HTML-escaped by default. The rendering context exposes both `Data` and `Metadata` as the same backing map, and locale/service setters keep translation wiring explicit.
|
||||
|
||||
## Package Layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, and `Role` helpers |
|
||||
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
||||
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
|
||||
| `context.go` | Rendering context: identity, locale, entitlements, data/metadata alias, i18n service |
|
||||
| `render.go` | `Render()` convenience function |
|
||||
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
|
||||
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
|
||||
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
|
||||
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
|
||||
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), and accessibility helpers (`AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, `Role`).
|
||||
|
||||
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<nav>` (L), `<main>` (C), `<aside>` (R), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L.0`, `L.0.1`, and `L.0.2`.
|
||||
|
||||
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting, and `Responsive.Add(name, layout, media)` can also annotate the container with `data-media`. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
|
||||
|
||||
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.
|
||||
|
||||
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
dappco.re/go/core/html
|
||||
dappco.re/go/core (direct, server builds only, !js)
|
||||
dappco.re/go/core/i18n (direct, all builds)
|
||||
forge.lthn.ai/core/go-inference (indirect, via core/i18n)
|
||||
dappco.re/go/core/i18n/reversal (server builds only, !js)
|
||||
dappco.re/go/core/io (direct, server builds only, !js)
|
||||
dappco.re/go/core/log (direct, server builds only, !js)
|
||||
github.com/stretchr/testify (test only)
|
||||
```
|
||||
|
||||
WASM-linked files (layout.go, node.go, path.go, responsive.go, render.go, context.go, text_builder_js.go, text_translate_js.go) deliberately avoid `dappco.re/go/core` to respect the RFC §7 WASM size budget — core transitively pulls in fmt/os/log.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture](architecture.md) -- Node interface, HLCRF layout internals, responsive compositor, grammar pipeline, WASM module, codegen CLI
|
||||
- [Development](development.md) -- Building, testing, benchmarks, WASM builds, coding standards, contribution guide
|
||||
620
edge_test.go
620
edge_test.go
|
|
@ -1,620 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
// --- Unicode / RTL edge cases ---
|
||||
|
||||
func TestText_Emoji_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"simple emoji", "\U0001F680"},
|
||||
{"emoji sequence", "\U0001F468\u200D\U0001F4BB"},
|
||||
{"mixed text and emoji", "Hello \U0001F30D World"},
|
||||
{"flag emoji", "\U0001F1EC\U0001F1E7"},
|
||||
{"emoji in sentence", "Status: \u2705 Complete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with emoji should not produce empty output")
|
||||
}
|
||||
// Emoji should pass through (they are not HTML special chars)
|
||||
if !containsText(got, tt.input) {
|
||||
// Some chars may get escaped, but emoji bytes should survive
|
||||
t.Logf("note: emoji text rendered as %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEl_Emoji_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("span", Raw("\U0001F680 Launch"))
|
||||
got := node.Render(ctx)
|
||||
want := "<span>\U0001F680 Launch</span>"
|
||||
if got != want {
|
||||
t.Errorf("El with emoji = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_RTL_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"Arabic", "\u0645\u0631\u062D\u0628\u0627"},
|
||||
{"Hebrew", "\u05E9\u05DC\u05D5\u05DD"},
|
||||
{"mixed LTR and RTL", "Hello \u0645\u0631\u062D\u0628\u0627 World"},
|
||||
{"Arabic with numbers", "\u0627\u0644\u0639\u062F\u062F 42"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with RTL content should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEl_RTL_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
||||
got := node.Render(ctx)
|
||||
if !containsText(got, `dir="rtl"`) {
|
||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||
}
|
||||
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_ZeroWidth_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"zero-width space", "hello\u200Bworld"},
|
||||
{"zero-width joiner", "hello\u200Dworld"},
|
||||
{"zero-width non-joiner", "hello\u200Cworld"},
|
||||
{"soft hyphen", "super\u00ADcalifragilistic"},
|
||||
{"BOM character", "\uFEFFhello"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with zero-width characters should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_MixedScripts_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"Latin + CJK", "Hello \u4F60\u597D"},
|
||||
{"Latin + Cyrillic", "Hello \u041F\u0440\u0438\u0432\u0435\u0442"},
|
||||
{"CJK + Arabic", "\u4F60\u597D \u0645\u0631\u062D\u0628\u0627"},
|
||||
{"Latin + Devanagari", "Hello \u0928\u092E\u0938\u094D\u0924\u0947"},
|
||||
{"Latin + Thai", "Hello \u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35"},
|
||||
{"all scripts mixed", "EN \u4F60\u597D \u0645\u0631\u062D\u0628\u0627 \u041F\u0440\u0438\u0432\u0435\u0442 \U0001F30D"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with mixed scripts should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Unicode_Ugly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"},
|
||||
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"},
|
||||
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"},
|
||||
{"mixed unicode regions", "<header>\U0001F680</header><main>\u4F60\u597D</main>", "\U0001F680 \u4F60\u597D"},
|
||||
{"zero-width in tags", "<span>a\u200Bb</span>", "a\u200Bb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripTags(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
||||
got := node.Render(ctx)
|
||||
want := "title=\"\U0001F680 Rocket Launch\""
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deep nesting stress tests ---
|
||||
|
||||
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Build 10 levels of nested layouts
|
||||
current := NewLayout("C").C(Raw("deepest"))
|
||||
for range 9 {
|
||||
current = NewLayout("C").C(current)
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
|
||||
// Should contain the deepest content
|
||||
if !containsText(got, "deepest") {
|
||||
t.Error("10 levels deep: missing leaf content")
|
||||
}
|
||||
|
||||
// Should have 10 levels of C.0 nesting
|
||||
expectedBlock := "C"
|
||||
for i := 1; i < 10; i++ {
|
||||
expectedBlock += ".0"
|
||||
}
|
||||
if !containsText(got, `data-block="`+expectedBlock+`"`) {
|
||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
||||
}
|
||||
|
||||
// Must have exactly 10 <main> tags
|
||||
if count := countText(got, "<main"); count != 10 {
|
||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
current := NewLayout("C").C(Raw("bottom"))
|
||||
for range 19 {
|
||||
current = NewLayout("C").C(current)
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
|
||||
if !containsText(got, "bottom") {
|
||||
t.Error("20 levels deep: missing leaf content")
|
||||
}
|
||||
if count := countText(got, "<main"); count != 20 {
|
||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||
current := NewLayout("C").C(Raw("leaf"))
|
||||
for i := range 5 {
|
||||
if i%2 == 0 {
|
||||
current = NewLayout("HLCRF").L(current)
|
||||
} else {
|
||||
current = NewLayout("HCF").C(current)
|
||||
}
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
if !containsText(got, "leaf") {
|
||||
t.Error("mixed deep nesting: missing leaf content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 1000)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := countText(got, "<li>"); count != 1000 {
|
||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||
}
|
||||
if !containsText(got, "<li>0</li>") {
|
||||
t.Error("Each with 1000 items: missing first item")
|
||||
}
|
||||
if !containsText(got, "<li>999</li>") {
|
||||
t.Error("Each with 1000 items: missing last item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 5000)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("span", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := countText(got, "<span>"); count != 5000 {
|
||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_NestedEach_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
rows := []int{0, 1, 2}
|
||||
cols := []string{"a", "b", "c"}
|
||||
|
||||
node := Each(rows, func(row int) Node {
|
||||
return El("tr", Each(cols, func(col string) Node {
|
||||
return El("td", Raw(itoaText(row)+"-"+col))
|
||||
}))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := countText(got, "<tr>"); count != 3 {
|
||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||
}
|
||||
if count := countText(got, "<td>"); count != 9 {
|
||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||
}
|
||||
if !containsText(got, "1-b") {
|
||||
t.Error("nested Each: missing cell content '1-b'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_WrappedElement_PreservesItemPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
node := Each([]string{"a", "b"}, func(item string) Node {
|
||||
return If(func(*Context) bool { return true }, El("span", Raw(item)))
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0.0"`) {
|
||||
t.Fatalf("wrapped Each element should preserve first item path, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.1"`) {
|
||||
t.Fatalf("wrapped Each element should preserve second item path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_WrappedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
|
||||
node := Each([]Node{inner}, func(item Node) Node {
|
||||
return If(func(*Context) bool { return true }, item)
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C"><main role="main" data-block="C.0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("wrapped Each layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout variant validation ---
|
||||
|
||||
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
}{
|
||||
{"all invalid", "XYZ"},
|
||||
{"lowercase valid", "hlcrf"},
|
||||
{"numbers", "123"},
|
||||
{"special chars", "!@#"},
|
||||
{"mixed valid and invalid", "HXC"},
|
||||
{"empty string", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
layout := NewLayout(tt.variant).
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Invalid variant chars should silently produce no output for those slots
|
||||
// This documents the current behaviour: no panic, no error.
|
||||
if tt.variant == "XYZ" || tt.variant == "hlcrf" || tt.variant == "123" ||
|
||||
tt.variant == "!@#" || tt.variant == "" {
|
||||
if got != "" {
|
||||
t.Errorf("NewLayout(%q) with all invalid chars should produce empty output, got %q", tt.variant, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_VariantError_NoOp_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
build func(*Layout)
|
||||
wantRender string
|
||||
}{
|
||||
{
|
||||
name: "valid variant",
|
||||
variant: "HCF",
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H">header</header><main role="main" data-block="C">main</main><footer role="contentinfo" data-block="F">footer</footer>`,
|
||||
},
|
||||
{
|
||||
name: "mixed invalid variant",
|
||||
variant: "HXC",
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H">header</header><main role="main" data-block="C">main</main>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
layout := NewLayout(tt.variant)
|
||||
if tt.build != nil {
|
||||
tt.build(layout)
|
||||
}
|
||||
if layout.VariantError() != nil {
|
||||
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
||||
}
|
||||
|
||||
got := layout.Render(NewContext())
|
||||
if got != tt.wantRender {
|
||||
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLayoutVariant_NoOp_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
}{
|
||||
{name: "valid", variant: "HCF"},
|
||||
{name: "invalid", variant: "HXC"},
|
||||
{name: "empty", variant: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLayoutVariant(tt.variant)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
||||
layout := NewLayout("HXC").
|
||||
H(Raw("header")).C(Raw("main"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !containsText(got, "header") {
|
||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
||||
}
|
||||
// Should only have 2 semantic elements
|
||||
if count := countText(got, "data-block="); count != 2 {
|
||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "CCC" — C appears three times. Should render C slot content three times.
|
||||
layout := NewLayout("CCC").C(Raw("content"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
count := countText(got, "content")
|
||||
if count != 3 {
|
||||
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DuplicateVariantChars_UniqueBlockIDs_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
layout := NewLayout("CCC").C(Raw("content"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
for _, want := range []string{`data-block="C"`, `data-block="C.1"`, `data-block="C.2"`} {
|
||||
if !containsText(got, want) {
|
||||
t.Fatalf("CCC variant should assign unique block ID %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EmptySlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Variant includes all slots but none are populated — should produce empty output.
|
||||
layout := NewLayout("HLCRF")
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("layout with no slot content should produce empty output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0"`) {
|
||||
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": Raw("ignored"),
|
||||
}))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0"`) {
|
||||
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render convenience function edge cases ---
|
||||
|
||||
func TestRender_NilContext_Ugly(t *testing.T) {
|
||||
node := Raw("test")
|
||||
got := Render(node, nil)
|
||||
if got != "test" {
|
||||
t.Errorf("Render with nil context = %q, want %q", got, "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImprint_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
node := NewLayout("C").C(El("p", Text("Building project")))
|
||||
imp := Imprint(node, nil)
|
||||
|
||||
if imp.TokenCount == 0 {
|
||||
t.Error("Imprint with nil context should still produce tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("a", NewLayout("C").C(Text("Building project"))).
|
||||
Variant("b", NewLayout("C").C(Text("Building project")))
|
||||
|
||||
scores := CompareVariants(r, nil)
|
||||
if _, ok := scores["a:b"]; !ok {
|
||||
t.Error("CompareVariants with nil context should still produce scores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("only", NewLayout("C").C(Text("Building project")))
|
||||
|
||||
scores := CompareVariants(r, NewContext())
|
||||
if len(scores) != 0 {
|
||||
t.Errorf("CompareVariants with single variant should produce no pairs, got %d", len(scores))
|
||||
}
|
||||
}
|
||||
|
||||
// --- escapeHTML / escapeAttr edge cases ---
|
||||
|
||||
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "data-val", `&<>"'`)
|
||||
got := node.Render(ctx)
|
||||
|
||||
if containsText(got, `"&<>"'"`) {
|
||||
t.Error("attribute value with special chars must be fully escaped")
|
||||
}
|
||||
if !containsText(got, "&<>"'") {
|
||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_EmptyTag_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
||||
// Empty tag is weird but should not panic
|
||||
if !containsText(got, "content") {
|
||||
t.Errorf("El with empty tag should still render children, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"a": Raw("alpha"),
|
||||
"b": Raw("beta"),
|
||||
}
|
||||
node := Switch(func(*Context) string { return "c" }, cases)
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Switch with no matching case should produce empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitled_NilContext_Ugly(t *testing.T) {
|
||||
node := Entitled("premium", Raw("content"))
|
||||
got := node.Render(nil)
|
||||
if got != "" {
|
||||
t.Errorf("Entitled with nil context should produce empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
21
go.mod
21
go.mod
|
|
@ -1,21 +0,0 @@
|
|||
module dappco.re/go/core/html
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/i18n v0.2.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core/process v0.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
33
go.sum
33
go.sum
|
|
@ -1,33 +0,0 @@
|
|||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
|
||||
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
|
||||
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
3
go.work
3
go.work
|
|
@ -1,3 +0,0 @@
|
|||
go 1.26.0
|
||||
|
||||
use .
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))
|
||||
|
||||
imp := Imprint(page, ctx)
|
||||
|
||||
if imp.UniqueVerbs == 0 {
|
||||
t.Error("reversal found no verbs in rendered page")
|
||||
}
|
||||
if imp.TokenCount == 0 {
|
||||
t.Error("reversal produced empty imprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
L(El("nav", Text("Deleted files"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
R(El("aside", Text("Completed"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("C").
|
||||
C(El("p", Text("Files deleted successfully"))))
|
||||
|
||||
imp := Imprint(r, ctx)
|
||||
|
||||
if imp.TokenCount == 0 {
|
||||
t.Error("responsive imprint produced zero tokens")
|
||||
}
|
||||
if imp.UniqueVerbs == 0 {
|
||||
t.Error("responsive imprint found no verbs")
|
||||
}
|
||||
}
|
||||
17
layout.go
17
layout.go
|
|
@ -174,22 +174,24 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
rendered := 0
|
||||
slotCounts := make(map[byte]int)
|
||||
slotOrdinal := 0
|
||||
|
||||
for i := range len(l.variant) {
|
||||
slot := l.variant[i]
|
||||
children := l.slots[slot]
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
meta, ok := slotRegistry[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
count := rendered
|
||||
count := slotOrdinal
|
||||
slotOrdinal++
|
||||
|
||||
children := l.slots[slot]
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if l.path == "" {
|
||||
count = slotCounts[slot]
|
||||
slotCounts[slot] = count + 1
|
||||
|
|
@ -214,7 +216,6 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
b.WriteString("</")
|
||||
b.WriteString(meta.tag)
|
||||
b.WriteByte('>')
|
||||
rendered++
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
|
|
|||
149
layout_test.go
149
layout_test.go
|
|
@ -1,149 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayout_HLCRF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Must contain semantic elements
|
||||
for _, want := range []string{"<header", "<nav", "<main", "<footer"} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain ARIA roles
|
||||
for _, want := range []string{`role="banner"`, `role="navigation"`, `role="main"`, `role="contentinfo"`} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain data-block IDs
|
||||
for _, want := range []string{`data-block="H"`, `data-block="L"`, `data-block="C"`, `data-block="R"`, `data-block="F"`} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain content
|
||||
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_HCF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// HCF should have header, main, footer
|
||||
for _, want := range []string{`data-block="H"`, `data-block="C"`, `data-block="F"`} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// HCF must NOT have L or R slots
|
||||
for _, unwanted := range []string{`data-block="L"`, `data-block="R"`} {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_ContentOnly_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("C").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Only C slot should render
|
||||
if !containsText(got, `data-block="C"`) {
|
||||
t.Errorf("C layout missing data-block=\"C\" in:\n%s", got)
|
||||
}
|
||||
if !containsText(got, "<main") {
|
||||
t.Errorf("C layout missing <main in:\n%s", got)
|
||||
}
|
||||
|
||||
// No other slots
|
||||
for _, unwanted := range []string{`data-block="H"`, `data-block="L"`, `data-block="R"`, `data-block="F"`} {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_FluentAPI_Good(t *testing.T) {
|
||||
layout := NewLayout("HLCRF")
|
||||
|
||||
// Fluent methods should return the same layout for chaining
|
||||
result := layout.H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))
|
||||
if result != layout {
|
||||
t.Error("fluent methods must return the same *Layout for chaining")
|
||||
}
|
||||
|
||||
got := layout.Render(NewContext())
|
||||
if got == "" {
|
||||
t.Error("fluent chain should produce non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
// "C" variant: populating L and R should have no effect
|
||||
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("C variant should render main content, got:\n%s", got)
|
||||
}
|
||||
if containsText(got, "left") {
|
||||
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
||||
}
|
||||
if containsText(got, "right") {
|
||||
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Methods_NilLayout_Ugly(t *testing.T) {
|
||||
var layout *Layout
|
||||
|
||||
if layout.H(Raw("h")) != nil {
|
||||
t.Fatal("expected nil layout from H on nil receiver")
|
||||
}
|
||||
if layout.L(Raw("l")) != nil {
|
||||
t.Fatal("expected nil layout from L on nil receiver")
|
||||
}
|
||||
if layout.C(Raw("c")) != nil {
|
||||
t.Fatal("expected nil layout from C on nil receiver")
|
||||
}
|
||||
if layout.R(Raw("r")) != nil {
|
||||
t.Fatal("expected nil layout from R on nil receiver")
|
||||
}
|
||||
if layout.F(Raw("f")) != nil {
|
||||
t.Fatal("expected nil layout from F on nil receiver")
|
||||
}
|
||||
|
||||
if got := layout.Render(NewContext()); got != "" {
|
||||
t.Fatalf("nil layout render should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Render_NilContext_Good(t *testing.T) {
|
||||
layout := NewLayout("C").C(Raw("content"))
|
||||
|
||||
got := layout.Render(nil)
|
||||
want := `<main role="main" data-block="C">content</main>`
|
||||
if got != want {
|
||||
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
553
node.go
553
node.go
|
|
@ -1,553 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"html"
|
||||
"iter"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Node is anything renderable.
|
||||
// Usage example: var n Node = El("div", Text("welcome"))
|
||||
type Node interface {
|
||||
Render(ctx *Context) string
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ Node = (*rawNode)(nil)
|
||||
_ Node = (*elNode)(nil)
|
||||
_ Node = (*textNode)(nil)
|
||||
_ Node = (*ifNode)(nil)
|
||||
_ Node = (*unlessNode)(nil)
|
||||
_ Node = (*entitledNode)(nil)
|
||||
_ Node = (*switchNode)(nil)
|
||||
_ Node = (*eachNode[any])(nil)
|
||||
)
|
||||
|
||||
type layoutPathRenderer interface {
|
||||
renderWithLayoutPath(ctx *Context, path string) string
|
||||
}
|
||||
|
||||
// voidElements is the set of HTML elements that must not have a closing tag.
|
||||
var voidElements = map[string]bool{
|
||||
"area": true,
|
||||
"base": true,
|
||||
"br": true,
|
||||
"col": true,
|
||||
"embed": true,
|
||||
"hr": true,
|
||||
"img": true,
|
||||
"input": true,
|
||||
"link": true,
|
||||
"meta": true,
|
||||
"source": true,
|
||||
"track": true,
|
||||
"wbr": true,
|
||||
}
|
||||
|
||||
// escapeAttr escapes a string for use in an HTML attribute value.
|
||||
func escapeAttr(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// --- rawNode ---
|
||||
|
||||
type rawNode struct {
|
||||
content string
|
||||
}
|
||||
|
||||
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
// Usage example: Raw("<strong>trusted</strong>")
|
||||
func Raw(content string) Node {
|
||||
return &rawNode{content: content}
|
||||
}
|
||||
|
||||
func (n *rawNode) Render(_ *Context) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return n.content
|
||||
}
|
||||
|
||||
func (n *rawNode) renderWithLayoutPath(_ *Context, _ string) string {
|
||||
return n.Render(nil)
|
||||
}
|
||||
|
||||
// --- elNode ---
|
||||
|
||||
type elNode struct {
|
||||
tag string
|
||||
children []Node
|
||||
attrs map[string]string
|
||||
}
|
||||
|
||||
// El creates an HTML element node with children.
|
||||
// Usage example: El("section", Text("welcome"))
|
||||
func El(tag string, children ...Node) Node {
|
||||
return &elNode{
|
||||
tag: tag,
|
||||
children: children,
|
||||
attrs: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Attr sets an attribute on an El node. Returns the node for chaining.
|
||||
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||
// It recursively traverses through wrappers like If, Unless, Entitled, Each,
|
||||
// EachSeq, Switch, Layout, and Responsive when present.
|
||||
func Attr(n Node, key, value string) Node {
|
||||
if isNilNode(n) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch t := n.(type) {
|
||||
case *elNode:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.attrs[key] = value
|
||||
case *ifNode:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
Attr(t.node, key, value)
|
||||
case *unlessNode:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
Attr(t.node, key, value)
|
||||
case *entitledNode:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
Attr(t.node, key, value)
|
||||
case *switchNode:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
for _, child := range t.cases {
|
||||
Attr(child, key, value)
|
||||
}
|
||||
case *Layout:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if t.slots != nil {
|
||||
for slot, children := range t.slots {
|
||||
for i := range children {
|
||||
children[i] = Attr(children[i], key, value)
|
||||
}
|
||||
t.slots[slot] = children
|
||||
}
|
||||
}
|
||||
case *Responsive:
|
||||
for i := range t.variants {
|
||||
Attr(t.variants[i].layout, key, value)
|
||||
}
|
||||
case attrApplier:
|
||||
t.applyAttr(key, value)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isNilNode(n Node) bool {
|
||||
if n == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(n)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AriaLabel sets an aria-label attribute on an element node.
|
||||
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
||||
func AriaLabel(n Node, label string) Node {
|
||||
return Attr(n, "aria-label", label)
|
||||
}
|
||||
|
||||
// AltText sets an alt attribute on an element node.
|
||||
// Usage example: AltText(El("img"), "Profile photo")
|
||||
func AltText(n Node, text string) Node {
|
||||
return Attr(n, "alt", text)
|
||||
}
|
||||
|
||||
// TabIndex sets a tabindex attribute on an element node.
|
||||
// Usage example: TabIndex(El("button", Text("save")), 0)
|
||||
func TabIndex(n Node, index int) Node {
|
||||
return Attr(n, "tabindex", strconv.Itoa(index))
|
||||
}
|
||||
|
||||
// AutoFocus sets an autofocus attribute on an element node.
|
||||
// Usage example: AutoFocus(El("input"))
|
||||
func AutoFocus(n Node) Node {
|
||||
return Attr(n, "autofocus", "autofocus")
|
||||
}
|
||||
|
||||
// Role sets a role attribute on an element node.
|
||||
// Usage example: Role(El("nav", Text("links")), "navigation")
|
||||
func Role(n Node, role string) Node {
|
||||
return Attr(n, "role", role)
|
||||
}
|
||||
|
||||
func (n *elNode) Render(ctx *Context) string {
|
||||
return n.render(ctx, "")
|
||||
}
|
||||
|
||||
func (n *elNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
return n.render(ctx, path)
|
||||
}
|
||||
|
||||
func (n *elNode) render(ctx *Context, path string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
attrs := n.attrs
|
||||
if path != "" {
|
||||
attrs = make(map[string]string, len(n.attrs)+1)
|
||||
for key, value := range n.attrs {
|
||||
attrs[key] = value
|
||||
}
|
||||
attrs["data-block"] = path
|
||||
}
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
|
||||
// Sort attribute keys for deterministic output.
|
||||
keys := slices.Collect(maps.Keys(attrs))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(escapeHTML(key))
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeAttr(attrs[key]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
b.WriteByte('>')
|
||||
|
||||
if voidElements[n.tag] {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i := range len(n.children) {
|
||||
child := n.children[i]
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
b.WriteString(child.Render(ctx))
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path+"."+strconv.Itoa(i)))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
b.WriteByte('>')
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- escapeHTML ---
|
||||
|
||||
// escapeHTML escapes a string for safe inclusion in HTML text content.
|
||||
func escapeHTML(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// --- textNode ---
|
||||
|
||||
type textNode struct {
|
||||
key string
|
||||
args []any
|
||||
}
|
||||
|
||||
// Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
// Usage example: Text("welcome", "Ada")
|
||||
// Output is HTML-escaped by default. Safe-by-default path.
|
||||
func Text(key string, args ...any) Node {
|
||||
return &textNode{key: key, args: args}
|
||||
}
|
||||
|
||||
func (n *textNode) Render(ctx *Context) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||
}
|
||||
|
||||
func (n *textNode) renderWithLayoutPath(ctx *Context, _ string) string {
|
||||
return n.Render(ctx)
|
||||
}
|
||||
|
||||
// --- ifNode ---
|
||||
|
||||
type ifNode struct {
|
||||
cond func(*Context) bool
|
||||
node Node
|
||||
}
|
||||
|
||||
// If renders child only when condition is true.
|
||||
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
func If(cond func(*Context) bool, node Node) Node {
|
||||
return &ifNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *ifNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (n *ifNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if n.cond(ctx) {
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- unlessNode ---
|
||||
|
||||
type unlessNode struct {
|
||||
cond func(*Context) bool
|
||||
node Node
|
||||
}
|
||||
|
||||
// Unless renders child only when condition is false.
|
||||
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
func Unless(cond func(*Context) bool, node Node) Node {
|
||||
return &unlessNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *unlessNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (n *unlessNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !n.cond(ctx) {
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- entitledNode ---
|
||||
|
||||
type entitledNode struct {
|
||||
feature string
|
||||
node Node
|
||||
}
|
||||
|
||||
// Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
// Usage example: Entitled("beta", Text("preview"))
|
||||
// If no entitlement function is set on the context, access is denied by default.
|
||||
func Entitled(feature string, node Node) Node {
|
||||
return &entitledNode{feature: feature, node: node}
|
||||
}
|
||||
|
||||
func (n *entitledNode) Render(ctx *Context) string {
|
||||
if n == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return ""
|
||||
}
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
|
||||
func (n *entitledNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(n.node, ctx, path)
|
||||
}
|
||||
|
||||
// --- switchNode ---
|
||||
|
||||
type switchNode struct {
|
||||
selector func(*Context) string
|
||||
cases map[string]Node
|
||||
}
|
||||
|
||||
// Switch renders based on runtime selector value.
|
||||
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
||||
return &switchNode{selector: selector, cases: cases}
|
||||
}
|
||||
|
||||
func (n *switchNode) Render(ctx *Context) string {
|
||||
if n == nil || n.selector == nil {
|
||||
return ""
|
||||
}
|
||||
key := n.selector(ctx)
|
||||
if n.cases == nil {
|
||||
return ""
|
||||
}
|
||||
if node, ok := n.cases[key]; ok {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
return node.Render(ctx)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (n *switchNode) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.selector == nil {
|
||||
return ""
|
||||
}
|
||||
key := n.selector(ctx)
|
||||
if n.cases == nil {
|
||||
return ""
|
||||
}
|
||||
node, ok := n.cases[key]
|
||||
if !ok || node == nil {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(node, ctx, path)
|
||||
}
|
||||
|
||||
// --- eachNode ---
|
||||
|
||||
type eachNode[T any] struct {
|
||||
items []T
|
||||
seq iter.Seq[T]
|
||||
fn func(T) Node
|
||||
}
|
||||
|
||||
type attrApplier interface {
|
||||
applyAttr(key, value string)
|
||||
}
|
||||
|
||||
func nodePreservesLayoutPath(node Node, ctx *Context) bool {
|
||||
switch n := node.(type) {
|
||||
case *Layout, *Responsive:
|
||||
return true
|
||||
case *ifNode:
|
||||
if n == nil || n.cond == nil || n.node == nil || !n.cond(ctx) {
|
||||
return false
|
||||
}
|
||||
return nodePreservesLayoutPath(n.node, ctx)
|
||||
case *unlessNode:
|
||||
if n == nil || n.cond == nil || n.node == nil || n.cond(ctx) {
|
||||
return false
|
||||
}
|
||||
return nodePreservesLayoutPath(n.node, ctx)
|
||||
case *entitledNode:
|
||||
if n == nil || n.node == nil {
|
||||
return false
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return false
|
||||
}
|
||||
return nodePreservesLayoutPath(n.node, ctx)
|
||||
case *switchNode:
|
||||
if n == nil || n.selector == nil || n.cases == nil {
|
||||
return false
|
||||
}
|
||||
child, ok := n.cases[n.selector(ctx)]
|
||||
if !ok || child == nil {
|
||||
return false
|
||||
}
|
||||
return nodePreservesLayoutPath(child, ctx)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Each iterates items and renders each via fn.
|
||||
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
func Each[T any](items []T, fn func(T) Node) Node {
|
||||
return &eachNode[T]{items: items, fn: fn}
|
||||
}
|
||||
|
||||
// EachSeq iterates an iter.Seq and renders each via fn.
|
||||
// Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
||||
return &eachNode[T]{seq: items, fn: fn}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) Render(ctx *Context) string {
|
||||
return n.renderWithLayoutPath(ctx, "")
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) applyAttr(key, value string) {
|
||||
if n == nil || n.fn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
prev := n.fn
|
||||
n.fn = func(item T) Node {
|
||||
return Attr(prev(item), key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.fn == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
items := n.materialiseItems()
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
total := len(items)
|
||||
for idx, item := range items {
|
||||
child := n.fn(item)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
childPath := path
|
||||
if path != "" && (!nodePreservesLayoutPath(child, ctx) || total > 1) {
|
||||
childPath = path + "." + strconv.Itoa(idx)
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, childPath))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) materialiseItems() []T {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
if n.seq == nil {
|
||||
return n.items
|
||||
}
|
||||
|
||||
items := make([]T, 0)
|
||||
for item := range n.seq {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
617
node_test.go
617
node_test.go
|
|
@ -1,617 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func TestRawNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Raw("hello")
|
||||
got := node.Render(ctx)
|
||||
if got != "hello" {
|
||||
t.Errorf("Raw(\"hello\").Render() = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
want := "<div>content</div>"
|
||||
if got != want {
|
||||
t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_Nested_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", El("span", Raw("inner")))
|
||||
got := node.Render(ctx)
|
||||
want := "<div><span>inner</span></div>"
|
||||
if got != want {
|
||||
t.Errorf("nested El().Render() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DirectElementBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
got := NewLayout("C").C(El("div", Raw("content"))).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0"`) {
|
||||
t.Fatalf("direct element inside layout should receive a block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EachElementBlockPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
got := NewLayout("C").C(
|
||||
Each([]string{"a", "b"}, func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
}),
|
||||
).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0.0"`) {
|
||||
t.Fatalf("first Each item should receive a block path, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.1"`) {
|
||||
t.Fatalf("second Each item should receive a block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleChildren_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("a"), Raw("b"))
|
||||
got := node.Render(ctx)
|
||||
want := "<div>ab</div>"
|
||||
if got != want {
|
||||
t.Errorf("El with multiple children = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_VoidElement_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("br")
|
||||
got := node.Render(ctx)
|
||||
want := "<br>"
|
||||
if got != want {
|
||||
t.Errorf("El(\"br\").Render() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
if got != "hello" {
|
||||
t.Errorf("Text(\"hello\").Render() = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_UsesContextDataForCount_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
data map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "capitalised count",
|
||||
key: "i18n.count.file",
|
||||
data: map[string]any{"Count": 5},
|
||||
want: "5 files",
|
||||
},
|
||||
{
|
||||
name: "lowercase count",
|
||||
key: "i18n.count.file",
|
||||
data: map[string]any{"count": 1},
|
||||
want: "1 file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
for k, v := range tt.data {
|
||||
ctx.Metadata[k] = v
|
||||
}
|
||||
|
||||
got := Text(tt.key).Render(ctx)
|
||||
if got != tt.want {
|
||||
t.Fatalf("Text(%q).Render() = %q, want %q", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Escapes_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("<script>alert('xss')</script>")
|
||||
got := node.Render(ctx)
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("Text node must HTML-escape output, got %q", got)
|
||||
}
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("Text node should contain escaped script tag, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return true }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
if got != "visible" {
|
||||
t.Errorf("If(true) = %q, want %q", got, "visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return false }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("If(false) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlessNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return false }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
if got != "visible" {
|
||||
t.Errorf("Unless(false) = %q, want %q", got, "visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Granted_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
got := node.Render(ctx)
|
||||
if got != "premium content" {
|
||||
t.Errorf("Entitled(granted) = %q, want %q", got, "premium content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Denied_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return false }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Entitled(denied) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Entitled(no func) = %q, want %q (deny by default)", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := []string{"a", "b", "c"}
|
||||
node := Each(items, func(item string) Node {
|
||||
return El("li", Raw(item))
|
||||
})
|
||||
got := node.Render(ctx)
|
||||
want := "<li>a</li><li>b</li><li>c</li>"
|
||||
if got != want {
|
||||
t.Errorf("Each([a,b,c]) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachNode_Empty_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{}, func(item string) Node {
|
||||
return El("li", Raw(item))
|
||||
})
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Each([]) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := Each([]Node{inner}, func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C"><main role="main" data-block="C.0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("Each nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachNode_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
first := NewLayout("C").C(Raw("one"))
|
||||
second := NewLayout("C").C(Raw("two"))
|
||||
|
||||
node := Each([]Node{first, second}, func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
if !containsText(got, `data-block="C.0.0"`) {
|
||||
t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.1"`) {
|
||||
t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C"><main role="main" data-block="C.0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSeq_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
first := NewLayout("C").C(Raw("one"))
|
||||
second := NewLayout("C").C(Raw("two"))
|
||||
|
||||
node := EachSeq(slices.Values([]Node{first, second}), func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
if !containsText(got, `data-block="C.0.0"`) {
|
||||
t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.1"`) {
|
||||
t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_Attr_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("content")), "class", "container")
|
||||
got := node.Render(ctx)
|
||||
want := `<div class="container">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_AttrRecursiveThroughEachSeq_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(
|
||||
EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
}),
|
||||
"data-kind",
|
||||
"item",
|
||||
)
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
if count := countText(got, `data-kind="item"`); count != 2 {
|
||||
t.Fatalf("Attr through EachSeq should apply to every item, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_AttrRecursiveThroughSwitch_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(
|
||||
Switch(
|
||||
func(*Context) string { return "match" },
|
||||
map[string]Node{
|
||||
"match": El("span", Raw("visible")),
|
||||
"miss": El("span", Raw("hidden")),
|
||||
},
|
||||
),
|
||||
"data-state",
|
||||
"selected",
|
||||
)
|
||||
|
||||
got := node.Render(ctx)
|
||||
if !containsText(got, `data-state="selected"`) {
|
||||
t.Fatalf("Attr through Switch should reach the selected case, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessibilityHelpers_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
button := Role(
|
||||
AriaLabel(
|
||||
TabIndex(
|
||||
AutoFocus(El("button", Raw("save"))),
|
||||
3,
|
||||
),
|
||||
"Save changes",
|
||||
),
|
||||
"button",
|
||||
)
|
||||
|
||||
got := button.Render(ctx)
|
||||
for _, want := range []string{
|
||||
`aria-label="Save changes"`,
|
||||
`autofocus="autofocus"`,
|
||||
`role="button"`,
|
||||
`tabindex="3"`,
|
||||
">save</button>",
|
||||
} {
|
||||
if !containsText(got, want) {
|
||||
t.Fatalf("accessibility helpers missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
img := AltText(El("img"), "Profile photo")
|
||||
if got := img.Render(ctx); got != `<img alt="Profile photo">` {
|
||||
t.Fatalf("AltText() = %q, want %q", got, `<img alt="Profile photo">`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Locale = "en-GB"
|
||||
|
||||
node := Switch(
|
||||
func(ctx *Context) string { return ctx.Locale },
|
||||
map[string]Node{
|
||||
"en-GB": Raw("hello"),
|
||||
"fr-FR": Raw("bonjour"),
|
||||
},
|
||||
)
|
||||
|
||||
if got := node.Render(ctx); got != "hello" {
|
||||
t.Fatalf("Switch matched case = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
if got := Switch(func(*Context) string { return "de-DE" }, map[string]Node{"en-GB": Raw("hello")}).Render(ctx); got != "" {
|
||||
t.Fatalf("Switch missing case = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_AttrEscaping_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("img"), "alt", `he said "hello"`)
|
||||
got := node.Render(ctx)
|
||||
if !containsText(got, `alt="he said "hello""`) {
|
||||
t.Errorf("Attr should escape attribute values, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAriaLabel_Good(t *testing.T) {
|
||||
node := AriaLabel(El("button", Raw("save")), "Save changes")
|
||||
got := node.Render(NewContext())
|
||||
want := `<button aria-label="Save changes">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("AriaLabel() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltText_Good(t *testing.T) {
|
||||
node := AltText(El("img"), "Profile photo")
|
||||
got := node.Render(NewContext())
|
||||
want := `<img alt="Profile photo">`
|
||||
if got != want {
|
||||
t.Errorf("AltText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabIndex_Good(t *testing.T) {
|
||||
node := TabIndex(El("button", Raw("save")), 0)
|
||||
got := node.Render(NewContext())
|
||||
want := `<button tabindex="0">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("TabIndex() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocus_Good(t *testing.T) {
|
||||
node := AutoFocus(El("input"))
|
||||
got := node.Render(NewContext())
|
||||
want := `<input autofocus="autofocus">`
|
||||
if got != want {
|
||||
t.Errorf("AutoFocus() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_Good(t *testing.T) {
|
||||
node := Role(El("nav", Raw("links")), "navigation")
|
||||
got := node.Render(NewContext())
|
||||
want := `<nav role="navigation">links</nav>`
|
||||
if got != want {
|
||||
t.Errorf("Role() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleAttrs_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
|
||||
got := node.Render(ctx)
|
||||
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
|
||||
t.Errorf("multiple Attr() calls should stack, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_NonElement_Ugly(t *testing.T) {
|
||||
node := Attr(Raw("text"), "class", "x")
|
||||
got := node.Render(NewContext())
|
||||
if got != "text" {
|
||||
t.Errorf("Attr on non-element should return unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_TypedNilWrappers_Ugly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node Node
|
||||
}{
|
||||
{name: "layout", node: (*Layout)(nil)},
|
||||
{name: "responsive", node: (*Responsive)(nil)},
|
||||
{name: "if", node: (*ifNode)(nil)},
|
||||
{name: "unless", node: (*unlessNode)(nil)},
|
||||
{name: "entitled", node: (*entitledNode)(nil)},
|
||||
{name: "switch", node: (*switchNode)(nil)},
|
||||
{name: "each", node: (*eachNode[string])(nil)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Attr(tt.node, "data-test", "x"); got != nil {
|
||||
t.Fatalf("Attr on typed nil %s should return nil, got %#v", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlessNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Unless(true) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughIfNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := If(func(*Context) bool { return true }, inner)
|
||||
Attr(node, "class", "wrapped")
|
||||
got := node.Render(ctx)
|
||||
want := `<div class="wrapped">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through If = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughUnlessNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Unless(func(*Context) bool { return false }, inner)
|
||||
Attr(node, "id", "test")
|
||||
got := node.Render(ctx)
|
||||
want := `<div id="test">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Unless = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEntitledNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(string) bool { return true }
|
||||
inner := El("div", Raw("content"))
|
||||
node := Entitled("feature", inner)
|
||||
Attr(node, "data-feat", "on")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-feat="on">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Entitled = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": El("span", Raw("unused")),
|
||||
})
|
||||
Attr(node, "data-state", "active")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-state="active">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Switch = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughLayout_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("C").C(El("div", Raw("content")))
|
||||
Attr(layout, "class", "page")
|
||||
|
||||
got := layout.Render(ctx)
|
||||
want := `<main role="main" data-block="C"><div class="page" data-block="C.0">content</div></main>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Layout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughResponsive_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
resp := NewResponsive().Variant("mobile", NewLayout("C").C(El("div", Raw("content"))))
|
||||
Attr(resp, "data-kind", "page")
|
||||
|
||||
got := resp.Render(ctx)
|
||||
want := `<div data-variant="mobile"><main role="main" data-block="C"><div data-block="C.0" data-kind="page">content</div></main></div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Responsive = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{"a", "b"}, func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "class", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span class="item">a</span><span class="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Each = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachSeqNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "data-kind", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span data-kind="item">a</span><span data-kind="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_WithService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
if got != "hello" {
|
||||
t.Errorf("Text with service context = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_SelectsMatch_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"dark": Raw("dark theme"),
|
||||
"light": Raw("light theme"),
|
||||
}
|
||||
node := Switch(func(*Context) string { return "dark" }, cases)
|
||||
got := node.Render(ctx)
|
||||
want := "dark theme"
|
||||
if got != want {
|
||||
t.Errorf("Switch(\"dark\") = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
134
path.go
134
path.go
|
|
@ -1,134 +0,0 @@
|
|||
package html
|
||||
|
||||
// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
|
||||
// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
|
||||
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
|
||||
// stdlib strings is safe for WASM.
|
||||
|
||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
// Usage example: slots := ParseBlockID("C.0.1")
|
||||
// It accepts the current dotted coordinate form and the older hyphenated
|
||||
// form for compatibility. Mixed separators and malformed coordinates are
|
||||
// rejected.
|
||||
func ParseBlockID(id string) []byte {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tokens := make([]string, 0, 4)
|
||||
sepKind := byte(0)
|
||||
|
||||
for i := 0; i < len(id); {
|
||||
start := i
|
||||
for i < len(id) && id[i] != '.' && id[i] != '-' {
|
||||
i++
|
||||
}
|
||||
|
||||
token := id[start:i]
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
|
||||
if i == len(id) {
|
||||
break
|
||||
}
|
||||
|
||||
sep := id[i]
|
||||
if sepKind == 0 {
|
||||
sepKind = sep
|
||||
} else if sepKind != sep {
|
||||
return nil
|
||||
}
|
||||
i++
|
||||
if i == len(id) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch sepKind {
|
||||
case 0, '.':
|
||||
return parseDottedBlockID(tokens)
|
||||
case '-':
|
||||
return parseHyphenatedBlockID(tokens)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseDottedBlockID(tokens []string) []byte {
|
||||
if len(tokens) == 0 || !isSlotToken(tokens[0]) {
|
||||
return nil
|
||||
}
|
||||
if len(tokens) > 1 && isSlotToken(tokens[len(tokens)-1]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(tokens))
|
||||
slots = append(slots, tokens[0][0])
|
||||
|
||||
prevWasSlot := true
|
||||
for i := 1; i < len(tokens); i++ {
|
||||
token := tokens[i]
|
||||
if isSlotToken(token) {
|
||||
if prevWasSlot {
|
||||
return nil
|
||||
}
|
||||
slots = append(slots, token[0])
|
||||
prevWasSlot = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !allDigits(token) {
|
||||
return nil
|
||||
}
|
||||
prevWasSlot = false
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
func parseHyphenatedBlockID(tokens []string) []byte {
|
||||
if len(tokens) < 2 || len(tokens)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
if !isSlotToken(tokens[0]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(tokens)/2)
|
||||
for i, token := range tokens {
|
||||
switch {
|
||||
case i%2 == 0:
|
||||
if !isSlotToken(token) {
|
||||
return nil
|
||||
}
|
||||
slots = append(slots, token[0])
|
||||
case token != "0":
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
func isSlotToken(token string) bool {
|
||||
if len(token) != 1 {
|
||||
return false
|
||||
}
|
||||
_, ok := slotRegistry[token[0]]
|
||||
return ok
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
13
path_test.go
13
path_test.go
|
|
@ -38,6 +38,19 @@ func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNestedLayout_StablePathsAcrossEmptySlots_Good(t *testing.T) {
|
||||
inner := NewLayout("HCF").
|
||||
C(Raw("body")).
|
||||
F(Raw("links"))
|
||||
outer := NewLayout("C").C(inner)
|
||||
|
||||
got := outer.Render(NewContext())
|
||||
want := `<main role="main" data-block="C"><main role="main" data-block="C.0.1">body</main><footer role="contentinfo" data-block="C.0.2">links</footer></main>`
|
||||
if got != want {
|
||||
t.Fatalf("nested layout with empty leading slots = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockID_BuildsPath_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
|
|
|
|||
154
pipeline.go
154
pipeline.go
|
|
@ -1,154 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"dappco.re/go/core/i18n/reversal"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// StripTags removes HTML tags from rendered output, returning plain text.
|
||||
// Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
// Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
// Does not handle script/style element content (go-html does not generate these).
|
||||
func StripTags(html string) string {
|
||||
b := core.NewBuilder()
|
||||
prevSpace := true // starts true to trim leading space
|
||||
|
||||
for i := 0; i < len(html); {
|
||||
r, size := utf8.DecodeRuneInString(html[i:])
|
||||
|
||||
if r == '<' {
|
||||
next, nextSize := nextRune(html, i+size)
|
||||
if nextSize > 0 && isTagStartRune(next) {
|
||||
if end, ok := findTagCloser(html, i+size+nextSize); ok {
|
||||
if !prevSpace {
|
||||
b.WriteByte(' ')
|
||||
prevSpace = true
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
if !prevSpace {
|
||||
b.WriteByte(' ')
|
||||
prevSpace = true
|
||||
}
|
||||
default:
|
||||
_, _ = b.WriteString(html[i : i+size])
|
||||
prevSpace = false
|
||||
}
|
||||
|
||||
i += size
|
||||
}
|
||||
|
||||
return core.Trim(b.String())
|
||||
}
|
||||
|
||||
func nextRune(s string, i int) (rune, int) {
|
||||
if i >= len(s) {
|
||||
return 0, 0
|
||||
}
|
||||
return utf8.DecodeRuneInString(s[i:])
|
||||
}
|
||||
|
||||
func isTagStartRune(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return true
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return true
|
||||
case r == '/', r == '!', r == '?':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func findTagCloser(s string, start int) (int, bool) {
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
|
||||
for i := start; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
switch r {
|
||||
case '\'':
|
||||
if !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
}
|
||||
case '"':
|
||||
if !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
}
|
||||
case '>':
|
||||
if !inSingleQuote && !inDoubleQuote {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
// and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
// Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
rendered := ""
|
||||
if node != nil {
|
||||
rendered = node.Render(ctx)
|
||||
}
|
||||
text := StripTags(rendered)
|
||||
tok := reversal.NewTokeniser()
|
||||
tokens := tok.Tokenise(text)
|
||||
return reversal.NewImprint(tokens)
|
||||
}
|
||||
|
||||
// CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
// and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
if r == nil {
|
||||
return make(map[string]float64)
|
||||
}
|
||||
|
||||
type named struct {
|
||||
name string
|
||||
imp reversal.GrammarImprint
|
||||
}
|
||||
|
||||
var imprints []named
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
imp := Imprint(v.layout, cloneContext(ctx))
|
||||
imprints = append(imprints, named{name: v.name, imp: imp})
|
||||
}
|
||||
|
||||
scores := make(map[string]float64)
|
||||
for i := range len(imprints) {
|
||||
for j := i + 1; j < len(imprints); j++ {
|
||||
left := imprints[i].name
|
||||
right := imprints[j].name
|
||||
if right < left {
|
||||
left, right = right, left
|
||||
}
|
||||
key := left + ":" + right
|
||||
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
190
pipeline_test.go
190
pipeline_test.go
|
|
@ -1,190 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestStripTags_Simple_Good(t *testing.T) {
|
||||
got := StripTags(`<div>hello</div>`)
|
||||
want := "hello"
|
||||
if got != want {
|
||||
t.Errorf("StripTags(<div>hello</div>) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Nested_Good(t *testing.T) {
|
||||
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
||||
want := "Title"
|
||||
if got != want {
|
||||
t.Errorf("StripTags(nested) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
||||
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
||||
want := "Head Body Foot"
|
||||
if got != want {
|
||||
t.Errorf("StripTags(multi) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Empty_Ugly(t *testing.T) {
|
||||
got := StripTags("")
|
||||
if got != "" {
|
||||
t.Errorf("StripTags(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_NoTags_Good(t *testing.T) {
|
||||
got := StripTags("plain text")
|
||||
if got != "plain text" {
|
||||
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_PreservesComparisonOperators_Good(t *testing.T) {
|
||||
got := StripTags(`<p>1 < 2 and 3 > 2</p>`)
|
||||
want := "1 < 2 and 3 > 2"
|
||||
if got != want {
|
||||
t.Errorf("StripTags(comparisons) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_LiteralAngleBracket_Good(t *testing.T) {
|
||||
got := StripTags(`a<b`)
|
||||
want := `a<b`
|
||||
if got != want {
|
||||
t.Errorf("StripTags(literal angle) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Entities_Good(t *testing.T) {
|
||||
got := StripTags(`<script>`)
|
||||
want := "<script>"
|
||||
if got != want {
|
||||
t.Errorf("StripTags should preserve entities, got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_QuotedAttributes_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "double quotes",
|
||||
input: `<div title="1 > 0">answer</div>`,
|
||||
want: "answer",
|
||||
},
|
||||
{
|
||||
name: "single quotes",
|
||||
input: `<div title='a > b'>answer</div>`,
|
||||
want: "answer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripTags(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImprint_FromNode_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))
|
||||
|
||||
imp := Imprint(page, ctx)
|
||||
|
||||
if imp.TokenCount == 0 {
|
||||
t.Error("Imprint should produce non-zero token count")
|
||||
}
|
||||
if imp.UniqueVerbs == 0 {
|
||||
t.Error("Imprint should find verbs in rendered content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImprint_SimilarPages_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page1 := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully")))
|
||||
|
||||
page2 := NewLayout("HCF").
|
||||
H(El("h1", Text("Building system"))).
|
||||
C(El("p", Text("Files removed successfully")))
|
||||
|
||||
different := NewLayout("HCF").
|
||||
C(El("p", Raw("no grammar content here xyz abc")))
|
||||
|
||||
imp1 := Imprint(page1, ctx)
|
||||
imp2 := Imprint(page2, ctx)
|
||||
impDiff := Imprint(different, ctx)
|
||||
|
||||
sim := imp1.Similar(imp2)
|
||||
diffSim := imp1.Similar(impDiff)
|
||||
|
||||
if sim <= diffSim {
|
||||
t.Errorf("similar pages should score higher (%f) than different (%f)", sim, diffSim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_SameContent_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed"))))
|
||||
|
||||
scores := CompareVariants(r, ctx)
|
||||
|
||||
key := "desktop:mobile"
|
||||
sim, ok := scores[key]
|
||||
if !ok {
|
||||
t.Fatalf("CompareVariants missing key %q, got keys: %v", key, scores)
|
||||
}
|
||||
if sim < 0.8 {
|
||||
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_KeyOrderDeterministic_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("beta", NewLayout("C").C(El("p", Text("Building project")))).
|
||||
Variant("alpha", NewLayout("C").C(El("p", Text("Building project"))))
|
||||
|
||||
scores := CompareVariants(r, ctx)
|
||||
|
||||
if _, ok := scores["alpha:beta"]; !ok {
|
||||
t.Fatalf("CompareVariants should use deterministic key ordering, got keys: %v", scores)
|
||||
}
|
||||
}
|
||||
13
render.go
13
render.go
|
|
@ -1,13 +0,0 @@
|
|||
package html
|
||||
|
||||
// Render is a convenience function that renders a node tree to HTML.
|
||||
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
func Render(node Node, ctx *Context) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
return node.Render(ctx)
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestRender_FullPage_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Dashboard"))).
|
||||
C(
|
||||
El("div",
|
||||
El("p", Text("Welcome")),
|
||||
Each([]string{"Home", "Settings", "Profile"}, func(item string) Node {
|
||||
return El("a", Raw(item))
|
||||
}),
|
||||
),
|
||||
).
|
||||
F(El("small", Text("Footer")))
|
||||
|
||||
got := page.Render(ctx)
|
||||
|
||||
// Contains semantic elements
|
||||
for _, want := range []string{"<header", "<main", "<footer"} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Content rendered
|
||||
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic tag balance check: every opening tag should have a closing tag.
|
||||
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
|
||||
open := "<" + tag
|
||||
close := "</" + tag + ">"
|
||||
if countText(got, open) != countText(got, close) {
|
||||
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_EntitlementGating_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(f string) bool { return f == "admin" }
|
||||
|
||||
page := NewLayout("HCF").
|
||||
H(Raw("header")).
|
||||
C(
|
||||
Raw("public"),
|
||||
Entitled("admin", Raw(" admin-panel")),
|
||||
Entitled("premium", Raw(" premium-content")),
|
||||
).
|
||||
F(Raw("footer"))
|
||||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if !containsText(got, "public") {
|
||||
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, "admin-panel") {
|
||||
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
||||
}
|
||||
if containsText(got, "premium-content") {
|
||||
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XSSPrevention_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
page := NewLayout("C").
|
||||
C(Text("<script>alert('xss')</script>"))
|
||||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
||||
}
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
125
responsive.go
125
responsive.go
|
|
@ -1,125 +0,0 @@
|
|||
package html
|
||||
|
||||
// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
|
||||
// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
|
||||
// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
|
||||
// The stdlib strings/strconv primitives are safe for WASM.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Responsive)(nil)
|
||||
var _ layoutPathRenderer = (*Responsive)(nil)
|
||||
|
||||
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
// Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
type Responsive struct {
|
||||
variants []responsiveVariant
|
||||
}
|
||||
|
||||
type responsiveVariant struct {
|
||||
name string
|
||||
layout *Layout
|
||||
media string // optional CSS media-query hint (e.g. "(min-width: 768px)")
|
||||
}
|
||||
|
||||
// NewResponsive creates a new multi-variant responsive compositor.
|
||||
// Usage example: r := NewResponsive()
|
||||
func NewResponsive() *Responsive {
|
||||
return &Responsive{}
|
||||
}
|
||||
|
||||
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
// Variants render in insertion order.
|
||||
// Variant is equivalent to Add(name, layout) with no media-query hint.
|
||||
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
||||
return r.Add(name, layout)
|
||||
}
|
||||
|
||||
// Add registers a responsive variant. The optional media argument carries a
|
||||
// CSS media-query hint for downstream CSS generation (e.g. "(min-width: 768px)").
|
||||
// When supplied, Render emits it on the container as data-media.
|
||||
//
|
||||
// Usage example: NewResponsive().Add("desktop", NewLayout("HLCRF"), "(min-width: 1024px)")
|
||||
func (r *Responsive) Add(name string, layout *Layout, media ...string) *Responsive {
|
||||
if r == nil {
|
||||
r = NewResponsive()
|
||||
}
|
||||
variant := responsiveVariant{name: name, layout: layout}
|
||||
if len(media) > 0 {
|
||||
variant.media = media[0]
|
||||
}
|
||||
r.variants = append(r.variants, variant)
|
||||
return r
|
||||
}
|
||||
|
||||
// Render produces HTML with each variant in a data-variant container.
|
||||
// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
func (r *Responsive) Render(ctx *Context) string {
|
||||
return r.renderWithLayoutPath(ctx, "")
|
||||
}
|
||||
|
||||
func (r *Responsive) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString(`<div data-variant="`)
|
||||
b.WriteString(escapeAttr(v.name))
|
||||
if v.media != "" {
|
||||
b.WriteString(`" data-media="`)
|
||||
b.WriteString(escapeAttr(v.media))
|
||||
}
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(renderWithLayoutPath(v.layout, ctx, path))
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// VariantSelector returns a CSS attribute selector for a responsive variant.
|
||||
// Usage example: selector := VariantSelector("desktop")
|
||||
func VariantSelector(name string) string {
|
||||
return `[data-variant="` + escapeCSSString(name) + `"]`
|
||||
}
|
||||
|
||||
func escapeCSSString(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '"':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if r < 0x20 || r == 0x7f {
|
||||
b.WriteByte('\\')
|
||||
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
|
||||
for i := 0; i < len(esc); i++ {
|
||||
b.WriteByte(esc[i])
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponsive_SingleVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-variant="desktop"`) {
|
||||
t.Errorf("responsive should contain data-variant, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="H"`) {
|
||||
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_Add_MediaHint_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Add("desktop", NewLayout("C").C(Raw("content")), "(min-width: 1024px)")
|
||||
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-variant="desktop"`) {
|
||||
t.Fatalf("responsive should still contain data-variant, got:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-media="(min-width: 1024px)"`) {
|
||||
t.Fatalf("responsive should expose media hint, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_MultiVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
|
||||
Variant("mobile", NewLayout("C").C(Raw("c")))
|
||||
|
||||
got := r.Render(ctx)
|
||||
|
||||
for _, v := range []string{"desktop", "tablet", "mobile"} {
|
||||
if !containsText(got, `data-variant="`+v+`"`) {
|
||||
t.Errorf("responsive missing variant %q in:\n%s", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantOrder_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
|
||||
Variant("mobile", NewLayout("C").C(Raw("m")))
|
||||
|
||||
got := r.Render(ctx)
|
||||
|
||||
di := indexText(got, `data-variant="desktop"`)
|
||||
mi := indexText(got, `data-variant="mobile"`)
|
||||
if di < 0 || mi < 0 {
|
||||
t.Fatalf("missing variants in:\n%s", got)
|
||||
}
|
||||
if di >= mi {
|
||||
t.Errorf("desktop should appear before mobile (insertion order), desktop=%d mobile=%d", di, mi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_NestedPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").C(inner))
|
||||
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C.0"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C.0 in:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.1"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C.0.1 in:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0.2"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C.0.2 in:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_NestedInsideLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
|
||||
got := NewLayout("C").C(r).Render(ctx)
|
||||
|
||||
if !containsText(got, `data-variant="mobile"`) {
|
||||
t.Fatalf("responsive wrapper missing variant container in:\n%s", got)
|
||||
}
|
||||
if !containsText(got, `data-block="C.0"`) {
|
||||
t.Fatalf("responsive wrapper should preserve outer layout path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
||||
Variant("b", NewLayout("HCF").C(Raw("content-b")))
|
||||
|
||||
got := r.Render(ctx)
|
||||
|
||||
count := countText(got, `data-block="C"`)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 independent C blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
|
||||
var _ Node = NewResponsive()
|
||||
}
|
||||
|
||||
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
|
||||
var r *Responsive
|
||||
|
||||
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil responsive from Variant on nil receiver")
|
||||
}
|
||||
|
||||
if output := got.Render(NewContext()); output != `<div data-variant="mobile"><main role="main" data-block="C">content</main></div>` {
|
||||
t.Fatalf("unexpected output from nil receiver Variant path: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_Render_NilContext_Good(t *testing.T) {
|
||||
r := NewResponsive().
|
||||
Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
|
||||
got := r.Render(nil)
|
||||
want := `<div data-variant="mobile"><main role="main" data-block="C">content</main></div>`
|
||||
if got != want {
|
||||
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Good(t *testing.T) {
|
||||
got := VariantSelector("desktop")
|
||||
want := `[data-variant="desktop"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Escapes_Good(t *testing.T) {
|
||||
got := VariantSelector("desk\"top\\wide")
|
||||
want := `[data-variant="desk\"top\\wide"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
|
||||
got := VariantSelector("a\tb\nc\u0007")
|
||||
want := `[data-variant="a\9 b\A c\7 "]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/codegen`
|
||||
**Files:** 1
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/wasm`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
225
specs/root.md
225
specs/root.md
|
|
@ -1,225 +0,0 @@
|
|||
# html
|
||||
**Import:** `dappco.re/go/core/html`
|
||||
**Files:** 13
|
||||
|
||||
## Types
|
||||
|
||||
### `Context`
|
||||
`type Context struct`
|
||||
|
||||
Context carries rendering state through the node tree.
|
||||
Usage example: ctx := NewContext()
|
||||
|
||||
Fields:
|
||||
- `Identity string`
|
||||
- `Locale string`
|
||||
- `Entitlements func(feature string) bool`
|
||||
- `Data map[string]any`
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Layout`
|
||||
`type Layout struct`
|
||||
|
||||
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
with deterministic path-based IDs.
|
||||
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (l *Layout) C(nodes ...Node) *Layout`
|
||||
C appends nodes to the Content (main) slot.
|
||||
Usage example: NewLayout("C").C(Text("body"))
|
||||
- `func (l *Layout) F(nodes ...Node) *Layout`
|
||||
F appends nodes to the Footer slot.
|
||||
Usage example: NewLayout("CF").F(Text("footer"))
|
||||
- `func (l *Layout) H(nodes ...Node) *Layout`
|
||||
H appends nodes to the Header slot.
|
||||
Usage example: NewLayout("HCF").H(Text("title"))
|
||||
- `func (l *Layout) L(nodes ...Node) *Layout`
|
||||
L appends nodes to the Left aside slot.
|
||||
Usage example: NewLayout("LC").L(Text("nav"))
|
||||
- `func (l *Layout) R(nodes ...Node) *Layout`
|
||||
R appends nodes to the Right aside slot.
|
||||
Usage example: NewLayout("CR").R(Text("ads"))
|
||||
- `func (l *Layout) Render(ctx *Context) string`
|
||||
Render produces the semantic HTML for this layout.
|
||||
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||
Only slots present in the variant string are rendered.
|
||||
|
||||
### `Node`
|
||||
`type Node interface`
|
||||
|
||||
Node is anything renderable.
|
||||
Usage example: var n Node = El("div", Text("welcome"))
|
||||
|
||||
Members:
|
||||
- `Render(ctx *Context) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Responsive`
|
||||
`type Responsive struct`
|
||||
|
||||
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (r *Responsive) Render(ctx *Context) string`
|
||||
Render produces HTML with each variant in a data-variant container.
|
||||
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
|
||||
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
Variants render in insertion order.
|
||||
|
||||
### `Translator`
|
||||
`type Translator interface`
|
||||
|
||||
Translator provides Text() lookups for a rendering context.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
can provide any implementation with the same T() method.
|
||||
|
||||
Members:
|
||||
- `T(key string, args ...any) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `Attr`
|
||||
`func Attr(n Node, key, value string) Node`
|
||||
|
||||
Attr sets an attribute on an El node. Returns the node for chaining.
|
||||
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||
It recursively traverses through wrappers like If, Unless, and Entitled.
|
||||
|
||||
### `CompareVariants`
|
||||
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
|
||||
|
||||
CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
|
||||
### `Each`
|
||||
`func Each[T any](items []T, fn func(T) Node) Node`
|
||||
|
||||
Each iterates items and renders each via fn.
|
||||
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
|
||||
### `EachSeq`
|
||||
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
|
||||
|
||||
EachSeq iterates an iter.Seq and renders each via fn.
|
||||
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
|
||||
### `El`
|
||||
`func El(tag string, children ...Node) Node`
|
||||
|
||||
El creates an HTML element node with children.
|
||||
Usage example: El("section", Text("welcome"))
|
||||
|
||||
### `Entitled`
|
||||
`func Entitled(feature string, node Node) Node`
|
||||
|
||||
Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
Usage example: Entitled("beta", Text("preview"))
|
||||
If no entitlement function is set on the context, access is denied by default.
|
||||
|
||||
### `If`
|
||||
`func If(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
If renders child only when condition is true.
|
||||
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
|
||||
### `Imprint`
|
||||
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
|
||||
|
||||
Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContext`
|
||||
`func NewContext() *Context`
|
||||
|
||||
NewContext creates a new rendering context with sensible defaults.
|
||||
Usage example: html := Render(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContextWithService`
|
||||
`func NewContextWithService(svc Translator) *Context`
|
||||
|
||||
NewContextWithService creates a rendering context backed by a specific translator.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
### `NewLayout`
|
||||
`func NewLayout(variant string) *Layout`
|
||||
|
||||
NewLayout creates a new Layout with the given variant string.
|
||||
Usage example: page := NewLayout("HLCRF")
|
||||
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
|
||||
### `NewResponsive`
|
||||
`func NewResponsive() *Responsive`
|
||||
|
||||
NewResponsive creates a new multi-variant responsive compositor.
|
||||
Usage example: r := NewResponsive()
|
||||
|
||||
### `ParseBlockID`
|
||||
`func ParseBlockID(id string) []byte`
|
||||
|
||||
ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
"L-0-C-0" → ['L', 'C']
|
||||
|
||||
### `Raw`
|
||||
`func Raw(content string) Node`
|
||||
|
||||
Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
Usage example: Raw("<strong>trusted</strong>")
|
||||
|
||||
### `Render`
|
||||
`func Render(node Node, ctx *Context) string`
|
||||
|
||||
Render is a convenience function that renders a node tree to HTML.
|
||||
Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
|
||||
### `StripTags`
|
||||
`func StripTags(html string) string`
|
||||
|
||||
StripTags removes HTML tags from rendered output, returning plain text.
|
||||
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
Does not handle script/style element content (go-html does not generate these).
|
||||
|
||||
### `Switch`
|
||||
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
|
||||
|
||||
Switch renders based on runtime selector value.
|
||||
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
|
||||
### `Text`
|
||||
`func Text(key string, args ...any) Node`
|
||||
|
||||
Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
Usage example: Text("welcome", "Ada")
|
||||
Output is HTML-escaped by default. Safe-by-default path.
|
||||
|
||||
### `Unless`
|
||||
`func Unless(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
Unless renders child only when condition is false.
|
||||
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
func containsText(s, substr string) bool {
|
||||
return core.Contains(s, substr)
|
||||
}
|
||||
|
||||
func countText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexText(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func itoaText(v int) string {
|
||||
return core.Sprint(v)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
type builderOps interface {
|
||||
WriteByte(byte) error
|
||||
WriteRune(rune) (int, error)
|
||||
WriteString(string) (int, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
type textBuilder struct {
|
||||
inner builderOps
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{inner: core.NewBuilder()}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
return b.inner.WriteByte(c)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
return b.inner.WriteRune(r)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
return b.inner.WriteString(s)
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return b.inner.String()
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
//go:build js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
type textBuilder struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{buf: make([]byte, 0, 128)}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
b.buf = append(b.buf, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
s := string(r)
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return string(b.buf)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
func translateText(ctx *Context, key string, args ...any) string {
|
||||
if ctx != nil {
|
||||
args = translationArgs(ctx, key, args)
|
||||
if ctx.service != nil {
|
||||
return ctx.service.T(key, args...)
|
||||
}
|
||||
}
|
||||
|
||||
return translateDefault(key, args...)
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func translationArgs(ctx *Context, key string, args []any) []any {
|
||||
if ctx == nil {
|
||||
return args
|
||||
}
|
||||
if !strings.HasPrefix(key, "i18n.count.") {
|
||||
return args
|
||||
}
|
||||
|
||||
count, ok := contextCount(ctx)
|
||||
if !ok {
|
||||
return args
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return []any{count}
|
||||
}
|
||||
if !isCountLike(args[0]) {
|
||||
return append([]any{count}, args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func contextCount(ctx *Context) (int, bool) {
|
||||
if ctx == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if n, ok := contextCountMap(ctx.Data); ok {
|
||||
return n, true
|
||||
}
|
||||
if n, ok := contextCountMap(ctx.Metadata); ok {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func contextCountMap(data map[string]any) (int, bool) {
|
||||
if len(data) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if v, ok := data["Count"]; ok {
|
||||
if n, ok := countInt(v); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
if v, ok := data["count"]; ok {
|
||||
if n, ok := countInt(v); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func countInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int8:
|
||||
return int(n), true
|
||||
case int16:
|
||||
return int(n), true
|
||||
case int32:
|
||||
return int(n), true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case uint:
|
||||
return int(n), true
|
||||
case uint8:
|
||||
return int(n), true
|
||||
case uint16:
|
||||
return int(n), true
|
||||
case uint32:
|
||||
return int(n), true
|
||||
case uint64:
|
||||
return int(n), true
|
||||
case float32:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
case string:
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
return 0, false
|
||||
}
|
||||
if parsed, err := strconv.Atoi(n); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isCountLike(v any) bool {
|
||||
_, ok := countInt(v)
|
||||
return ok
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func translateDefault(key string, args ...any) string {
|
||||
return i18n.T(key, args...)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
//go:build js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
func translateDefault(key string, _ ...any) string {
|
||||
return key
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue