Compare commits
66 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a924b0be4 | ||
|
|
f543f02cc1 | ||
|
|
8402485489 | ||
|
|
5784b76990 | ||
|
|
70a3096518 | ||
|
|
8abd428227 | ||
|
|
c088e5a5ac | ||
|
|
1d11472136 | ||
|
|
2e2af31c1d | ||
|
|
b9e2630da3 | ||
|
|
c2ff591ec9 | ||
|
|
60d8225a83 | ||
|
|
8e9ca0091c | ||
|
|
cb901dbb71 | ||
|
|
4a3a69e8b7 | ||
|
|
14c16b5385 | ||
|
|
1f98026d04 | ||
|
|
8386c7e57d | ||
|
|
5d13a4028b | ||
|
|
a928d01b9e | ||
|
|
12a7d2497b | ||
|
|
c63f0a2cbe | ||
|
|
c1852f86aa | ||
|
|
4ae93ce36f | ||
|
|
65c0dd3e27 | ||
|
|
f9f0aa197b | ||
|
|
714d7adc90 | ||
|
|
911071d2b0 | ||
|
|
c6fd135239 | ||
|
|
cae46f9c61 | ||
|
|
0318d73a12 | ||
| 8c7a9de546 | |||
|
|
33d9e0c516 | ||
| adc9403883 | |||
|
|
f21562c555 | ||
|
|
adcb98ee2f | ||
| 44e3478be0 | |||
|
|
11f18a24d2 | ||
| 1c61fde5fc | |||
|
|
df5035c3c4 | ||
| df19b84051 | |||
|
|
3616ad3a76 | ||
| 2a5bd5cbba | |||
|
|
b8d06460d6 | ||
|
|
0e976b3a87 | ||
|
|
8a3f28aff3 | ||
| b3f622988d | |||
|
|
c525437ed6 | ||
|
|
666e3a68c6 | ||
|
|
913bbb555a | ||
|
|
63714ec9a1 | ||
|
|
d8525255e0 | ||
|
|
0607c5b517 | ||
| ba26232b27 | |||
|
|
e532c219b9 | ||
|
|
44b3f77806 | ||
|
|
6e59bf8bf8 | ||
|
|
2d16ce9d69 | ||
|
|
176ef74dfd | ||
|
|
050f8d9967 | ||
|
|
253cc75cf6 | ||
|
|
782edd0583 | ||
|
|
27c0d1fd09 | ||
|
|
5bbacc54fe | ||
|
|
473fda5894 | ||
|
|
77dd329b01 |
56 changed files with 2827 additions and 3085 deletions
25
.core/build.yaml
Normal file
25
.core/build.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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
|
||||
20
.core/release.yaml
Normal file
20
.core/release.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: go-html
|
||||
repository: core/go-html
|
||||
|
||||
publishers: []
|
||||
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
- refactor
|
||||
exclude:
|
||||
- chore
|
||||
- docs
|
||||
- style
|
||||
- test
|
||||
- ci
|
||||
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dAppCore/build/actions/build/core@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
run-vet: "true"
|
||||
|
||||
auto-fix:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'changes_requested'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: dAppCore/build/actions/fix@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Merge PR
|
||||
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
|||
dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.core/
|
||||
|
|
|
|||
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -1,6 +1,8 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Agent instructions for `go-html`. Module path: `forge.lthn.ai/core/go-html`
|
||||
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
|
||||
|
||||
|
|
@ -9,8 +11,10 @@ go test ./... # Run all tes
|
|||
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
|
||||
```
|
||||
|
||||
|
|
@ -18,12 +22,12 @@ echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ # Codegen CLI
|
|||
|
||||
See `docs/architecture.md` for full detail. Summary:
|
||||
|
||||
- **Node interface**: `Render(ctx *Context) string` — El, Text, Raw, If, Unless, Each[T], Switch, Entitled
|
||||
- **HLCRF Layout**: Header/Left/Content/Right/Footer compositor with ARIA roles and deterministic `data-block` IDs
|
||||
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes)
|
||||
- **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 — 2.90 MB raw / 842 KB gzip
|
||||
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
|
||||
|
||||
## Server/Client Split
|
||||
|
||||
|
|
@ -32,37 +36,33 @@ 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)
|
||||
|
||||
Never import `encoding/json`, `text/template`, or `fmt` in WASM-linked code. Use string concatenation instead of `fmt.Sprintf` in `layout.go` and any other file without a `!js` guard.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `node.go` | All node types (El, Text, Raw, If, Unless, Each, Switch, Entitled) |
|
||||
| `layout.go` | HLCRF compositor |
|
||||
| `pipeline.go` | StripTags, Imprint, CompareVariants (!js only) |
|
||||
| `responsive.go` | Multi-variant breakpoint wrapper |
|
||||
| `context.go` | Rendering context (Identity, Locale, Entitlements, i18n Service) |
|
||||
| `codegen/codegen.go` | Web Component class generation |
|
||||
| `cmd/wasm/main.go` | WASM entry point (renderToString only) |
|
||||
| `cmd/codegen/main.go` | Build-time CLI for WC bundle generation |
|
||||
| `cmd/wasm/size_test.go` | WASM binary size gate (< 1 MB gzip, < 3 MB raw) |
|
||||
**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
|
||||
|
||||
- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
|
||||
- `go-i18n` and `go-inference` must be present alongside this repo for builds
|
||||
- `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)
|
||||
- All types annotated
|
||||
- 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 on Text nodes, void element handling, entitlement deny-by-default
|
||||
- Deterministic output: sorted attributes, reproducible paths
|
||||
- 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
|
||||
|
||||
No specific suffix pattern. Use table-driven subtests with `t.Run()`. Integration tests that use `Text` nodes must call `i18n.SetDefault(svc)` before rendering.
|
||||
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)
|
||||
```
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -1,8 +1,8 @@
|
|||
.PHONY: wasm test clean
|
||||
|
||||
WASM_OUT := dist/go-html.wasm
|
||||
# Raw size limit: 3MB (Go WASM has ~2MB runtime floor)
|
||||
WASM_RAW_LIMIT := 3145728
|
||||
# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth)
|
||||
WASM_RAW_LIMIT := 3670016
|
||||
# Gzip transfer size limit: 1MB (what users actually download)
|
||||
WASM_GZ_LIMIT := 1048576
|
||||
|
||||
|
|
@ -21,9 +21,9 @@ $(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
|
|||
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
|
||||
exit 1; \
|
||||
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
|
||||
echo "WARNING: raw binary exceeds 3MB ($${RAW} bytes) — check imports"; \
|
||||
echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \
|
||||
else \
|
||||
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3MB)"; \
|
||||
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# go-html
|
||||
|
||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||
|
||||
**Module**: `forge.lthn.ai/core/go-html`
|
||||
**Licence**: EUPL-1.2
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -100,7 +99,7 @@ func BenchmarkImprint_Small(b *testing.B) {
|
|||
func BenchmarkImprint_Large(b *testing.B) {
|
||||
items := make([]string, 20)
|
||||
for i := range items {
|
||||
items[i] = fmt.Sprintf("Item %d was created successfully", i)
|
||||
items[i] = "Item " + itoaText(i) + " was created successfully"
|
||||
}
|
||||
page := NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
|
|
@ -207,7 +206,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
|
|||
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
||||
nodes := make([]Node, 50)
|
||||
for i := range nodes {
|
||||
nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
|
||||
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
|
||||
}
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).
|
||||
|
|
@ -242,7 +241,7 @@ func benchEach(b *testing.B, n int) {
|
|||
items[i] = i
|
||||
}
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("item-%d", i)))
|
||||
return El("li", Raw("item-"+itoaText(i)))
|
||||
})
|
||||
ctx := NewContext()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,181 @@
|
|||
// Package main provides a build-time CLI for generating Web Component JS bundles.
|
||||
// Reads a JSON slot map from stdin, writes the generated JS to stdout.
|
||||
//go:build !js
|
||||
|
||||
// Package main provides a build-time CLI for generating Web Component bundles.
|
||||
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
goio "io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-html/codegen"
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/html/codegen"
|
||||
coreio "dappco.re/go/core/io"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
func run(r io.Reader, w io.Writer) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("codegen: reading stdin: %w", err)
|
||||
}
|
||||
|
||||
func generate(data []byte, emitTypes bool) (string, error) {
|
||||
var slots map[string]string
|
||||
if err := json.Unmarshal(data, &slots); err != nil {
|
||||
return fmt.Errorf("codegen: invalid JSON: %w", err)
|
||||
if result := core.JSONUnmarshal(data, &slots); !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
return "", log.E("codegen", "invalid JSON", err)
|
||||
}
|
||||
|
||||
js, err := codegen.GenerateBundle(slots)
|
||||
if emitTypes {
|
||||
return codegen.GenerateTypeScriptDefinitions(slots), nil
|
||||
}
|
||||
|
||||
out, err := codegen.GenerateBundle(slots)
|
||||
if err != nil {
|
||||
return "", log.E("codegen", "generate bundle", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
|
||||
data, err := goio.ReadAll(r)
|
||||
if err != nil {
|
||||
return log.E("codegen", "reading stdin", err)
|
||||
}
|
||||
|
||||
out, err := generate(data, emitTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.WriteString(w, js)
|
||||
_, err = goio.WriteString(w, out)
|
||||
if err != nil {
|
||||
return log.E("codegen", "writing output", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
|
||||
if inputPath == "" {
|
||||
return log.E("codegen", "watch mode requires -input", nil)
|
||||
}
|
||||
if outputPath == "" {
|
||||
return log.E("codegen", "watch mode requires -output", nil)
|
||||
}
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 250 * time.Millisecond
|
||||
}
|
||||
|
||||
var lastInput []byte
|
||||
for {
|
||||
input, err := readLocalFile(inputPath)
|
||||
if err != nil {
|
||||
return log.E("codegen", "reading input file", err)
|
||||
}
|
||||
|
||||
if !sameBytes(input, lastInput) {
|
||||
out, err := generate(input, emitTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLocalFile(outputPath, out); err != nil {
|
||||
return log.E("codegen", "writing output file", err)
|
||||
}
|
||||
lastInput = append(lastInput[:0], input...)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readLocalFile(path string) ([]byte, error) {
|
||||
f, err := coreio.Local.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
return goio.ReadAll(f)
|
||||
}
|
||||
|
||||
func writeLocalFile(path, content string) error {
|
||||
f, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
_, err = goio.WriteString(f, content)
|
||||
return err
|
||||
}
|
||||
|
||||
func sameBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range len(a) {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Stdin, os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
|
||||
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
|
||||
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
|
||||
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
|
||||
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
||||
flag.Parse()
|
||||
|
||||
if *emitWatch {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := coreio.Local.Open("/dev/stdin")
|
||||
if err != nil {
|
||||
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stdout, err := coreio.Local.Create("/dev/stdout")
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
if err := run(stdin, stdout, *emitTypes); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,179 @@
|
|||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
goio "io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_Good(t *testing.T) {
|
||||
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_WritesBundle_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
js := output.String()
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
}
|
||||
|
||||
func TestRun_Bad_InvalidJSON(t *testing.T) {
|
||||
input := strings.NewReader(`not json`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidJSON_Bad(t *testing.T) {
|
||||
input := core.NewReader(`not json`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid JSON")
|
||||
}
|
||||
|
||||
func TestRun_Bad_InvalidTag(t *testing.T) {
|
||||
input := strings.NewReader(`{"H":"notag"}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidTag_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"notag"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hyphen")
|
||||
}
|
||||
|
||||
func TestRun_Good_Empty(t *testing.T) {
|
||||
input := strings.NewReader(`{}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "lowercase hyphenated name")
|
||||
}
|
||||
|
||||
func TestRun_EmptySlots_Good(t *testing.T) {
|
||||
input := core.NewReader(`{}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, output.String())
|
||||
}
|
||||
|
||||
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
dts := output.String()
|
||||
assert.Contains(t, dts, "declare global")
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
||||
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
||||
}
|
||||
|
||||
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inputPath := filepath.Join(dir, "slots.json")
|
||||
outputPath := filepath.Join(dir, "bundle.js")
|
||||
|
||||
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got, err := readTextFile(outputPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
|
||||
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "watch mode requires -input")
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func writeTextFile(path, content string) error {
|
||||
f, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
_, err = goio.WriteString(f, content)
|
||||
return err
|
||||
}
|
||||
|
||||
func readTextFile(path string) (string, error) {
|
||||
f, err := coreio.Local.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
data, err := goio.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package main
|
|||
import (
|
||||
"syscall/js"
|
||||
|
||||
html "forge.lthn.ai/core/go-html"
|
||||
html "dappco.re/go/core/html"
|
||||
)
|
||||
|
||||
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
|
||||
|
|
@ -13,15 +13,19 @@ import (
|
|||
// This is intentional: the WASM module is a rendering engine for trusted content
|
||||
// produced server-side or by the application's own templates.
|
||||
func renderToString(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
if len(args) < 1 || args[0].Type() != js.TypeString {
|
||||
return ""
|
||||
}
|
||||
|
||||
variant := args[0].String()
|
||||
if variant == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx := html.NewContext()
|
||||
|
||||
if len(args) >= 2 {
|
||||
ctx.Locale = args[1].String()
|
||||
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
||||
ctx.SetLocale(args[1].String())
|
||||
}
|
||||
|
||||
layout := html.NewLayout(variant)
|
||||
|
|
|
|||
55
cmd/wasm/main_test.go
Normal file
55
cmd/wasm/main_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
func TestRenderToString_Good(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf("en-GB"),
|
||||
js.ValueOf(map[string]any{"C": "<strong>hello</strong>"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C-0"><strong>hello</strong></main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_VariantTypeGuard(t *testing.T) {
|
||||
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
|
||||
t.Fatalf("non-string variant should be empty, got %q", got)
|
||||
}
|
||||
|
||||
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
|
||||
t.Fatalf("missing variant should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf(123),
|
||||
js.ValueOf(map[string]any{"C": "x"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C-0">x</main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"forge.lthn.ai/core/go-html/codegen"
|
||||
"dappco.re/go/core/html/codegen"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
|
||||
|
|
@ -15,12 +15,13 @@ import (
|
|||
// Use cmd/codegen/ CLI instead for build-time generation.
|
||||
func buildComponentJS(slotsJSON string) (string, error) {
|
||||
var slots map[string]string
|
||||
if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
|
||||
return "", fmt.Errorf("registerComponents: %w", err)
|
||||
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
return "", log.E("buildComponentJS", "unmarshal JSON", err)
|
||||
}
|
||||
return codegen.GenerateBundle(slots)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("go-html WASM module — build with GOOS=js GOARCH=wasm")
|
||||
log.Info("go-html WASM module — build with GOOS=js GOARCH=wasm")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildComponentJS_Good(t *testing.T) {
|
||||
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
|
||||
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
|
||||
js, err := buildComponentJS(slotsJSON)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -18,7 +18,7 @@ func TestBuildComponentJS_Good(t *testing.T) {
|
|||
assert.Contains(t, js, "customElements.define")
|
||||
}
|
||||
|
||||
func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
|
||||
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
|
||||
_, err := buildComponentJS("not json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
process "dappco.re/go/core/process"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -20,33 +20,44 @@ const (
|
|||
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
||||
)
|
||||
|
||||
func TestWASMBinarySize_Good(t *testing.T) {
|
||||
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping WASM build test in short mode")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "gohtml.wasm")
|
||||
out := core.Path(dir, "gohtml.wasm")
|
||||
|
||||
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
output, err := cmd.CombinedOutput()
|
||||
factory := process.NewService(process.Options{})
|
||||
serviceValue, err := factory(core.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, ok := serviceValue.(*process.Service)
|
||||
require.True(t, ok, "process service factory returned %T", serviceValue)
|
||||
|
||||
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
|
||||
Command: "go",
|
||||
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
|
||||
Dir: ".",
|
||||
Env: []string{"GOOS=js", "GOARCH=wasm"},
|
||||
})
|
||||
require.NoError(t, err, "WASM build failed: %s", output)
|
||||
|
||||
raw, err := os.ReadFile(out)
|
||||
rawStr, err := coreio.Local.Read(out)
|
||||
require.NoError(t, err)
|
||||
rawBytes := []byte(rawStr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
buf := core.NewBuilder()
|
||||
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
|
||||
require.NoError(t, err)
|
||||
_, err = gz.Write(raw)
|
||||
_, err = gz.Write(rawBytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
|
||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len())
|
||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
|
||||
|
||||
assert.Less(t, buf.Len(), wasmGzLimit,
|
||||
"WASM gzip size %d exceeds 1MB limit", buf.Len())
|
||||
assert.Less(t, len(raw), wasmRawLimit,
|
||||
"WASM raw size %d exceeds 3MB limit", len(raw))
|
||||
assert.Less(t, len(rawBytes), wasmRawLimit,
|
||||
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import "testing"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,40 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sort"
|
||||
"text/template"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// isValidCustomElementTag reports whether tag is a safe custom element name.
|
||||
// The generator rejects values that would fail at customElements.define() time.
|
||||
func isValidCustomElementTag(tag string) bool {
|
||||
if tag == "" || !core.Contains(tag, "-") {
|
||||
return false
|
||||
}
|
||||
|
||||
if tag[0] < 'a' || tag[0] > 'z' {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range len(tag) {
|
||||
ch := tag[i]
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
case ch >= '0' && ch <= '9':
|
||||
case ch == '-' || ch == '.' || ch == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// wcTemplate is the Web Component class template.
|
||||
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
|
||||
// DOM API using trusted go-html codegen output (never user input).
|
||||
|
|
@ -29,12 +58,13 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
|
|||
}`))
|
||||
|
||||
// GenerateClass produces a JS class definition for a custom element.
|
||||
// Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
func GenerateClass(tag, slot string) (string, error) {
|
||||
if !strings.Contains(tag, "-") {
|
||||
return "", fmt.Errorf("codegen: custom element tag %q must contain a hyphen", tag)
|
||||
if !isValidCustomElementTag(tag) {
|
||||
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
||||
}
|
||||
var b strings.Builder
|
||||
err := wcTemplate.Execute(&b, struct {
|
||||
b := core.NewBuilder()
|
||||
err := wcTemplate.Execute(b, struct {
|
||||
ClassName, Tag, Slot string
|
||||
}{
|
||||
ClassName: TagToClassName(tag),
|
||||
|
|
@ -42,22 +72,24 @@ func GenerateClass(tag, slot string) (string, error) {
|
|||
Slot: slot,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("codegen: template exec: %w", err)
|
||||
return "", log.E("codegen.GenerateClass", "template exec", err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// GenerateRegistration produces the customElements.define() call.
|
||||
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
func GenerateRegistration(tag, className string) string {
|
||||
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
|
||||
return `customElements.define("` + tag + `", ` + className + `);`
|
||||
}
|
||||
|
||||
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
// Usage example: className := TagToClassName("nav-bar")
|
||||
func TagToClassName(tag string) string {
|
||||
var b strings.Builder
|
||||
for p := range strings.SplitSeq(tag, "-") {
|
||||
b := core.NewBuilder()
|
||||
for _, p := range core.Split(tag, "-") {
|
||||
if len(p) > 0 {
|
||||
b.WriteString(strings.ToUpper(p[:1]))
|
||||
b.WriteString(core.Upper(p[:1]))
|
||||
b.WriteString(p[1:])
|
||||
}
|
||||
}
|
||||
|
|
@ -66,11 +98,18 @@ func TagToClassName(tag string) string {
|
|||
|
||||
// GenerateBundle produces all WC class definitions and registrations
|
||||
// for a set of HLCRF slot assignments.
|
||||
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
func GenerateBundle(slots map[string]string) (string, error) {
|
||||
seen := make(map[string]bool)
|
||||
var b strings.Builder
|
||||
b := core.NewBuilder()
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for slot, tag := range slots {
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
|
|
@ -78,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
|
|||
|
||||
cls, err := GenerateClass(tag, slot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
||||
}
|
||||
b.WriteString(cls)
|
||||
b.WriteByte('\n')
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
|
|
@ -8,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateClass_Good(t *testing.T) {
|
||||
func TestGenerateClass_ValidTag_Good(t *testing.T) {
|
||||
js, err := GenerateClass("photo-grid", "C")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||
|
|
@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) {
|
|||
assert.Contains(t, js, "photo-grid")
|
||||
}
|
||||
|
||||
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
|
||||
func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
|
||||
_, err := GenerateClass("invalid", "C")
|
||||
assert.Error(t, err, "custom element names must contain a hyphen")
|
||||
|
||||
_, err = GenerateClass("Nav-Bar", "C")
|
||||
assert.Error(t, err, "custom element names must be lowercase")
|
||||
|
||||
_, err = GenerateClass("nav bar", "C")
|
||||
assert.Error(t, err, "custom element names must reject spaces")
|
||||
}
|
||||
|
||||
func TestGenerateRegistration_Good(t *testing.T) {
|
||||
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
|
||||
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Contains(t, js, `"photo-grid"`)
|
||||
assert.Contains(t, js, "PhotoGrid")
|
||||
}
|
||||
|
||||
func TestTagToClassName_Good(t *testing.T) {
|
||||
func TestTagToClassName_KebabCase_Good(t *testing.T) {
|
||||
tests := []struct{ tag, want string }{
|
||||
{"photo-grid", "PhotoGrid"},
|
||||
{"nav-breadcrumb", "NavBreadcrumb"},
|
||||
|
|
@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateBundle_Good(t *testing.T) {
|
||||
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
"F": "nav-bar",
|
||||
}
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "main-content",
|
||||
}
|
||||
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
|
||||
alpha := strings.Index(js, "class AlphaPanel")
|
||||
main := strings.Index(js, "class MainContent")
|
||||
zed := strings.Index(js, "class ZedPanel")
|
||||
|
||||
assert.NotEqual(t, -1, alpha)
|
||||
assert.NotEqual(t, -1, main)
|
||||
assert.NotEqual(t, -1, zed)
|
||||
assert.Less(t, alpha, main)
|
||||
assert.Less(t, main, zed)
|
||||
assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 3, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "alpha-panel",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
|
||||
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
|
||||
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
|
||||
assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
|
||||
assert.Contains(t, dts, "export {};")
|
||||
assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "Nav-Bar",
|
||||
"F": "nav bar",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.NotContains(t, dts, "Nav-Bar")
|
||||
assert.NotContains(t, dts, "nav bar")
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
13
codegen/doc.go
Normal file
13
codegen/doc.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package codegen generates Web Component bundles for go-html slot maps.
|
||||
//
|
||||
// Use it at build time, or through the cmd/codegen CLI:
|
||||
//
|
||||
// bundle, err := GenerateBundle(map[string]string{
|
||||
// "H": "site-header",
|
||||
// "C": "app-main",
|
||||
// })
|
||||
package codegen
|
||||
61
codegen/typescript.go
Normal file
61
codegen/typescript.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
|
||||
// a set of custom elements generated from HLCRF slot assignments.
|
||||
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
|
||||
func GenerateTypeScriptDefinitions(slots map[string]string) string {
|
||||
seen := make(map[string]bool)
|
||||
declared := make(map[string]bool)
|
||||
b := core.NewBuilder()
|
||||
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteString("declare global {\n")
|
||||
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !isValidCustomElementTag(tag) || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
b.WriteString(" \"")
|
||||
b.WriteString(tag)
|
||||
b.WriteString("\": ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(";\n")
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !seen[tag] || declared[tag] {
|
||||
continue
|
||||
}
|
||||
declared[tag] = true
|
||||
b.WriteString("export declare class ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(" extends HTMLElement {\n")
|
||||
b.WriteString(" connectedCallback(): void;\n")
|
||||
b.WriteString(" render(html: string): void;\n")
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("export {};\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
74
context.go
74
context.go
|
|
@ -1,27 +1,81 @@
|
|||
package html
|
||||
|
||||
import i18n "forge.lthn.ai/core/go-i18n"
|
||||
// Translator provides Text() lookups for a rendering context.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator)
|
||||
//
|
||||
// The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
// can provide any implementation with the same T() method.
|
||||
type Translator interface {
|
||||
T(key string, args ...any) string
|
||||
}
|
||||
|
||||
// Context carries rendering state through the node tree.
|
||||
// Usage example: ctx := NewContext()
|
||||
type Context struct {
|
||||
Identity string
|
||||
Locale string
|
||||
Entitlements func(feature string) bool
|
||||
Data map[string]any
|
||||
service *i18n.Service
|
||||
service Translator
|
||||
}
|
||||
|
||||
func applyLocaleToService(svc Translator, locale string) {
|
||||
if svc == nil || locale == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
|
||||
base := locale
|
||||
for i := 0; i < len(base); i++ {
|
||||
if base[i] == '-' || base[i] == '_' {
|
||||
base = base[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = setter.SetLanguage(base)
|
||||
}
|
||||
}
|
||||
|
||||
// NewContext creates a new rendering context with sensible defaults.
|
||||
func NewContext() *Context {
|
||||
return &Context{
|
||||
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
|
||||
func NewContext(locale ...string) *Context {
|
||||
ctx := &Context{
|
||||
Data: make(map[string]any),
|
||||
}
|
||||
if len(locale) > 0 {
|
||||
ctx.SetLocale(locale[0])
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// NewContextWithService creates a rendering context backed by a specific i18n service.
|
||||
func NewContextWithService(svc *i18n.Service) *Context {
|
||||
return &Context{
|
||||
Data: make(map[string]any),
|
||||
service: svc,
|
||||
}
|
||||
// NewContextWithService creates a rendering context backed by a specific translator.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
|
||||
func NewContextWithService(svc Translator, locale ...string) *Context {
|
||||
ctx := NewContext(locale...)
|
||||
ctx.SetService(svc)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetService swaps the translator used by the context.
|
||||
// Usage example: ctx.SetService(myTranslator)
|
||||
func (ctx *Context) SetService(svc Translator) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.service = svc
|
||||
applyLocaleToService(svc, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetLocale updates the context locale and reapplies it to the active translator.
|
||||
// Usage example: ctx.SetLocale("en-GB")
|
||||
func (ctx *Context) SetLocale(locale string) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.Locale = locale
|
||||
applyLocaleToService(ctx.service, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
|
|
|||
90
context_test.go
Normal file
90
context_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestNewContext_OptionalLocale_Good(t *testing.T) {
|
||||
ctx := NewContext("en-GB")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContext returned nil")
|
||||
}
|
||||
if ctx.Locale != "en-GB" {
|
||||
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
|
||||
}
|
||||
if ctx.Data == nil {
|
||||
t.Fatal("NewContext should initialise Data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContextWithService returned nil")
|
||||
}
|
||||
if ctx.Locale != "fr-FR" {
|
||||
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
|
||||
}
|
||||
if ctx.service == nil {
|
||||
t.Fatal("NewContextWithService should set translator service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContext("fr-FR")
|
||||
|
||||
if got := ctx.SetService(svc); got != ctx {
|
||||
t.Fatal("SetService should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetService(nil); got != nil {
|
||||
t.Fatal("SetService on nil context should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
|
||||
if got := ctx.SetLocale("fr-FR"); got != ctx {
|
||||
t.Fatal("SetLocale should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetLocale("en-GB"); got != nil {
|
||||
t.Fatal("SetLocale on nil context should return nil")
|
||||
}
|
||||
}
|
||||
12
doc.go
Normal file
12
doc.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package html renders semantic HTML from composable node trees.
|
||||
//
|
||||
// A typical page combines Layout, El, Text, and Render:
|
||||
//
|
||||
// page := NewLayout("HCF").
|
||||
// H(El("h1", Text("page.title"))).
|
||||
// C(El("main", Text("page.body"))).
|
||||
// F(El("small", Text("page.footer")))
|
||||
// out := Render(page, NewContext())
|
||||
package html
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
---
|
||||
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 an HLCRF DOM compositor with grammar pipeline integration. It provides a pure-Go, type-safe HTML rendering library designed for server-side generation with an optional lightweight WASM client module.
|
||||
|
||||
Module path: `forge.lthn.ai/core/go-html`
|
||||
`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
|
||||
|
||||
All renderable units implement a single interface:
|
||||
Every renderable unit implements one method:
|
||||
|
||||
```go
|
||||
type Node interface {
|
||||
|
|
@ -14,204 +17,289 @@ type Node interface {
|
|||
}
|
||||
```
|
||||
|
||||
Every node type is a private struct with a public constructor. The API surface is intentionally small: nine public constructors plus `Attr()` and `Render()` helpers.
|
||||
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 | Description |
|
||||
|-------------|-------------|
|
||||
| `El(tag, ...Node)` | HTML element with children |
|
||||
| `Attr(Node, key, value)` | Set attribute on an El node; chainable |
|
||||
| `Text(key, ...any)` | Translated, HTML-escaped text via go-i18n |
|
||||
| `Raw(content)` | Unescaped trusted content |
|
||||
| `If(cond, Node)` | Conditional render |
|
||||
| `Unless(cond, Node)` | Inverse conditional render |
|
||||
| `Each[T](items, fn)` | Type-safe iteration with generics |
|
||||
| `Switch(selector, cases)` | Runtime dispatch to named cases |
|
||||
| `Entitled(feature, Node)` | Entitlement-gated render; deny-by-default |
|
||||
| Constructor | Behaviour |
|
||||
|-------------|-----------|
|
||||
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
|
||||
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. |
|
||||
| `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
|
||||
### Safety Guarantees
|
||||
|
||||
- `Text` nodes are always HTML-escaped. XSS via user-supplied strings fed through `Text()` is not possible.
|
||||
- `Raw` is an explicit escape hatch for trusted content only. Its name signals intent.
|
||||
- `Entitled` returns an empty string when no entitlement function is set on the context. Access is denied by default, not granted.
|
||||
- `El` attributes are sorted alphabetically before output, producing deterministic HTML regardless of insertion order.
|
||||
- Void elements (`br`, `img`, `input`, etc.) never emit a closing tag.
|
||||
|
||||
## HLCRF Layout
|
||||
|
||||
The `Layout` type is a compositor for five named slots: **H**eader, **L**eft, **C**ontent, **R**ight, **F**ooter. Each slot maps to a specific semantic HTML element and ARIA role:
|
||||
|
||||
| Slot | Element | ARIA role |
|
||||
|------|---------|-----------|
|
||||
| H | `<header>` | `banner` |
|
||||
| L | `<aside>` | `complementary` |
|
||||
| C | `<main>` | `main` |
|
||||
| R | `<aside>` | `complementary` |
|
||||
| F | `<footer>` | `contentinfo` |
|
||||
|
||||
A layout variant string selects which slots are rendered and in which order:
|
||||
|
||||
```go
|
||||
NewLayout("HLCRF") // all five slots
|
||||
NewLayout("HCF") // header, content, footer — no sidebars
|
||||
NewLayout("C") // content only
|
||||
```
|
||||
|
||||
Each rendered slot receives a deterministic `data-block` attribute encoding its position in the tree. The root layout produces IDs in the form `{slot}-0` (e.g., `H-0`, `C-0`). Nested layouts extend the parent's block ID as a path prefix: a `Layout` placed inside the `L` slot of a root layout will produce inner slot IDs like `L-0-H-0`, `L-0-C-0`.
|
||||
|
||||
This path scheme is computed without `fmt.Sprintf` — using simple string concatenation — to keep `fmt` out of the WASM import graph.
|
||||
|
||||
### Nested layouts
|
||||
|
||||
`Layout` implements `Node`, so it can be placed inside any slot of another layout. At render time, nested layouts are cloned and their `path` field is set to the parent's block ID. This clone-on-render approach avoids shared mutation and is safe for concurrent use.
|
||||
|
||||
```go
|
||||
inner := NewLayout("HCF").H(Raw("nav")).C(Raw("body")).F(Raw("links"))
|
||||
outer := NewLayout("HLCRF").H(Raw("top")).L(inner).C(Raw("main")).F(Raw("foot"))
|
||||
```
|
||||
|
||||
### Fluent builder
|
||||
|
||||
All slot methods return the `*Layout` for chaining. Multiple nodes may be appended to the same slot across multiple calls:
|
||||
|
||||
```go
|
||||
NewLayout("HCF").
|
||||
H(El("h1", Text("Title"))).
|
||||
C(El("p", Text("Content")), Raw("<hr>")).
|
||||
F(El("small", Text("Copyright")))
|
||||
```
|
||||
|
||||
## Responsive Compositor
|
||||
|
||||
`Responsive` wraps multiple named `Layout` variants for breakpoint-aware rendering. Each variant renders inside a `<div data-variant="name">` container, giving CSS media queries or JavaScript a stable hook for show/hide logic.
|
||||
|
||||
```go
|
||||
NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF")...).
|
||||
Variant("tablet", NewLayout("HCF")...).
|
||||
Variant("mobile", NewLayout("C")...)
|
||||
```
|
||||
|
||||
`Responsive` itself implements `Node` and may be passed to `Imprint()` for cross-variant semantic analysis.
|
||||
|
||||
Note: `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a layout first.
|
||||
- **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
|
||||
|
||||
`Context` carries per-request state through the entire node tree:
|
||||
The `Context` struct carries per-request state through the node tree during rendering:
|
||||
|
||||
```go
|
||||
type Context struct {
|
||||
Identity string
|
||||
Locale string
|
||||
Entitlements func(feature string) bool
|
||||
Data map[string]any
|
||||
service *i18n.Service // private; set via NewContextWithService()
|
||||
Identity string // e.g. user ID or session identifier
|
||||
Locale string // BCP 47 locale string
|
||||
Entitlements func(feature string) bool // feature gate callback
|
||||
Data map[string]any // arbitrary per-request data
|
||||
service Translator // unexported; set via constructor
|
||||
}
|
||||
```
|
||||
|
||||
The `service` field is intentionally unexported. Custom i18n adapter injection requires `NewContextWithService(svc)`. This prevents callers from setting it inconsistently after construction.
|
||||
Two constructors are provided:
|
||||
|
||||
When `ctx.service` is nil, `Text` nodes fall back to the global `i18n.T()` default service.
|
||||
- `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`.
|
||||
|
||||
## Grammar Pipeline
|
||||
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.
|
||||
|
||||
The grammar pipeline is a server-side-only feature. It is guarded with `//go:build !js` and absent from all WASM builds.
|
||||
## 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 | `<aside>` | `complementary` | `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 follow the pattern `{slot}-0`:
|
||||
|
||||
```html
|
||||
<header role="banner" data-block="H-0">...</header>
|
||||
<main role="main" data-block="C-0">...</main>
|
||||
<footer role="contentinfo" data-block="F-0">...</footer>
|
||||
```
|
||||
|
||||
Block IDs are constructed by simple string concatenation (no `fmt.Sprintf`) to keep the `fmt` package out of the WASM import graph.
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts are cloned and their internal `path` field is set to the parent's block ID as a prefix. This produces hierarchical paths:
|
||||
|
||||
```go
|
||||
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-H-0`, `L-0-C-0`, `L-0-F-0`. At 10 levels of nesting, the deepest block ID becomes `C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0` (tested in `edge_test.go`).
|
||||
|
||||
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("C-0-C-0-C-0") // returns ['C', 'C', 'C']
|
||||
ParseBlockID("H-0") // returns ['H']
|
||||
ParseBlockID("") // returns nil
|
||||
```
|
||||
|
||||
This enables server-side or client-side code to locate a specific block in the rendered tree by its structural path.
|
||||
|
||||
## Responsive Compositor
|
||||
|
||||
`Responsive` 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. 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
|
||||
|
||||
`StripTags(html string) string` converts rendered HTML to plain text. Tag boundaries are collapsed to single spaces; the result is trimmed. The implementation is a single-pass rune scanner: no regex, no allocations beyond the output builder. It does not attempt to elide `<script>` or `<style>` content because `go-html` never generates those elements.
|
||||
```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
|
||||
|
||||
`Imprint(node Node, ctx *Context) reversal.GrammarImprint` runs the full render-to-analysis pipeline:
|
||||
```go
|
||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint
|
||||
```
|
||||
|
||||
1. Call `node.Render(ctx)` to produce HTML.
|
||||
2. Pass HTML through `StripTags` to extract plain text.
|
||||
3. Pass plain text through `go-i18n/reversal.Tokeniser` to produce a token sequence.
|
||||
4. Wrap tokens in a `reversal.GrammarImprint` for structural analysis.
|
||||
Runs the full render-to-analysis pipeline:
|
||||
|
||||
The resulting `GrammarImprint` exposes `TokenCount`, `UniqueVerbs`, and a `Similar()` method for pairwise semantic similarity scoring. This bridges the rendering layer to the privacy and analytics layers of the Lethean stack.
|
||||
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
|
||||
|
||||
`CompareVariants(r *Responsive, ctx *Context) map[string]float64` runs `Imprint` on each named layout variant in a `Responsive` and returns pairwise similarity scores. Keys are `"name1:name2"`. This enables detection of semantically divergent responsive variants — for example, a mobile layout that strips critical information that appears in the desktop variant.
|
||||
```go
|
||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64
|
||||
```
|
||||
|
||||
## Server/Client Split
|
||||
Runs `Imprint` independently on each named layout variant in a `Responsive` and returns pairwise similarity scores. Keys are formatted as `"name1:name2"`.
|
||||
|
||||
The binary split is enforced by Go build tags.
|
||||
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.
|
||||
|
||||
| File | Build tag | Reason for exclusion from WASM |
|
||||
|------|-----------|-------------------------------|
|
||||
| `pipeline.go` | `//go:build !js` | Imports `go-i18n/reversal` (~250 KB gzip) |
|
||||
| `cmd/wasm/register.go` | `//go:build !js` | Imports `encoding/json` (~200 KB gzip) and `text/template` (~125 KB gzip) |
|
||||
|
||||
The WASM binary includes only: node types, layout, responsive, context, render, path, and go-i18n core (translation). No codegen, no pipeline, no JSON, no templates, no `fmt`.
|
||||
A single-variant `Responsive` produces an empty score map (no pairs to compare).
|
||||
|
||||
## WASM Module
|
||||
|
||||
The WASM entry point is `cmd/wasm/main.go`, compiled with `GOOS=js GOARCH=wasm`.
|
||||
|
||||
It exposes a single JavaScript function on `window.gohtml`:
|
||||
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)
|
||||
```
|
||||
|
||||
- `variant`: HLCRF variant string, e.g. `"HCF"`.
|
||||
- `locale`: BCP 47 locale string for i18n, e.g. `"en-GB"`.
|
||||
- `slots`: object with optional keys `H`, `L`, `C`, `R`, `F` containing HTML strings.
|
||||
**Parameters:**
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
### Size gate
|
||||
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.
|
||||
|
||||
`cmd/wasm/size_test.go` contains `TestWASMBinarySize_Good`, a build-gated test that:
|
||||
### Size Budget
|
||||
|
||||
1. Builds the WASM binary with `-ldflags=-s -w`.
|
||||
2. Gzip-compresses the output at best compression.
|
||||
3. Asserts the compressed size is below 1,048,576 bytes (1 MB).
|
||||
4. Asserts the raw size is below 3,145,728 bytes (3 MB).
|
||||
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
|
||||
|
||||
This test is skipped under `go test -short`. It is guarded with `//go:build !js` so it does not run within the WASM environment itself. Current measured size: 2.90 MB raw, 842 KB gzip.
|
||||
| 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` is a build-time tool for generating Web Component JavaScript bundles from HLCRF slot assignments. It reads a JSON slot map from stdin and writes the generated JS to stdout.
|
||||
`cmd/codegen/main.go` generates Web Component JavaScript bundles from HLCRF slot assignments at build time:
|
||||
|
||||
```bash
|
||||
echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
||||
echo '{"H":"nav-bar","C":"main-content","F":"page-footer"}' | go run ./cmd/codegen/ > components.js
|
||||
```
|
||||
|
||||
The `codegen` package generates ES2022 class definitions with closed Shadow DOM. The generated pattern per component:
|
||||
The `codegen` package (`codegen/codegen.go`) generates ES2022 class definitions with closed Shadow DOM. For each custom element tag, it produces:
|
||||
|
||||
- A class extending `HTMLElement` with a private `#shadow` field.
|
||||
- `constructor()` attaches a closed shadow root (`mode: "closed"`).
|
||||
- `connectedCallback()` dispatches a `wc-ready` custom event with the tag name and slot.
|
||||
- `render(html)` sets shadow content from a `<template>` clone.
|
||||
- `customElements.define()` registration.
|
||||
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.
|
||||
|
||||
Closed Shadow DOM provides style isolation. Content is set via the DOM API, never via `innerHTML` directly on the element.
|
||||
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`.
|
||||
|
||||
Tag names must contain a hyphen (Web Components specification requirement). `TagToClassName()` converts kebab-case tags to PascalCase class names: `nav-bar` becomes `NavBar`.
|
||||
`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, not at runtime.
|
||||
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.
|
||||
|
||||
## Block ID Path Scheme
|
||||
|
||||
`path.go` exports `ParseBlockID(id string) []byte`, which extracts the slot letter sequence from a `data-block` attribute value.
|
||||
|
||||
Format: slots are separated by `-0-`. The sequence `L-0-C-0` decodes to `['L', 'C']`, meaning the content slot of a layout nested inside the left slot.
|
||||
|
||||
This scheme is deterministic and human-readable. It enables server-side or client-side code to locate a specific block in the rendered tree by path.
|
||||
|
||||
## Dependency Graph
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
go-html
|
||||
├── forge.lthn.ai/core/go-i18n (direct, all builds)
|
||||
│ └── forge.lthn.ai/core/go-inference (indirect)
|
||||
├── forge.lthn.ai/core/go-i18n/reversal (server builds only, !js)
|
||||
└── github.com/stretchr/testify (test only)
|
||||
```
|
||||
Server-Side
|
||||
+-------------------+
|
||||
| |
|
||||
Node tree -------> Render(ctx) |-----> HTML string
|
||||
| |
|
||||
| StripTags() |-----> plain text
|
||||
| |
|
||||
| Imprint() |-----> GrammarImprint
|
||||
| | .TokenCount
|
||||
| CompareVariants()| .UniqueVerbs
|
||||
| | .Similar()
|
||||
+-------------------+
|
||||
|
||||
Both `go-i18n` and `go-html` are developed in parallel. The `go.mod` uses a `replace` directive pointing to `../go-i18n`. Both repositories must be present on the local filesystem for builds and tests.
|
||||
WASM Client
|
||||
+-------------------+
|
||||
| |
|
||||
JS call ---------> renderToString() |-----> HTML string
|
||||
(variant, locale, | |
|
||||
slots object) +-------------------+
|
||||
|
||||
Build Time
|
||||
+-------------------+
|
||||
| |
|
||||
JSON slot map ---> cmd/codegen/ |-----> Web Component JS
|
||||
(stdin) | | (stdout)
|
||||
+-------------------+
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,36 +1,48 @@
|
|||
---
|
||||
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.25 or later (Go workspace required).
|
||||
- `go-i18n` repository cloned alongside this one: `../go-i18n` relative to the repository root. The `go.mod` `replace` directive points there.
|
||||
- `go-inference` also resolved via `replace` directive at `../go-inference`. It is an indirect dependency pulled in by `go-i18n`.
|
||||
- `testify` is the only external test dependency; it is fetched by the Go module system.
|
||||
- **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 a standard Go installation with `GOOS=js GOARCH=wasm` cross-compilation support, which is included in all official Go distributions.
|
||||
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)
|
||||
├── cmd/
|
||||
│ ├── codegen/
|
||||
│ │ └── main.go Build-time CLI (stdin JSON → stdout JS)
|
||||
│ └── wasm/
|
||||
│ ├── main.go WASM entry point (js+wasm build only)
|
||||
│ ├── register.go buildComponentJS helper (!js only)
|
||||
│ └── size_test.go WASM binary size gate test (!js only)
|
||||
└── docs/
|
||||
└── plans/ Phase design documents (historical)
|
||||
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
|
||||
|
|
@ -40,18 +52,32 @@ go-html/
|
|||
go test ./...
|
||||
|
||||
# Single test by name
|
||||
go test -run TestWASMBinarySize_Good ./cmd/wasm/
|
||||
go test -run TestElNode_Render .
|
||||
|
||||
# Skip slow WASM build test
|
||||
# Skip the slow WASM build test
|
||||
go test -short ./...
|
||||
|
||||
# Tests with verbose output
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Tests for a specific package
|
||||
go test ./codegen/
|
||||
go test ./cmd/codegen/
|
||||
go test ./cmd/wasm/
|
||||
```
|
||||
|
||||
Tests use `testify` assert and require helpers. Test names follow Go's standard `TestFunctionName` convention. Subtests use `t.Run()` with descriptive names.
|
||||
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.
|
||||
|
||||
The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess and is therefore slow. It is skipped automatically under `-short`. It is also guarded with `//go:build !js` so it cannot run under `GOARCH=wasm`.
|
||||
### 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
|
||||
|
||||
|
|
@ -60,26 +86,30 @@ The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a
|
|||
go test -bench . ./...
|
||||
|
||||
# Specific benchmark
|
||||
go test -bench BenchmarkRender_FullPage ./...
|
||||
go test -bench BenchmarkRender_FullPage .
|
||||
|
||||
# With memory allocations
|
||||
# With memory allocation statistics
|
||||
go test -bench . -benchmem ./...
|
||||
|
||||
# Fixed iteration count
|
||||
# Extended benchmark duration
|
||||
go test -bench . -benchtime=5s ./...
|
||||
```
|
||||
|
||||
Benchmarks are organised by operation:
|
||||
Available benchmark groups:
|
||||
|
||||
| Group | Variants |
|
||||
|-------|---------|
|
||||
| `BenchmarkRender_*` | Depth 1, 3, 5, 7 trees; full page |
|
||||
| `BenchmarkLayout_*` | Content-only, HCF, HLCRF, nested, many children |
|
||||
|-------|----------|
|
||||
| `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
|
||||
|
||||
|
|
@ -87,36 +117,51 @@ Benchmarks are organised by operation:
|
|||
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
|
||||
```
|
||||
|
||||
Strip flags (`-s -w`) are required. Without them the binary is approximately 50% larger.
|
||||
Strip flags (`-s -w`) are required. Without them, the binary is approximately 50% larger.
|
||||
|
||||
The Makefile target `make wasm` performs the build and measures the gzip size:
|
||||
The Makefile `wasm` target performs the build and checks the output size:
|
||||
|
||||
```bash
|
||||
make wasm
|
||||
```
|
||||
|
||||
The Makefile enforces a 1 MB gzip limit (`WASM_GZ_LIMIT = 1048576`). The build fails if this limit is exceeded.
|
||||
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 size manually:
|
||||
To verify the gzip size manually:
|
||||
|
||||
```bash
|
||||
gzip -c -9 gohtml.wasm | wc -c
|
||||
```
|
||||
|
||||
Current measured output: 2.90 MB raw, 842 KB gzip.
|
||||
|
||||
## Codegen CLI
|
||||
|
||||
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout. It is a build-time tool, not intended for runtime use.
|
||||
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout:
|
||||
|
||||
```bash
|
||||
# Generate components for a two-slot layout
|
||||
echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
|
||||
| go run ./cmd/codegen/ \
|
||||
> components.js
|
||||
```
|
||||
|
||||
The JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). The values are custom element tag names (must contain a hyphen). Duplicate tag values are deduplicated.
|
||||
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:
|
||||
|
||||
|
|
@ -130,7 +175,7 @@ go test ./cmd/codegen/
|
|||
go vet ./...
|
||||
```
|
||||
|
||||
The codebase passes `go vet` with no warnings.
|
||||
The repository also includes a `.golangci.yml` configuration for `golangci-lint`.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
|
|
@ -138,37 +183,39 @@ The codebase passes `go vet` with no warnings.
|
|||
|
||||
UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used.
|
||||
|
||||
### Types
|
||||
### Type Annotations
|
||||
|
||||
All exported and unexported functions carry full parameter and return type annotations. The `any` alias is used in preference to `interface{}`.
|
||||
|
||||
### Error handling
|
||||
### HTML Safety
|
||||
|
||||
Errors are wrapped with context using `fmt.Errorf("pkg.Function: %w", err)`. The codegen package prefixes all errors with `codegen:`.
|
||||
|
||||
### HTML safety
|
||||
|
||||
- Use `Text()` for any user-supplied or translated content. It escapes HTML.
|
||||
- Use `Raw()` only for content you control or have sanitised upstream.
|
||||
- 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. Attributes are sorted before rendering. `map` iteration in `codegen.GenerateBundle()` may produce non-deterministic class order across runs — this is acceptable because Web Component registration order does not affect correctness.
|
||||
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
|
||||
### 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`. Do not use the older `// +build` syntax.
|
||||
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 files carry the EUPL-1.2 SPDX identifier:
|
||||
All new files should carry the EUPL-1.2 SPDX identifier:
|
||||
|
||||
```go
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
```
|
||||
|
||||
### Commit format
|
||||
### Commit Format
|
||||
|
||||
Conventional commits with lowercase type and optional scope:
|
||||
|
||||
|
|
@ -179,7 +226,7 @@ test: add edge case for Unicode surrogate pairs
|
|||
docs: update architecture with pipeline diagram
|
||||
```
|
||||
|
||||
Commits include a co-author trailer:
|
||||
Include a co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>
|
||||
|
|
@ -187,7 +234,7 @@ Co-Authored-By: Virgil <virgil@lethean.io>
|
|||
|
||||
## Test Patterns
|
||||
|
||||
### Standard unit test
|
||||
### Standard Unit Test
|
||||
|
||||
```go
|
||||
func TestElNode_Render(t *testing.T) {
|
||||
|
|
@ -201,32 +248,31 @@ func TestElNode_Render(t *testing.T) {
|
|||
}
|
||||
```
|
||||
|
||||
### Table-driven subtest
|
||||
### Table-Driven Subtest
|
||||
|
||||
```go
|
||||
func TestStripTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
func TestStripTags_Unicode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"plain", "hello", "hello"},
|
||||
{"single tag", "<p>hello</p>", "hello"},
|
||||
{"nested", "<div><p>a</p><p>b</p></div>", "a b"},
|
||||
{"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 _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := StripTags(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("StripTags(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
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
|
||||
### Integration Test with i18n
|
||||
|
||||
```go
|
||||
func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||
|
|
@ -247,11 +293,22 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
|||
}
|
||||
```
|
||||
|
||||
Integration tests that exercise the full pipeline (`Imprint`, `CompareVariants`) must initialise the i18n default service before calling `Text` nodes. The `bench_test.go` `init()` function does this for benchmarks; individual integration tests must do so explicitly.
|
||||
### 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 when given unrecognised slot letters. There is no warning or error. Valid slot letters are `H`, `L`, `C`, `R`, `F`.
|
||||
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout.
|
||||
- `Context.service` is private. Custom i18n adapter injection requires `NewContextWithService()`. There is no way to set or swap the service after construction.
|
||||
- `cmd/wasm/main.go` has no integration test for the JS exports. The `size_test.go` file tests binary size only; it does not exercise `renderToString` behaviour.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
|
|||
|
||||
### Size gate test (`aae5d21`)
|
||||
|
||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||
|
||||
- Gzip size < 1,048,576 bytes (1 MB).
|
||||
- Raw size < 3,145,728 bytes (3 MB).
|
||||
|
|
@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
|
|||
|
||||
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
||||
|
||||
4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
|
||||
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
|
||||
|
||||
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
|
||||
|
||||
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
|
||||
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
|
||||
|
||||
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
||||
|
||||
|
|
@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for
|
|||
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
||||
|
||||
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
|
||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||
- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
|
||||
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
||||
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
||||
|
|
|
|||
80
docs/index.md
Normal file
80
docs/index.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
title: go-html
|
||||
description: HLCRF DOM compositor with grammar pipeline integration for type-safe server-side HTML generation and optional WASM client rendering.
|
||||
---
|
||||
|
||||
# go-html
|
||||
|
||||
`go-html` is a pure-Go library for building HTML documents as type-safe node trees and rendering them to string output. It provides a five-slot layout compositor (Header, Left, Content, Right, Footer -- abbreviated HLCRF), a responsive multi-variant wrapper, a server-side grammar analysis pipeline, a Web Component code generator, and an optional WASM module for client-side rendering.
|
||||
|
||||
**Module path:** `forge.lthn.ai/core/go-html`
|
||||
**Go version:** 1.26
|
||||
**Licence:** EUPL-1.2
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import html "forge.lthn.ai/core/go-html"
|
||||
|
||||
func main() {
|
||||
page := html.NewLayout("HCF").
|
||||
H(html.El("nav", html.Text("nav.label"))).
|
||||
C(html.El("article",
|
||||
html.El("h1", html.Text("page.title")),
|
||||
html.Each(items, func(item Item) html.Node {
|
||||
return html.El("li", html.Text(item.Name))
|
||||
}),
|
||||
)).
|
||||
F(html.El("footer", html.Text("footer.copyright")))
|
||||
|
||||
output := page.Render(html.NewContext())
|
||||
}
|
||||
```
|
||||
|
||||
This builds a Header-Content-Footer layout with semantic HTML elements (`<header>`, `<main>`, `<footer>`), ARIA roles, and deterministic `data-block` path identifiers. Text nodes pass through the `go-i18n` translation layer and are HTML-escaped by default.
|
||||
|
||||
## Package Layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, and `Role` helpers |
|
||||
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
||||
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
|
||||
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
|
||||
| `render.go` | `Render()` convenience function |
|
||||
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
|
||||
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
|
||||
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
|
||||
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
|
||||
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), and accessibility helpers (`AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, `Role`).
|
||||
|
||||
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
|
||||
|
||||
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
|
||||
|
||||
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.
|
||||
|
||||
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
forge.lthn.ai/core/go-html
|
||||
forge.lthn.ai/core/go-i18n (direct, all builds)
|
||||
forge.lthn.ai/core/go-inference (indirect, via go-i18n)
|
||||
forge.lthn.ai/core/go-i18n/reversal (server builds only, !js)
|
||||
github.com/stretchr/testify (test only)
|
||||
```
|
||||
|
||||
Both `go-i18n` and `go-inference` must be present on the local filesystem. The `go.mod` uses `replace` directives pointing to sibling directories (`../go-i18n`, `../go-inference`).
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture](architecture.md) -- Node interface, HLCRF layout internals, responsive compositor, grammar pipeline, WASM module, codegen CLI
|
||||
- [Development](development.md) -- Building, testing, benchmarks, WASM builds, coding standards, contribution guide
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
# Phase 4: CoreDeno + Web Components
|
||||
|
||||
**Date:** 2026-02-17
|
||||
**Status:** Approved
|
||||
**Heritage:** dAppServer prototype (20 repos), Chandler/Dreaming in Code
|
||||
|
||||
## Vision
|
||||
|
||||
A universal application framework where `.core/view.yml` defines what an app IS.
|
||||
Run `core` in any directory — it discovers the manifest, verifies its signature,
|
||||
and boots the application. Like `docker-compose.yml` but for applications.
|
||||
|
||||
Philosophical lineage: Mitch Kapor's Chandler (universal configurable app),
|
||||
rebuilt with Web Components, Deno sandboxing, WASM rendering, and LEM ethics.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WebView2 (Browser) │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Angular │ │ Web Comp │ │ go-html │ │
|
||||
│ │ (shell) │ │ (modules)│ │ WASM │ │
|
||||
│ └─────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ └──────┬───────┘ │ │
|
||||
│ │ fetch/WS │ │
|
||||
└───────────────┼─────────────────────┼───────┘
|
||||
│ │
|
||||
┌───────────────┼─────────────────────┼───────┐
|
||||
│ CoreDeno (Deno sidecar) │ │
|
||||
│ ┌────────────┴──────────┐ ┌─────┴─────┐ │
|
||||
│ │ Module Loader │ │ ITW3→WC │ │
|
||||
│ │ + Permission Gates │ │ Codegen │ │
|
||||
│ │ + Dev Server (HMR) │ │ │ │
|
||||
│ └────────────┬──────────┘ └───────────┘ │
|
||||
│ │ gRPC / Unix socket │
|
||||
└───────────────┼─────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼─────────────────────────────┐
|
||||
│ Go Backend (CoreGO) │
|
||||
│ ┌────────┐ ┌┴───────┐ ┌─────────────────┐ │
|
||||
│ │ Module │ │ gRPC │ │ MCPBridge │ │
|
||||
│ │Registry│ │ Server │ │ (WebView tools) │ │
|
||||
│ └────────┘ └────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three processes:
|
||||
- **WebView2**: Angular shell (gradual migration) + Web Components + go-html WASM
|
||||
- **CoreDeno**: Deno sidecar — module sandbox, I/O fortress, TypeScript toolchain
|
||||
- **CoreGO**: Framework backbone — lifecycle, services, I/O (core/pkg/io), gRPC server
|
||||
|
||||
## Responsibility Split
|
||||
|
||||
| Layer | Role |
|
||||
|-------|------|
|
||||
| **CoreGO** | Framework (lifecycle, services, I/O via core/pkg/io, module registry, gRPC server) |
|
||||
| **go-html** | Web Component factory (layout → Shadow DOM, manifest → custom element, WASM client-side registration) |
|
||||
| **CoreDeno** | Sandbox + toolchain (Deno permissions, TypeScript compilation, dev server, asset serving) |
|
||||
| **MCPBridge** | Retained for direct WebView tools (window control, display, clipboard, dialogs) |
|
||||
|
||||
## CoreDeno Sidecar
|
||||
|
||||
### Lifecycle
|
||||
Go spawns Deno as a managed child process at app startup. Auto-restart on crash.
|
||||
SIGTERM on app shutdown.
|
||||
|
||||
### Communication
|
||||
- **Channel**: Unix domain socket at `$XDG_RUNTIME_DIR/core/deno.sock`
|
||||
- **Protocol**: gRPC (proto definitions in `pkg/coredeno/proto/`)
|
||||
- **Direction**: Bidirectional
|
||||
- Deno → Go: I/O requests (file, network, process) gated by permissions
|
||||
- Go → Deno: Module lifecycle events, HLCRF re-render triggers
|
||||
|
||||
### Deno's Three Roles
|
||||
|
||||
**1. Module loader + sandbox**: Reads ITW3 manifests, loads modules with
|
||||
per-module `--allow-*` permission flags. Modules run in Deno's isolate.
|
||||
|
||||
**2. I/O fortress gateway**: All file/network/process I/O routed through Deno's
|
||||
permission gates before reaching Go via gRPC. A module requesting access outside
|
||||
its declared paths is denied before Go ever sees the request.
|
||||
|
||||
**3. Build/dev toolchain**: TypeScript compilation, module resolution, dev server
|
||||
with HMR. Replaces Node/npm entirely. In production, pre-compiled bundles
|
||||
embedded in binary.
|
||||
|
||||
### Permission Model
|
||||
Each module declares required permissions in its manifest:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
read: ["/data/mining/"]
|
||||
write: ["/data/mining/config.json"]
|
||||
net: ["pool.lthn.io:3333"]
|
||||
run: ["xmrig"]
|
||||
```
|
||||
|
||||
CoreDeno enforces these at the gRPC boundary.
|
||||
|
||||
## The .core/ Convention
|
||||
|
||||
### Auto-Discovery
|
||||
Run `core` in any directory. If `.core/view.yml` exists, CoreGO reads it,
|
||||
validates the ed25519 signature, and boots the application context.
|
||||
|
||||
### view.yml Format (successor to .itw3.json)
|
||||
|
||||
```yaml
|
||||
code: photo-browser
|
||||
name: Photo Browser
|
||||
version: 0.1.0
|
||||
sign: <ed25519 signature>
|
||||
|
||||
layout: HLCRF
|
||||
slots:
|
||||
H: nav-breadcrumb
|
||||
L: folder-tree
|
||||
C: photo-grid
|
||||
R: metadata-panel
|
||||
F: status-bar
|
||||
|
||||
permissions:
|
||||
read: ["./photos/"]
|
||||
net: []
|
||||
run: []
|
||||
|
||||
modules:
|
||||
- core/media
|
||||
- core/fs
|
||||
```
|
||||
|
||||
### Signed Application Loading
|
||||
The `sign` field contains an ed25519 signature. CoreGO verifies before loading.
|
||||
Unsigned or tampered manifests are rejected. The I/O fortress operates at the
|
||||
application boundary — the entire app load chain is authenticated.
|
||||
|
||||
## Web Component Lifecycle
|
||||
|
||||
1. **Discovery** → `core` reads `.core/view.yml`, verifies signature
|
||||
2. **Resolve** → CoreGO checks module registry for declared components
|
||||
3. **Codegen** → go-html generates Web Component class definitions from manifest
|
||||
4. **Permission binding** → CoreDeno wraps component I/O calls with per-module gates
|
||||
5. **Composition** → HLCRF layout assembles slots, each a custom element with Shadow DOM
|
||||
6. **Hot reload** → Dev mode: Deno watches files, WASM re-renders affected slots only
|
||||
|
||||
### HLCRF Slot Composition
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ <nav-breadcrumb> (H - shadow) │
|
||||
├────────┬───────────────┬─────────┤
|
||||
│ <folder│ <photo-grid> │<metadata│
|
||||
│ -tree> │ (C-shadow) │ -panel> │
|
||||
│(L-shad)│ │(R-shad) │
|
||||
├────────┴───────────────┴─────────┤
|
||||
│ <status-bar> (F - shadow) │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each slot is a custom element with closed Shadow DOM. Isolation by design —
|
||||
one module cannot reach into another's shadow tree.
|
||||
|
||||
### go-html WASM Integration
|
||||
- **Server-side (Go)**: go-html reads manifests, generates WC class definitions
|
||||
- **Client-side (WASM)**: go-html WASM in browser dynamically registers custom
|
||||
elements at runtime via `customElements.define()`
|
||||
- Same code, two targets. Server pre-renders for initial load, client handles
|
||||
dynamic re-renders when slots change.
|
||||
|
||||
## Angular Migration Path
|
||||
|
||||
**Phase 4a** (current): Web Components load inside Angular's `<router-outlet>`.
|
||||
Angular sees custom elements via `CUSTOM_ELEMENTS_SCHEMA`. No Angular code needed
|
||||
for new modules.
|
||||
|
||||
**Phase 4b**: ApplicationFrame becomes a go-html Web Component (HLCRF outer frame).
|
||||
Angular router replaced by lightweight hash-based router mapping URLs to
|
||||
`.core/view.yml` slot configurations.
|
||||
|
||||
**Phase 4c**: Angular removed. WebView2 loads:
|
||||
1. go-html WASM (layout engine + WC factory)
|
||||
2. Thin router (~50 lines)
|
||||
3. CoreDeno-served module bundles
|
||||
4. Web Awesome (design system — already vanilla custom elements)
|
||||
|
||||
## dAppServer Heritage
|
||||
|
||||
20 repos at `github.com/dAppServer/` — the original client-side server concept
|
||||
and browser↔Go communications bridge. Extract and port, not copy.
|
||||
|
||||
### Tier 1: Extract (Core Architecture)
|
||||
|
||||
| dAppServer repo | What to extract | Phase 4 target |
|
||||
|---|---|---|
|
||||
| `server` | Port 36911 bridge, ZeroMQ IPC (pub/sub + req/rep + push/pull), air-gapped PGP auth, object store, 13 test files with RPC procedures | CoreDeno sidecar, I/O fortress, auth |
|
||||
| `dappui` | Angular→WC migration, REST+WS+Wails triple, terminal (xterm.js) | Web Component framework, MCPBridge |
|
||||
| `mod-auth` | PGP zero-knowledge auth (sign→encrypt→verify→JWT), QuasiSalt, roles | Signed manifest verification, identity |
|
||||
| `mod-io-process` | Process registry, 3-layer I/O streaming (process→ZeroMQ→WS→browser) | `core/pkg/process`, event bus |
|
||||
| `app-marketplace` | Git-as-database registry, category-as-directory, install pipeline | Module registry, `.core/view.yml` loader |
|
||||
|
||||
### Tier 2: Port (Useful Patterns)
|
||||
|
||||
| dAppServer repo | What to port | Phase 4 target |
|
||||
|---|---|---|
|
||||
| `auth-server` | Keycloak + native PGP fallback | External auth option |
|
||||
| `mod-docker` | Docker socket client, container CRUD (8 ops) | `core/pkg/process` |
|
||||
| `app-mining` | CLI Bridge (camelCase→kebab-case), Process-as-Service, API proxy | Generic CLI wrapper |
|
||||
| `app-directory-browser` | Split-pane layout, lazy tree, filesystem CRUD RPCs | `<core-file-tree>` WC |
|
||||
| `wails-build-action` | Auto-stack detection, cross-platform signing, Deno CI | Build tooling |
|
||||
|
||||
### Tier 3: Reference
|
||||
|
||||
| dAppServer repo | Value |
|
||||
|---|---|
|
||||
| `depends` | Bitcoin Core hermetic build; libmultiprocess + Cap'n Proto validates process-separation |
|
||||
| `app-utils-cyberchef` | Purest manifest-only pattern ("manifest IS the app") |
|
||||
| `devops` | Cross-compilation matrix (9 triples), ancestor of ADR-001 |
|
||||
| `pwa-native-action` | PWA→Wails native shell proof, ancestor of `core-gui` |
|
||||
| `docker-images` | C++ cross-compile layers |
|
||||
|
||||
### Tier 4: Skip
|
||||
|
||||
| dAppServer repo | Reason |
|
||||
|---|---|
|
||||
| `server-sdk-python` | Auto-generated, Go replaces |
|
||||
| `server-sdk-typescript-angular` | Auto-generated, superseded |
|
||||
| `openvpn` | Unmodified upstream fork |
|
||||
| `ansible-server-base` | Standard Ansible hardening |
|
||||
| `.github` | Org profile only |
|
||||
|
||||
## Polyfills
|
||||
|
||||
dAppServer polyfilled nothing at the browser level. The prototype ran inside
|
||||
Electron/WebView2 (Chromium), which already supports all required APIs natively:
|
||||
Custom Elements v1, Shadow DOM v1, ES Modules, `fetch`, `WebSocket`,
|
||||
`customElements.define()`, `structuredClone()`.
|
||||
|
||||
**Decision**: No polyfills needed. WebView2 is Chromium-based. The minimum
|
||||
Chromium version Wails v3 targets already supports all Web Component APIs.
|
||||
|
||||
## Object Store
|
||||
|
||||
dAppServer used a file-based JSON key-value store at `data/objects/{group}/{object}.json`.
|
||||
Six operations discovered from test files:
|
||||
|
||||
| Operation | dAppServer endpoint | Phase 4 equivalent |
|
||||
|-----------|--------------------|--------------------|
|
||||
| Get | `GET /config/object/{group}/{object}` | `store.get(group, key)` |
|
||||
| Set | `POST /config/object/{group}/{object}` | `store.set(group, key, value)` |
|
||||
| Clear | `DELETE /config/object/{group}/{object}` | `store.delete(group, key)` |
|
||||
| Count | `GET /config/object/{group}/count` | `store.count(group)` |
|
||||
| Remove group | `DELETE /config/object/{group}` | `store.deleteGroup(group)` |
|
||||
| Render template | `POST /config/render` | `store.render(template, vars)` |
|
||||
|
||||
Used for: installed app registry (`conf/installed-apps.json`), menu state
|
||||
(`conf/menu.json`), per-module config, user preferences.
|
||||
|
||||
**Decision**: Go-managed storage via gRPC. CoreGO owns persistence through
|
||||
`core/pkg/io`. Modules request storage through the I/O fortress — never
|
||||
touching the filesystem directly. SQLite backend (already a dependency in
|
||||
the blockchain layer). IndexedDB reserved for client-side cache only.
|
||||
|
||||
## Templated Config Generators
|
||||
|
||||
dAppServer's `config.render` endpoint accepted a template string + variable map
|
||||
and returned the rendered output. Used to generate configs for managed processes
|
||||
(e.g., xmrig config.json from user-selected pool/wallet parameters).
|
||||
|
||||
The pattern in Phase 4:
|
||||
1. Module declares config templates in `.core/view.yml` under a `config:` key
|
||||
2. User preferences stored in the object store
|
||||
3. CoreGO renders templates at process-start time via Go `text/template`
|
||||
4. Rendered configs written to sandboxed paths the module has `write` permission for
|
||||
|
||||
Example from mining module (camelCase→kebab-case CLI arg transformation):
|
||||
```yaml
|
||||
config:
|
||||
xmrig:
|
||||
template: conf/xmrig/config.json.tmpl
|
||||
vars:
|
||||
pool: "{{ .user.pool }}"
|
||||
wallet: "{{ .user.wallet }}"
|
||||
```
|
||||
|
||||
**Decision**: Go `text/template` in CoreGO. Templates live in the module's
|
||||
`.core/` directory. Variables come from the object store. No Deno involvement —
|
||||
config rendering is a Go-side I/O fortress operation.
|
||||
|
||||
## Git-Based Plugin Marketplace
|
||||
|
||||
### dAppServer Pattern (Extracted from `app-marketplace`)
|
||||
|
||||
A Git repository serves as the package registry. No server infrastructure needed.
|
||||
|
||||
```
|
||||
marketplace/ # Git repo
|
||||
├── index.json # Root: {version, apps[], dirs[]}
|
||||
├── miner/
|
||||
│ └── index.json # Category: {version, apps[], dirs[]}
|
||||
├── utils/
|
||||
│ └── index.json # Category: {version, apps[], dirs[]}
|
||||
└── ...
|
||||
```
|
||||
|
||||
Each `index.json` entry points to a raw `.itw3.json` URL in the plugin's own repo:
|
||||
```json
|
||||
{"code": "utils-cyberchef", "name": "CyberChef", "type": "bin",
|
||||
"pkg": "https://raw.githubusercontent.com/dAppServer/app-utils-cyberchef/main/.itw3.json"}
|
||||
```
|
||||
|
||||
Install pipeline: browse index → fetch manifest → download zip from `app.url` →
|
||||
extract → run hooks (rename, etc.) → register in object store → add menu entry.
|
||||
|
||||
### Phase 4 Evolution
|
||||
|
||||
Replace GitHub-specific URLs with Forgejo-compatible Git operations:
|
||||
|
||||
1. **Registry**: A Git repo (`host-uk/marketplace`) with category directories
|
||||
and `index.json` files. Cloned/pulled by CoreGO at startup and periodically.
|
||||
2. **Manifests**: Each module's `.core/view.yml` is the manifest (replaces `.itw3.json`).
|
||||
The marketplace index points to the module's Git repo, not a raw file URL.
|
||||
3. **Distribution**: Git clone of the module repo (not zip downloads). CoreGO
|
||||
clones into a managed modules directory with depth=1.
|
||||
4. **Verification**: ed25519 signature in `view.yml` verified before loading.
|
||||
The marketplace index includes the expected signing public key.
|
||||
5. **Install hooks**: Declared in `view.yml` under `hooks:`. Executed by CoreGO
|
||||
in the I/O fortress (rename, template render, permission grant).
|
||||
6. **Updates**: `git pull` on the module repo. Signature re-verified after pull.
|
||||
If signature fails, rollback to previous commit.
|
||||
7. **Discovery**: `core marketplace list`, `core marketplace search <query>`,
|
||||
`core marketplace install <code>`.
|
||||
|
||||
```yaml
|
||||
# marketplace/index.json
|
||||
version: 1
|
||||
modules:
|
||||
- code: utils-cyberchef
|
||||
name: CyberChef Data Toolkit
|
||||
repo: https://forge.lthn.io/host-uk/mod-cyberchef.git
|
||||
sign_key: <ed25519 public key>
|
||||
category: utils
|
||||
categories:
|
||||
- miner
|
||||
- utils
|
||||
- network
|
||||
```
|
||||
|
||||
### RPC Surface (from dAppServer test extraction)
|
||||
|
||||
| Operation | CLI | RPC |
|
||||
|-----------|-----|-----|
|
||||
| Browse | `core marketplace list` | `marketplace.list(category?)` |
|
||||
| Search | `core marketplace search <q>` | `marketplace.search(query)` |
|
||||
| Install | `core marketplace install <code>` | `marketplace.install(code)` |
|
||||
| Remove | `core marketplace remove <code>` | `marketplace.remove(code)` |
|
||||
| Installed | `core marketplace installed` | `marketplace.installed()` |
|
||||
| Update | `core marketplace update <code>` | `marketplace.update(code)` |
|
||||
| Update all | `core marketplace update` | `marketplace.updateAll()` |
|
||||
|
||||
## Complete RPC Surface (Archaeological Extraction)
|
||||
|
||||
All procedures discovered from dAppServer test files and controllers:
|
||||
|
||||
### Auth
|
||||
- `auth.create(username, password)` — PGP key generation + QuasiSalt hash
|
||||
- `auth.login(username, encryptedPayload)` — Zero-knowledge PGP verify → JWT
|
||||
- `auth.delete(username)` — Remove account
|
||||
|
||||
### Crypto
|
||||
- `crypto.pgp.generateKeyPair(name, email, passphrase)` → {publicKey, privateKey}
|
||||
- `crypto.pgp.encrypt(data, publicKey)` → encryptedData
|
||||
- `crypto.pgp.decrypt(data, privateKey, passphrase)` → plaintext
|
||||
- `crypto.pgp.sign(data, privateKey, passphrase)` → signature
|
||||
- `crypto.pgp.verify(data, signature, publicKey)` → boolean
|
||||
|
||||
### Filesystem
|
||||
- `fs.list(path, detailed?)` → FileEntry[]
|
||||
- `fs.read(path)` → content
|
||||
- `fs.write(path, content)` → boolean
|
||||
- `fs.delete(path)` → boolean
|
||||
- `fs.rename(from, to)` → boolean
|
||||
- `fs.mkdir(path)` → boolean
|
||||
- `fs.isDir(path)` → boolean
|
||||
- `fs.ensureDir(path)` → boolean
|
||||
|
||||
### Process
|
||||
- `process.run(command, args, options)` → ProcessHandle
|
||||
- `process.add(request)` → key
|
||||
- `process.start(key)` → boolean
|
||||
- `process.stop(key)` → boolean
|
||||
- `process.kill(key)` → boolean
|
||||
- `process.list()` → string[]
|
||||
- `process.get(key)` → ProcessInfo
|
||||
- `process.stdout.subscribe(key)` → stream
|
||||
- `process.stdin.write(key, data)` → void
|
||||
|
||||
### Object Store
|
||||
- `store.get(group, key)` → value
|
||||
- `store.set(group, key, value)` → void
|
||||
- `store.delete(group, key)` → void
|
||||
- `store.count(group)` → number
|
||||
- `store.deleteGroup(group)` → void
|
||||
- `store.render(template, vars)` → string
|
||||
|
||||
### IPC / Event Bus
|
||||
- `ipc.pub.subscribe(channel)` → stream
|
||||
- `ipc.pub.publish(channel, message)` → void
|
||||
- `ipc.req.send(channel, message)` → response
|
||||
- `ipc.push.send(message)` → void
|
||||
|
||||
### Marketplace
|
||||
- `marketplace.list(category?)` → ModuleEntry[]
|
||||
- `marketplace.search(query)` → ModuleEntry[]
|
||||
- `marketplace.install(code)` → boolean
|
||||
- `marketplace.remove(code)` → boolean
|
||||
- `marketplace.installed()` → InstalledModule[]
|
||||
- `marketplace.update(code)` → boolean
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Component | Location | Language |
|
||||
|---|---|---|
|
||||
| CoreDeno sidecar manager | `core/pkg/coredeno/` | Go |
|
||||
| gRPC proto definitions | `core/pkg/coredeno/proto/` | Protobuf |
|
||||
| gRPC server (Go side) | `core/pkg/coredeno/server.go` | Go |
|
||||
| Deno client runtime | `core-deno/` (new repo) | TypeScript |
|
||||
| ITW3 → WC codegen | `go-html/codegen/` | Go |
|
||||
| .core/view.yml loader | `core/pkg/manifest/` | Go |
|
||||
| Manifest signing/verify | `core/pkg/manifest/sign.go` | Go |
|
||||
| WASM WC registration | `go-html/cmd/wasm/` (extend) | Go |
|
||||
|
||||
## Not In Scope (Future Phases)
|
||||
|
||||
- LEM auto-loading from signed manifests
|
||||
- Marketplace server infrastructure (Git-based registry is sufficient)
|
||||
- Offline-first sync (IndexedDB client cache)
|
||||
- Full Angular removal (Phase 4c)
|
||||
- GlusterFS distributed storage (dAppServer aspiration, not needed yet)
|
||||
- Multi-chain support (Phase 4 is Lethean-only)
|
||||
File diff suppressed because it is too large
Load diff
229
edge_test.go
229
edge_test.go
|
|
@ -1,16 +1,15 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
// --- Unicode / RTL edge cases ---
|
||||
|
||||
func TestText_Emoji(t *testing.T) {
|
||||
func TestText_Emoji_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -33,7 +32,7 @@ func TestText_Emoji(t *testing.T) {
|
|||
t.Error("Text with emoji should not produce empty output")
|
||||
}
|
||||
// Emoji should pass through (they are not HTML special chars)
|
||||
if !strings.Contains(got, tt.input) {
|
||||
if !containsText(got, tt.input) {
|
||||
// Some chars may get escaped, but emoji bytes should survive
|
||||
t.Logf("note: emoji text rendered as %q", got)
|
||||
}
|
||||
|
|
@ -41,7 +40,7 @@ func TestText_Emoji(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEl_Emoji(t *testing.T) {
|
||||
func TestEl_Emoji_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("span", Raw("\U0001F680 Launch"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -51,7 +50,7 @@ func TestEl_Emoji(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestText_RTL(t *testing.T) {
|
||||
func TestText_RTL_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -76,19 +75,19 @@ func TestText_RTL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEl_RTL(t *testing.T) {
|
||||
func TestEl_RTL_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `dir="rtl"`) {
|
||||
if !containsText(got, `dir="rtl"`) {
|
||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_ZeroWidth(t *testing.T) {
|
||||
func TestText_ZeroWidth_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -114,7 +113,7 @@ func TestText_ZeroWidth(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestText_MixedScripts(t *testing.T) {
|
||||
func TestText_MixedScripts_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -141,7 +140,7 @@ func TestText_MixedScripts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Unicode(t *testing.T) {
|
||||
func TestStripTags_Unicode_Ugly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
|
|
@ -163,19 +162,19 @@ func TestStripTags_Unicode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAttr_UnicodeValue(t *testing.T) {
|
||||
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
||||
got := node.Render(ctx)
|
||||
want := "title=\"\U0001F680 Rocket Launch\""
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deep nesting stress tests ---
|
||||
|
||||
func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Build 10 levels of nested layouts
|
||||
|
|
@ -187,7 +186,7 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
|||
got := current.Render(ctx)
|
||||
|
||||
// Should contain the deepest content
|
||||
if !strings.Contains(got, "deepest") {
|
||||
if !containsText(got, "deepest") {
|
||||
t.Error("10 levels deep: missing leaf content")
|
||||
}
|
||||
|
||||
|
|
@ -196,17 +195,17 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
|||
for i := 1; i < 10; i++ {
|
||||
expectedBlock += "-C-0"
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
|
||||
if !containsText(got, `data-block="`+expectedBlock+`"`) {
|
||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
||||
}
|
||||
|
||||
// Must have exactly 10 <main> tags
|
||||
if count := strings.Count(got, "<main"); count != 10 {
|
||||
if count := countText(got, "<main"); count != 10 {
|
||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
||||
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
current := NewLayout("C").C(Raw("bottom"))
|
||||
|
|
@ -216,15 +215,15 @@ func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
|||
|
||||
got := current.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "bottom") {
|
||||
if !containsText(got, "bottom") {
|
||||
t.Error("20 levels deep: missing leaf content")
|
||||
}
|
||||
if count := strings.Count(got, "<main"); count != 20 {
|
||||
if count := countText(got, "<main"); count != 20 {
|
||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
||||
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||
|
|
@ -238,12 +237,12 @@ func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
|||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
if !strings.Contains(got, "leaf") {
|
||||
if !containsText(got, "leaf") {
|
||||
t.Error("mixed deep nesting: missing leaf content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_1000(t *testing.T) {
|
||||
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 1000)
|
||||
for i := range items {
|
||||
|
|
@ -251,23 +250,23 @@ func TestEach_LargeIteration_1000(t *testing.T) {
|
|||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("%d", i)))
|
||||
return El("li", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<li>"); count != 1000 {
|
||||
if count := countText(got, "<li>"); count != 1000 {
|
||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "<li>0</li>") {
|
||||
if !containsText(got, "<li>0</li>") {
|
||||
t.Error("Each with 1000 items: missing first item")
|
||||
}
|
||||
if !strings.Contains(got, "<li>999</li>") {
|
||||
if !containsText(got, "<li>999</li>") {
|
||||
t.Error("Each with 1000 items: missing last item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_5000(t *testing.T) {
|
||||
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 5000)
|
||||
for i := range items {
|
||||
|
|
@ -275,43 +274,43 @@ func TestEach_LargeIteration_5000(t *testing.T) {
|
|||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("span", Raw(fmt.Sprintf("%d", i)))
|
||||
return El("span", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<span>"); count != 5000 {
|
||||
if count := countText(got, "<span>"); count != 5000 {
|
||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_NestedEach(t *testing.T) {
|
||||
func TestEach_NestedEach_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
rows := []int{0, 1, 2}
|
||||
cols := []string{"a", "b", "c"}
|
||||
|
||||
node := Each(rows, func(row int) Node {
|
||||
return El("tr", Each(cols, func(col string) Node {
|
||||
return El("td", Raw(fmt.Sprintf("%d-%s", row, col)))
|
||||
return El("td", Raw(itoaText(row)+"-"+col))
|
||||
}))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<tr>"); count != 3 {
|
||||
if count := countText(got, "<tr>"); count != 3 {
|
||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||
}
|
||||
if count := strings.Count(got, "<td>"); count != 9 {
|
||||
if count := countText(got, "<td>"); count != 9 {
|
||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "1-b") {
|
||||
if !containsText(got, "1-b") {
|
||||
t.Error("nested Each: missing cell content '1-b'")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout variant validation ---
|
||||
|
||||
func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
||||
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
|
|
@ -343,7 +342,96 @@ func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
||||
func TestLayout_VariantError_Bad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
wantInvalid bool
|
||||
wantErrString string
|
||||
build func(*Layout)
|
||||
wantRender string
|
||||
}{
|
||||
{
|
||||
name: "valid variant",
|
||||
variant: "HCF",
|
||||
wantInvalid: false,
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main><footer role="contentinfo" data-block="F-0">footer</footer>`,
|
||||
},
|
||||
{
|
||||
name: "mixed invalid variant",
|
||||
variant: "HXC",
|
||||
wantInvalid: true,
|
||||
wantErrString: "html: invalid layout variant HXC",
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
layout := NewLayout(tt.variant)
|
||||
if tt.build != nil {
|
||||
tt.build(layout)
|
||||
}
|
||||
if tt.wantInvalid {
|
||||
if layout.VariantError() == nil {
|
||||
t.Fatalf("VariantError() = nil, want sentinel error for %q", tt.variant)
|
||||
}
|
||||
if !errors.Is(layout.VariantError(), ErrInvalidLayoutVariant) {
|
||||
t.Fatalf("VariantError() = %v, want errors.Is(..., ErrInvalidLayoutVariant)", layout.VariantError())
|
||||
}
|
||||
if got := layout.VariantError().Error(); got != tt.wantErrString {
|
||||
t.Fatalf("VariantError().Error() = %q, want %q", got, tt.wantErrString)
|
||||
}
|
||||
} else if layout.VariantError() != nil {
|
||||
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
||||
}
|
||||
|
||||
got := layout.Render(NewContext())
|
||||
if got != tt.wantRender {
|
||||
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLayoutVariant_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", variant: "HCF", wantErr: false},
|
||||
{name: "invalid", variant: "HXC", wantErr: true},
|
||||
{name: "empty", variant: "", wantErr: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLayoutVariant(tt.variant)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = nil, want error", tt.variant)
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidLayoutVariant) {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want ErrInvalidLayoutVariant", tt.variant, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
||||
|
|
@ -351,32 +439,32 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
|||
H(Raw("header")).C(Raw("main"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "header") {
|
||||
if !containsText(got, "header") {
|
||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "main") {
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
||||
}
|
||||
// Should only have 2 semantic elements
|
||||
if count := strings.Count(got, "data-block="); count != 2 {
|
||||
if count := countText(got, "data-block="); count != 2 {
|
||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DuplicateVariantChars(t *testing.T) {
|
||||
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "CCC" — C appears three times. Should render C slot content three times.
|
||||
layout := NewLayout("CCC").C(Raw("content"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
count := strings.Count(got, "content")
|
||||
count := countText(got, "content")
|
||||
if count != 3 {
|
||||
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EmptySlots(t *testing.T) {
|
||||
func TestLayout_EmptySlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Variant includes all slots but none are populated — should produce empty output.
|
||||
|
|
@ -388,9 +476,38 @@ func TestLayout_EmptySlots(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": Raw("ignored"),
|
||||
}))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render convenience function edge cases ---
|
||||
|
||||
func TestRender_NilContext(t *testing.T) {
|
||||
func TestRender_NilContext_Ugly(t *testing.T) {
|
||||
node := Raw("test")
|
||||
got := Render(node, nil)
|
||||
if got != "test" {
|
||||
|
|
@ -398,7 +515,7 @@ func TestRender_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_NilContext(t *testing.T) {
|
||||
func TestImprint_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -410,7 +527,7 @@ func TestImprint_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_NilContext(t *testing.T) {
|
||||
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -424,7 +541,7 @@ func TestCompareVariants_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_SingleVariant(t *testing.T) {
|
||||
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -439,31 +556,31 @@ func TestCompareVariants_SingleVariant(t *testing.T) {
|
|||
|
||||
// --- escapeHTML / escapeAttr edge cases ---
|
||||
|
||||
func TestEscapeAttr_AllSpecialChars(t *testing.T) {
|
||||
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "data-val", `&<>"'`)
|
||||
got := node.Render(ctx)
|
||||
|
||||
if strings.Contains(got, `"&<>"'"`) {
|
||||
if containsText(got, `"&<>"'"`) {
|
||||
t.Error("attribute value with special chars must be fully escaped")
|
||||
}
|
||||
if !strings.Contains(got, "&<>"'") {
|
||||
if !containsText(got, "&<>"'") {
|
||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_EmptyTag(t *testing.T) {
|
||||
func TestElNode_EmptyTag_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
||||
// Empty tag is weird but should not panic
|
||||
if !strings.Contains(got, "content") {
|
||||
if !containsText(got, "content") {
|
||||
t.Errorf("El with empty tag should still render children, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_NoMatch(t *testing.T) {
|
||||
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"a": Raw("alpha"),
|
||||
|
|
@ -476,7 +593,7 @@ func TestSwitchNode_NoMatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitled_NilContext(t *testing.T) {
|
||||
func TestEntitled_NilContext_Ugly(t *testing.T) {
|
||||
node := Entitled("premium", Raw("content"))
|
||||
got := node.Render(nil)
|
||||
if got != "" {
|
||||
|
|
|
|||
13
go.mod
13
go.mod
|
|
@ -1,16 +1,21 @@
|
|||
module forge.lthn.ai/core/go-html
|
||||
module dappco.re/go/core/html
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-i18n v0.1.0
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/i18n v0.2.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core/process v0.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-inference v0.1.0 // indirect
|
||||
dappco.re/go/core/inference v0.1.4 // indirect
|
||||
dappco.re/go/core/log v0.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
22
go.sum
22
go.sum
|
|
@ -1,7 +1,17 @@
|
|||
forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
|
||||
forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
|
||||
forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
|
||||
forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
|
||||
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
|
||||
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
|
@ -14,8 +24,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
|||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package html
|
|||
import (
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -26,7 +26,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ResponsiveImprint(t *testing.T) {
|
||||
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
|
|||
193
layout.go
193
layout.go
|
|
@ -1,6 +1,13 @@
|
|||
package html
|
||||
|
||||
import "strings"
|
||||
import "errors"
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Layout)(nil)
|
||||
|
||||
// ErrInvalidLayoutVariant reports that a layout variant string contains at least
|
||||
// one unrecognised slot character.
|
||||
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
|
||||
|
||||
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
|
||||
type slotMeta struct {
|
||||
|
|
@ -19,48 +26,159 @@ var slotRegistry = map[byte]slotMeta{
|
|||
|
||||
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
// with deterministic path-based IDs.
|
||||
// Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||
type Layout struct {
|
||||
variant string // "HLCRF", "HCF", "C", etc.
|
||||
path string // "" for root, "L-0-" for nested
|
||||
slots map[byte][]Node // H, L, C, R, F → children
|
||||
variant string // "HLCRF", "HCF", "C", etc.
|
||||
path string // "" for root, "L-0-" for nested
|
||||
slots map[byte][]Node // H, L, C, R, F → children
|
||||
variantErr error
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
func NewLayout(variant string) *Layout {
|
||||
return &Layout{
|
||||
variant: variant,
|
||||
slots: make(map[byte][]Node),
|
||||
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if renderer, ok := node.(layoutPathRenderer); ok {
|
||||
return renderer.renderWithLayoutPath(ctx, path)
|
||||
}
|
||||
|
||||
switch t := node.(type) {
|
||||
case *Layout:
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
clone := *t
|
||||
clone.path = path
|
||||
return clone.Render(ctx)
|
||||
case *ifNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *unlessNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *entitledNode:
|
||||
if t == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
case *switchNode:
|
||||
if t == nil || t.selector == nil || t.cases == nil {
|
||||
return ""
|
||||
}
|
||||
key := t.selector(ctx)
|
||||
node, ok := t.cases[key]
|
||||
if !ok || node == nil {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(node, ctx, path)
|
||||
default:
|
||||
return node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
// Usage example: page := NewLayout("HLCRF")
|
||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
func NewLayout(variant string) *Layout {
|
||||
l := &Layout{
|
||||
variant: variant,
|
||||
slots: make(map[byte][]Node),
|
||||
}
|
||||
l.variantErr = ValidateLayoutVariant(variant)
|
||||
return l
|
||||
}
|
||||
|
||||
// ValidateLayoutVariant reports whether a layout variant string contains only
|
||||
// recognised slot characters.
|
||||
//
|
||||
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
|
||||
// layoutVariantError for invalid ones.
|
||||
func ValidateLayoutVariant(variant string) error {
|
||||
var invalid bool
|
||||
for i := range len(variant) {
|
||||
if _, ok := slotRegistry[variant[i]]; ok {
|
||||
continue
|
||||
}
|
||||
invalid = true
|
||||
break
|
||||
}
|
||||
if !invalid {
|
||||
return nil
|
||||
}
|
||||
return &layoutVariantError{variant: variant}
|
||||
}
|
||||
|
||||
func (l *Layout) slotsForSlot(slot byte) []Node {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if l.slots == nil {
|
||||
l.slots = make(map[byte][]Node)
|
||||
}
|
||||
return l.slots[slot]
|
||||
}
|
||||
|
||||
// H appends nodes to the Header slot.
|
||||
// Usage example: NewLayout("HCF").H(Text("title"))
|
||||
func (l *Layout) H(nodes ...Node) *Layout {
|
||||
l.slots['H'] = append(l.slots['H'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// L appends nodes to the Left aside slot.
|
||||
// Usage example: NewLayout("LC").L(Text("nav"))
|
||||
func (l *Layout) L(nodes ...Node) *Layout {
|
||||
l.slots['L'] = append(l.slots['L'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// C appends nodes to the Content (main) slot.
|
||||
// Usage example: NewLayout("C").C(Text("body"))
|
||||
func (l *Layout) C(nodes ...Node) *Layout {
|
||||
l.slots['C'] = append(l.slots['C'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// R appends nodes to the Right aside slot.
|
||||
// Usage example: NewLayout("CR").R(Text("ads"))
|
||||
func (l *Layout) R(nodes ...Node) *Layout {
|
||||
l.slots['R'] = append(l.slots['R'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// F appends nodes to the Footer slot.
|
||||
// Usage example: NewLayout("CF").F(Text("footer"))
|
||||
func (l *Layout) F(nodes ...Node) *Layout {
|
||||
l.slots['F'] = append(l.slots['F'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +187,27 @@ func (l *Layout) blockID(slot byte) string {
|
|||
return l.path + string(slot) + "-0"
|
||||
}
|
||||
|
||||
// VariantError reports whether the layout variant string contained any invalid
|
||||
// slot characters when the layout was constructed.
|
||||
func (l *Layout) VariantError() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return l.variantErr
|
||||
}
|
||||
|
||||
// Render produces the semantic HTML for this layout.
|
||||
// Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||
// Only slots present in the variant string are rendered.
|
||||
func (l *Layout) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
|
||||
for i := range len(l.variant) {
|
||||
slot := l.variant[i]
|
||||
|
|
@ -89,22 +224,18 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
bid := l.blockID(slot)
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(meta.tag)
|
||||
b.WriteString(escapeHTML(meta.tag))
|
||||
b.WriteString(` role="`)
|
||||
b.WriteString(meta.role)
|
||||
b.WriteString(escapeAttr(meta.role))
|
||||
b.WriteString(`" data-block="`)
|
||||
b.WriteString(bid)
|
||||
b.WriteString(escapeAttr(bid))
|
||||
b.WriteString(`">`)
|
||||
|
||||
for _, child := range children {
|
||||
// Clone nested layouts before setting path (thread-safe).
|
||||
if inner, ok := child.(*Layout); ok {
|
||||
clone := *inner
|
||||
clone.path = bid + "-"
|
||||
b.WriteString(clone.Render(ctx))
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(child.Render(ctx))
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
|
|
@ -114,3 +245,15 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type layoutVariantError struct {
|
||||
variant string
|
||||
}
|
||||
|
||||
func (e *layoutVariantError) Error() string {
|
||||
return "html: invalid layout variant " + e.variant
|
||||
}
|
||||
|
||||
func (e *layoutVariantError) Unwrap() error {
|
||||
return ErrInvalidLayoutVariant
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayout_HLCRF(t *testing.T) {
|
||||
func TestLayout_HLCRF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -13,34 +12,34 @@ func TestLayout_HLCRF(t *testing.T) {
|
|||
|
||||
// Must contain semantic elements
|
||||
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain ARIA roles
|
||||
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain data-block IDs
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain content
|
||||
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_HCF(t *testing.T) {
|
||||
func TestLayout_HCF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -48,42 +47,42 @@ func TestLayout_HCF(t *testing.T) {
|
|||
|
||||
// HCF should have header, main, footer
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// HCF must NOT have L or R slots
|
||||
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_ContentOnly(t *testing.T) {
|
||||
func TestLayout_ContentOnly_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("C").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Only C slot should render
|
||||
if !strings.Contains(got, `data-block="C-0"`) {
|
||||
if !containsText(got, `data-block="C-0"`) {
|
||||
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<main") {
|
||||
if !containsText(got, "<main") {
|
||||
t.Errorf("C layout missing <main in:\n%s", got)
|
||||
}
|
||||
|
||||
// No other slots
|
||||
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_FluentAPI(t *testing.T) {
|
||||
func TestLayout_FluentAPI_Good(t *testing.T) {
|
||||
layout := NewLayout("HLCRF")
|
||||
|
||||
// Fluent methods should return the same layout for chaining
|
||||
|
|
@ -98,19 +97,53 @@ func TestLayout_FluentAPI(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
|
||||
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
// "C" variant: populating L and R should have no effect
|
||||
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "main") {
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("C variant should render main content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "left") {
|
||||
if containsText(got, "left") {
|
||||
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "right") {
|
||||
if containsText(got, "right") {
|
||||
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Methods_NilLayout_Ugly(t *testing.T) {
|
||||
var layout *Layout
|
||||
|
||||
if layout.H(Raw("h")) != nil {
|
||||
t.Fatal("expected nil layout from H on nil receiver")
|
||||
}
|
||||
if layout.L(Raw("l")) != nil {
|
||||
t.Fatal("expected nil layout from L on nil receiver")
|
||||
}
|
||||
if layout.C(Raw("c")) != nil {
|
||||
t.Fatal("expected nil layout from C on nil receiver")
|
||||
}
|
||||
if layout.R(Raw("r")) != nil {
|
||||
t.Fatal("expected nil layout from R on nil receiver")
|
||||
}
|
||||
if layout.F(Raw("f")) != nil {
|
||||
t.Fatal("expected nil layout from F on nil receiver")
|
||||
}
|
||||
|
||||
if got := layout.Render(NewContext()); got != "" {
|
||||
t.Fatalf("nil layout render should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Render_NilContext_Good(t *testing.T) {
|
||||
layout := NewLayout("C").C(Raw("content"))
|
||||
|
||||
got := layout.Render(nil)
|
||||
want := `<main role="main" data-block="C-0">content</main>`
|
||||
if got != want {
|
||||
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
node.go
175
node.go
|
|
@ -1,19 +1,35 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"html"
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Node is anything renderable.
|
||||
// Usage example: var n Node = El("div", Text("welcome"))
|
||||
type Node interface {
|
||||
Render(ctx *Context) string
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ Node = (*rawNode)(nil)
|
||||
_ Node = (*elNode)(nil)
|
||||
_ Node = (*textNode)(nil)
|
||||
_ Node = (*ifNode)(nil)
|
||||
_ Node = (*unlessNode)(nil)
|
||||
_ Node = (*entitledNode)(nil)
|
||||
_ Node = (*switchNode)(nil)
|
||||
_ Node = (*eachNode[any])(nil)
|
||||
)
|
||||
|
||||
type layoutPathRenderer interface {
|
||||
renderWithLayoutPath(ctx *Context, path string) string
|
||||
}
|
||||
|
||||
// voidElements is the set of HTML elements that must not have a closing tag.
|
||||
var voidElements = map[string]bool{
|
||||
"area": true,
|
||||
|
|
@ -33,12 +49,7 @@ var voidElements = map[string]bool{
|
|||
|
||||
// escapeAttr escapes a string for use in an HTML attribute value.
|
||||
func escapeAttr(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// --- rawNode ---
|
||||
|
|
@ -48,11 +59,15 @@ type rawNode struct {
|
|||
}
|
||||
|
||||
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
// Usage example: Raw("<strong>trusted</strong>")
|
||||
func Raw(content string) Node {
|
||||
return &rawNode{content: content}
|
||||
}
|
||||
|
||||
func (n *rawNode) Render(_ *Context) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return n.content
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +80,7 @@ type elNode struct {
|
|||
}
|
||||
|
||||
// El creates an HTML element node with children.
|
||||
// Usage example: El("section", Text("welcome"))
|
||||
func El(tag string, children ...Node) Node {
|
||||
return &elNode{
|
||||
tag: tag,
|
||||
|
|
@ -74,26 +90,78 @@ func El(tag string, children ...Node) Node {
|
|||
}
|
||||
|
||||
// Attr sets an attribute on an El node. Returns the node for chaining.
|
||||
// If the node is not an *elNode, returns it unchanged.
|
||||
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||
// It recursively traverses through wrappers like If, Unless, Entitled, and Each.
|
||||
func Attr(n Node, key, value string) Node {
|
||||
if el, ok := n.(*elNode); ok {
|
||||
el.attrs[key] = value
|
||||
if n == nil {
|
||||
return n
|
||||
}
|
||||
|
||||
switch t := n.(type) {
|
||||
case *elNode:
|
||||
t.attrs[key] = value
|
||||
case *ifNode:
|
||||
Attr(t.node, key, value)
|
||||
case *unlessNode:
|
||||
Attr(t.node, key, value)
|
||||
case *entitledNode:
|
||||
Attr(t.node, key, value)
|
||||
case *switchNode:
|
||||
for _, child := range t.cases {
|
||||
Attr(child, key, value)
|
||||
}
|
||||
case attrApplier:
|
||||
t.applyAttr(key, value)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AriaLabel sets an aria-label attribute on an element node.
|
||||
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
||||
func AriaLabel(n Node, label string) Node {
|
||||
return Attr(n, "aria-label", label)
|
||||
}
|
||||
|
||||
// AltText sets an alt attribute on an element node.
|
||||
// Usage example: AltText(El("img"), "Profile photo")
|
||||
func AltText(n Node, text string) Node {
|
||||
return Attr(n, "alt", text)
|
||||
}
|
||||
|
||||
// TabIndex sets a tabindex attribute on an element node.
|
||||
// Usage example: TabIndex(El("button", Text("save")), 0)
|
||||
func TabIndex(n Node, index int) Node {
|
||||
return Attr(n, "tabindex", strconv.Itoa(index))
|
||||
}
|
||||
|
||||
// AutoFocus sets an autofocus attribute on an element node.
|
||||
// Usage example: AutoFocus(El("input"))
|
||||
func AutoFocus(n Node) Node {
|
||||
return Attr(n, "autofocus", "autofocus")
|
||||
}
|
||||
|
||||
// Role sets a role attribute on an element node.
|
||||
// Usage example: Role(El("nav", Text("links")), "navigation")
|
||||
func Role(n Node, role string) Node {
|
||||
return Attr(n, "role", role)
|
||||
}
|
||||
|
||||
func (n *elNode) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(n.tag)
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
|
||||
// Sort attribute keys for deterministic output.
|
||||
keys := slices.Collect(maps.Keys(n.attrs))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(key)
|
||||
b.WriteString(escapeHTML(key))
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeAttr(n.attrs[key]))
|
||||
b.WriteByte('"')
|
||||
|
|
@ -106,11 +174,14 @@ func (n *elNode) Render(ctx *Context) string {
|
|||
}
|
||||
|
||||
for i := range len(n.children) {
|
||||
if n.children[i] == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(n.children[i].Render(ctx))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
b.WriteString(n.tag)
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
b.WriteByte('>')
|
||||
|
||||
return b.String()
|
||||
|
|
@ -120,12 +191,7 @@ func (n *elNode) Render(ctx *Context) string {
|
|||
|
||||
// escapeHTML escapes a string for safe inclusion in HTML text content.
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// --- textNode ---
|
||||
|
|
@ -136,19 +202,17 @@ type textNode struct {
|
|||
}
|
||||
|
||||
// Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
// Usage example: Text("welcome", "Ada")
|
||||
// Output is HTML-escaped by default. Safe-by-default path.
|
||||
func Text(key string, args ...any) Node {
|
||||
return &textNode{key: key, args: args}
|
||||
}
|
||||
|
||||
func (n *textNode) Render(ctx *Context) string {
|
||||
var text string
|
||||
if ctx != nil && ctx.service != nil {
|
||||
text = ctx.service.T(n.key, n.args...)
|
||||
} else {
|
||||
text = i18n.T(n.key, n.args...)
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return escapeHTML(text)
|
||||
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||
}
|
||||
|
||||
// --- ifNode ---
|
||||
|
|
@ -159,11 +223,15 @@ type ifNode struct {
|
|||
}
|
||||
|
||||
// If renders child only when condition is true.
|
||||
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
func If(cond func(*Context) bool, node Node) Node {
|
||||
return &ifNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *ifNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
|
|
@ -178,11 +246,15 @@ type unlessNode struct {
|
|||
}
|
||||
|
||||
// Unless renders child only when condition is false.
|
||||
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
func Unless(cond func(*Context) bool, node Node) Node {
|
||||
return &unlessNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *unlessNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
|
|
@ -197,12 +269,16 @@ type entitledNode struct {
|
|||
}
|
||||
|
||||
// Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
// Usage example: Entitled("beta", Text("preview"))
|
||||
// If no entitlement function is set on the context, access is denied by default.
|
||||
func Entitled(feature string, node Node) Node {
|
||||
return &entitledNode{feature: feature, node: node}
|
||||
}
|
||||
|
||||
func (n *entitledNode) Render(ctx *Context) string {
|
||||
if n == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -217,13 +293,23 @@ type switchNode struct {
|
|||
}
|
||||
|
||||
// Switch renders based on runtime selector value.
|
||||
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
||||
return &switchNode{selector: selector, cases: cases}
|
||||
}
|
||||
|
||||
func (n *switchNode) Render(ctx *Context) string {
|
||||
if n == nil || n.selector == nil {
|
||||
return ""
|
||||
}
|
||||
key := n.selector(ctx)
|
||||
if n.cases == nil {
|
||||
return ""
|
||||
}
|
||||
if node, ok := n.cases[key]; ok {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
return node.Render(ctx)
|
||||
}
|
||||
return ""
|
||||
|
|
@ -236,20 +322,49 @@ type eachNode[T any] struct {
|
|||
fn func(T) Node
|
||||
}
|
||||
|
||||
type attrApplier interface {
|
||||
applyAttr(key, value string)
|
||||
}
|
||||
|
||||
// Each iterates items and renders each via fn.
|
||||
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
func Each[T any](items []T, fn func(T) Node) Node {
|
||||
return EachSeq(slices.Values(items), fn)
|
||||
}
|
||||
|
||||
// EachSeq iterates an iter.Seq and renders each via fn.
|
||||
// Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
||||
return &eachNode[T]{items: items, fn: fn}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
return n.renderWithLayoutPath(ctx, "")
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) applyAttr(key, value string) {
|
||||
if n == nil || n.fn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
prev := n.fn
|
||||
n.fn = func(item T) Node {
|
||||
return Attr(prev(item), key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.fn == nil || n.items == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
for item := range n.items {
|
||||
b.WriteString(n.fn(item).Render(ctx))
|
||||
child := n.fn(item)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
224
node_test.go
224
node_test.go
|
|
@ -1,11 +1,13 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func TestRawNode_Render(t *testing.T) {
|
||||
func TestRawNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Raw("hello")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -14,7 +16,7 @@ func TestRawNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Render(t *testing.T) {
|
||||
func TestElNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -24,7 +26,7 @@ func TestElNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Nested(t *testing.T) {
|
||||
func TestElNode_Nested_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", El("span", Raw("inner")))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -34,7 +36,7 @@ func TestElNode_Nested(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleChildren(t *testing.T) {
|
||||
func TestElNode_MultipleChildren_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("a"), Raw("b"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -44,7 +46,7 @@ func TestElNode_MultipleChildren(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_VoidElement(t *testing.T) {
|
||||
func TestElNode_VoidElement_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("br")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -54,7 +56,7 @@ func TestElNode_VoidElement(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Render(t *testing.T) {
|
||||
func TestTextNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -63,19 +65,19 @@ func TestTextNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Escapes(t *testing.T) {
|
||||
func TestTextNode_Escapes_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("<script>alert('xss')</script>")
|
||||
got := node.Render(ctx)
|
||||
if strings.Contains(got, "<script>") {
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("Text node must HTML-escape output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("Text node should contain escaped script tag, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNode_True(t *testing.T) {
|
||||
func TestIfNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return true }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -84,7 +86,7 @@ func TestIfNode_True(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIfNode_False(t *testing.T) {
|
||||
func TestIfNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return false }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -93,7 +95,7 @@ func TestIfNode_False(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUnlessNode(t *testing.T) {
|
||||
func TestUnlessNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return false }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -102,7 +104,7 @@ func TestUnlessNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Granted(t *testing.T) {
|
||||
func TestEntitledNode_Granted_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
|
|
@ -112,7 +114,7 @@ func TestEntitledNode_Granted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Denied(t *testing.T) {
|
||||
func TestEntitledNode_Denied_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return false }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
|
|
@ -122,7 +124,7 @@ func TestEntitledNode_Denied(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_NoFunc(t *testing.T) {
|
||||
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -131,7 +133,7 @@ func TestEntitledNode_NoFunc(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEachNode(t *testing.T) {
|
||||
func TestEachNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := []string{"a", "b", "c"}
|
||||
node := Each(items, func(item string) Node {
|
||||
|
|
@ -144,7 +146,7 @@ func TestEachNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEachNode_Empty(t *testing.T) {
|
||||
func TestEachNode_Empty_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{}, func(item string) Node {
|
||||
return El("li", Raw(item))
|
||||
|
|
@ -155,7 +157,35 @@ func TestEachNode_Empty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Attr(t *testing.T) {
|
||||
func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := Each([]Node{inner}, func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("Each nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_Attr_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("content")), "class", "container")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -165,25 +195,70 @@ func TestElNode_Attr(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_AttrEscaping(t *testing.T) {
|
||||
func TestElNode_AttrEscaping_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("img"), "alt", `he said "hello"`)
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `alt="he said "hello""`) {
|
||||
if !containsText(got, `alt="he said "hello""`) {
|
||||
t.Errorf("Attr should escape attribute values, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleAttrs(t *testing.T) {
|
||||
func TestAriaLabel_Good(t *testing.T) {
|
||||
node := AriaLabel(El("button", Raw("save")), "Save changes")
|
||||
got := node.Render(NewContext())
|
||||
want := `<button aria-label="Save changes">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("AriaLabel() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltText_Good(t *testing.T) {
|
||||
node := AltText(El("img"), "Profile photo")
|
||||
got := node.Render(NewContext())
|
||||
want := `<img alt="Profile photo">`
|
||||
if got != want {
|
||||
t.Errorf("AltText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabIndex_Good(t *testing.T) {
|
||||
node := TabIndex(El("button", Raw("save")), 0)
|
||||
got := node.Render(NewContext())
|
||||
want := `<button tabindex="0">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("TabIndex() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocus_Good(t *testing.T) {
|
||||
node := AutoFocus(El("input"))
|
||||
got := node.Render(NewContext())
|
||||
want := `<input autofocus="autofocus">`
|
||||
if got != want {
|
||||
t.Errorf("AutoFocus() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_Good(t *testing.T) {
|
||||
node := Role(El("nav", Raw("links")), "navigation")
|
||||
got := node.Render(NewContext())
|
||||
want := `<nav role="navigation">links</nav>`
|
||||
if got != want {
|
||||
t.Errorf("Role() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleAttrs_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
|
||||
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
|
||||
t.Errorf("multiple Attr() calls should stack, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_NonElement(t *testing.T) {
|
||||
func TestAttr_NonElement_Ugly(t *testing.T) {
|
||||
node := Attr(Raw("text"), "class", "x")
|
||||
got := node.Render(NewContext())
|
||||
if got != "text" {
|
||||
|
|
@ -191,7 +266,106 @@ func TestAttr_NonElement(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode(t *testing.T) {
|
||||
func TestUnlessNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Unless(true) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughIfNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := If(func(*Context) bool { return true }, inner)
|
||||
Attr(node, "class", "wrapped")
|
||||
got := node.Render(ctx)
|
||||
want := `<div class="wrapped">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through If = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughUnlessNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Unless(func(*Context) bool { return false }, inner)
|
||||
Attr(node, "id", "test")
|
||||
got := node.Render(ctx)
|
||||
want := `<div id="test">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Unless = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEntitledNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(string) bool { return true }
|
||||
inner := El("div", Raw("content"))
|
||||
node := Entitled("feature", inner)
|
||||
Attr(node, "data-feat", "on")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-feat="on">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Entitled = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": El("span", Raw("unused")),
|
||||
})
|
||||
Attr(node, "data-state", "active")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-state="active">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Switch = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{"a", "b"}, func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "class", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span class="item">a</span><span class="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Each = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachSeqNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "data-kind", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span data-kind="item">a</span><span data-kind="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_WithService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
if got != "hello" {
|
||||
t.Errorf("Text with service context = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_SelectsMatch_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"dark": Raw("dark theme"),
|
||||
|
|
|
|||
21
path.go
21
path.go
|
|
@ -3,21 +3,26 @@ package html
|
|||
import "strings"
|
||||
|
||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
// Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
// "L-0-C-0" → ['L', 'C']
|
||||
func ParseBlockID(id string) []byte {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split on "-" and take every other element (the slot letters).
|
||||
// Format: "X-0" or "X-0-Y-0-Z-0"
|
||||
var slots []byte
|
||||
i := 0
|
||||
for part := range strings.SplitSeq(id, "-") {
|
||||
if i%2 == 0 && len(part) == 1 {
|
||||
slots = append(slots, part[0])
|
||||
// Valid IDs are exact sequences of "{slot}-0" segments, e.g.
|
||||
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID.
|
||||
parts := strings.Split(id, "-")
|
||||
if len(parts)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(parts)/2)
|
||||
for i := 0; i < len(parts); i += 2 {
|
||||
if len(parts[i]) != 1 || parts[i+1] != "0" {
|
||||
return nil
|
||||
}
|
||||
i++
|
||||
slots = append(slots, parts[i][0])
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
|
|
|||
30
path_test.go
30
path_test.go
|
|
@ -1,11 +1,10 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedLayout_PathChain(t *testing.T) {
|
||||
func TestNestedLayout_PathChain_Good(t *testing.T) {
|
||||
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
|
||||
outer := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -13,33 +12,33 @@ func TestNestedLayout_PathChain(t *testing.T) {
|
|||
|
||||
// Inner layout paths must be prefixed with parent block ID
|
||||
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("nested layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Outer layout must still have root-level paths
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("outer layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedLayout_DeepNesting(t *testing.T) {
|
||||
func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
|
||||
deepest := NewLayout("C").C(Raw("deep"))
|
||||
middle := NewLayout("C").C(deepest)
|
||||
outer := NewLayout("C").C(middle)
|
||||
got := outer.Render(NewContext())
|
||||
|
||||
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("deep nesting missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockID(t *testing.T) {
|
||||
func TestBlockID_BuildsPath_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
slot byte
|
||||
|
|
@ -60,7 +59,7 @@ func TestBlockID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseBlockID(t *testing.T) {
|
||||
func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
want []byte
|
||||
|
|
@ -84,3 +83,18 @@ func TestParseBlockID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockID_InvalidInput_Good(t *testing.T) {
|
||||
tests := []string{
|
||||
"L-1-C-0",
|
||||
"L-0-C",
|
||||
"L-0-",
|
||||
"X",
|
||||
}
|
||||
|
||||
for _, id := range tests {
|
||||
if got := ParseBlockID(id); got != nil {
|
||||
t.Errorf("ParseBlockID(%q) = %v, want nil", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
pipeline.go
22
pipeline.go
|
|
@ -3,16 +3,17 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n/reversal"
|
||||
"dappco.re/go/core/i18n/reversal"
|
||||
)
|
||||
|
||||
// StripTags removes HTML tags from rendered output, returning plain text.
|
||||
// Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
// Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
// Does not handle script/style element content (go-html does not generate these).
|
||||
func StripTags(html string) string {
|
||||
var b strings.Builder
|
||||
b := core.NewBuilder()
|
||||
inTag := false
|
||||
prevSpace := true // starts true to trim leading space
|
||||
for _, r := range html {
|
||||
|
|
@ -40,16 +41,20 @@ func StripTags(html string) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
return core.Trim(b.String())
|
||||
}
|
||||
|
||||
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
// and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
// Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
rendered := node.Render(ctx)
|
||||
rendered := ""
|
||||
if node != nil {
|
||||
rendered = node.Render(ctx)
|
||||
}
|
||||
text := StripTags(rendered)
|
||||
tok := reversal.NewTokeniser()
|
||||
tokens := tok.Tokenise(text)
|
||||
|
|
@ -58,10 +63,14 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
|||
|
||||
// CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
// and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
if r == nil {
|
||||
return make(map[string]float64)
|
||||
}
|
||||
|
||||
type named struct {
|
||||
name string
|
||||
|
|
@ -70,6 +79,9 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
|||
|
||||
var imprints []named
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
imp := Imprint(v.layout, ctx)
|
||||
imprints = append(imprints, named{name: v.name, imp: imp})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ package html
|
|||
import (
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestStripTags_Simple(t *testing.T) {
|
||||
func TestStripTags_Simple_Good(t *testing.T) {
|
||||
got := StripTags(`<div>hello</div>`)
|
||||
want := "hello"
|
||||
if got != want {
|
||||
|
|
@ -16,7 +16,7 @@ func TestStripTags_Simple(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Nested(t *testing.T) {
|
||||
func TestStripTags_Nested_Good(t *testing.T) {
|
||||
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
||||
want := "Title"
|
||||
if got != want {
|
||||
|
|
@ -24,7 +24,7 @@ func TestStripTags_Nested(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_MultipleRegions(t *testing.T) {
|
||||
func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
||||
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
||||
want := "Head Body Foot"
|
||||
if got != want {
|
||||
|
|
@ -32,21 +32,21 @@ func TestStripTags_MultipleRegions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Empty(t *testing.T) {
|
||||
func TestStripTags_Empty_Ugly(t *testing.T) {
|
||||
got := StripTags("")
|
||||
if got != "" {
|
||||
t.Errorf("StripTags(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_NoTags(t *testing.T) {
|
||||
func TestStripTags_NoTags_Good(t *testing.T) {
|
||||
got := StripTags("plain text")
|
||||
if got != "plain text" {
|
||||
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Entities(t *testing.T) {
|
||||
func TestStripTags_Entities_Good(t *testing.T) {
|
||||
got := StripTags(`<script>`)
|
||||
want := "<script>"
|
||||
if got != want {
|
||||
|
|
@ -54,7 +54,7 @@ func TestStripTags_Entities(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_FromNode(t *testing.T) {
|
||||
func TestImprint_FromNode_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -74,7 +74,7 @@ func TestImprint_FromNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_SimilarPages(t *testing.T) {
|
||||
func TestImprint_SimilarPages_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -102,7 +102,7 @@ func TestImprint_SimilarPages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants(t *testing.T) {
|
||||
func TestCompareVariants_SameContent_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package html
|
||||
|
||||
// Render is a convenience function that renders a node tree to HTML.
|
||||
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
func Render(node Node, ctx *Context) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestRender_FullPage(t *testing.T) {
|
||||
func TestRender_FullPage_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -28,14 +27,14 @@ func TestRender_FullPage(t *testing.T) {
|
|||
|
||||
// Contains semantic elements
|
||||
for _, want := range []string{"<header", "<main", "<footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Content rendered
|
||||
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,13 +43,13 @@ func TestRender_FullPage(t *testing.T) {
|
|||
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
|
||||
open := "<" + tag
|
||||
close := "</" + tag + ">"
|
||||
if strings.Count(got, open) != strings.Count(got, close) {
|
||||
if countText(got, open) != countText(got, close) {
|
||||
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_EntitlementGating(t *testing.T) {
|
||||
func TestRender_EntitlementGating_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -67,18 +66,18 @@ func TestRender_EntitlementGating(t *testing.T) {
|
|||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "public") {
|
||||
if !containsText(got, "public") {
|
||||
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "admin-panel") {
|
||||
if !containsText(got, "admin-panel") {
|
||||
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "premium-content") {
|
||||
if containsText(got, "premium-content") {
|
||||
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XSSPrevention(t *testing.T) {
|
||||
func TestRender_XSSPrevention_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -88,10 +87,10 @@ func TestRender_XSSPrevention(t *testing.T) {
|
|||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if strings.Contains(got, "<script>") {
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
package html
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Responsive)(nil)
|
||||
|
||||
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
// Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
type Responsive struct {
|
||||
variants []responsiveVariant
|
||||
|
|
@ -14,21 +21,38 @@ type responsiveVariant struct {
|
|||
}
|
||||
|
||||
// NewResponsive creates a new multi-variant responsive compositor.
|
||||
// Usage example: r := NewResponsive()
|
||||
func NewResponsive() *Responsive {
|
||||
return &Responsive{}
|
||||
}
|
||||
|
||||
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
// Variants render in insertion order.
|
||||
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
||||
if r == nil {
|
||||
r = NewResponsive()
|
||||
}
|
||||
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
||||
return r
|
||||
}
|
||||
|
||||
// Render produces HTML with each variant in a data-variant container.
|
||||
// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
func (r *Responsive) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString(`<div data-variant="`)
|
||||
b.WriteString(escapeAttr(v.name))
|
||||
b.WriteString(`">`)
|
||||
|
|
@ -37,3 +61,36 @@ func (r *Responsive) Render(ctx *Context) string {
|
|||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// VariantSelector returns a CSS attribute selector for a responsive variant.
|
||||
// Usage example: selector := VariantSelector("desktop")
|
||||
func VariantSelector(name string) string {
|
||||
return `[data-variant="` + escapeCSSString(name) + `"]`
|
||||
}
|
||||
|
||||
func escapeCSSString(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '"':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if r < 0x20 || r == 0x7f {
|
||||
b.WriteByte('\\')
|
||||
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
|
||||
for i := 0; i < len(esc); i++ {
|
||||
b.WriteByte(esc[i])
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,25 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponsive_SingleVariant(t *testing.T) {
|
||||
func TestResponsive_SingleVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||
if !containsText(got, `data-variant="desktop"`) {
|
||||
t.Errorf("responsive should contain data-variant, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-block="H-0"`) {
|
||||
if !containsText(got, `data-block="H-0"`) {
|
||||
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_MultiVariant(t *testing.T) {
|
||||
func TestResponsive_MultiVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||
|
|
@ -30,13 +29,13 @@ func TestResponsive_MultiVariant(t *testing.T) {
|
|||
got := r.Render(ctx)
|
||||
|
||||
for _, v := range []string{"desktop", "tablet", "mobile"} {
|
||||
if !strings.Contains(got, `data-variant="`+v+`"`) {
|
||||
if !containsText(got, `data-variant="`+v+`"`) {
|
||||
t.Errorf("responsive missing variant %q in:\n%s", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantOrder(t *testing.T) {
|
||||
func TestResponsive_VariantOrder_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
|
||||
|
|
@ -44,8 +43,8 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
di := strings.Index(got, `data-variant="desktop"`)
|
||||
mi := strings.Index(got, `data-variant="mobile"`)
|
||||
di := indexText(got, `data-variant="desktop"`)
|
||||
mi := indexText(got, `data-variant="mobile"`)
|
||||
if di < 0 || mi < 0 {
|
||||
t.Fatalf("missing variants in:\n%s", got)
|
||||
}
|
||||
|
|
@ -54,7 +53,7 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResponsive_NestedPaths(t *testing.T) {
|
||||
func TestResponsive_NestedPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||
r := NewResponsive().
|
||||
|
|
@ -62,15 +61,15 @@ func TestResponsive_NestedPaths(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, `data-block="C-0-H-0"`) {
|
||||
if !containsText(got, `data-block="C-0-H-0"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantsIndependent(t *testing.T) {
|
||||
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
||||
|
|
@ -78,12 +77,60 @@ func TestResponsive_VariantsIndependent(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
count := strings.Count(got, `data-block="C-0"`)
|
||||
count := countText(got, `data-block="C-0"`)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_ImplementsNode(t *testing.T) {
|
||||
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
|
||||
var _ Node = NewResponsive()
|
||||
}
|
||||
|
||||
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
|
||||
var r *Responsive
|
||||
|
||||
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil responsive from Variant on nil receiver")
|
||||
}
|
||||
|
||||
if output := got.Render(NewContext()); output != `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>` {
|
||||
t.Fatalf("unexpected output from nil receiver Variant path: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_Render_NilContext_Good(t *testing.T) {
|
||||
r := NewResponsive().
|
||||
Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
|
||||
got := r.Render(nil)
|
||||
want := `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>`
|
||||
if got != want {
|
||||
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Good(t *testing.T) {
|
||||
got := VariantSelector("desktop")
|
||||
want := `[data-variant="desktop"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Escapes_Good(t *testing.T) {
|
||||
got := VariantSelector("desk\"top\\wide")
|
||||
want := `[data-variant="desk\"top\\wide"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
|
||||
got := VariantSelector("a\tb\nc\u0007")
|
||||
want := `[data-variant="a\9 b\A c\7 "]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
specs/cmd/codegen.md
Normal file
11
specs/cmd/codegen.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/codegen`
|
||||
**Files:** 1
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
11
specs/cmd/wasm.md
Normal file
11
specs/cmd/wasm.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/wasm`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
34
specs/codegen.md
Normal file
34
specs/codegen.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
34
specs/codegen/RFC.md
Normal file
34
specs/codegen/RFC.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
225
specs/root.md
Normal file
225
specs/root.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# html
|
||||
**Import:** `dappco.re/go/core/html`
|
||||
**Files:** 13
|
||||
|
||||
## Types
|
||||
|
||||
### `Context`
|
||||
`type Context struct`
|
||||
|
||||
Context carries rendering state through the node tree.
|
||||
Usage example: ctx := NewContext()
|
||||
|
||||
Fields:
|
||||
- `Identity string`
|
||||
- `Locale string`
|
||||
- `Entitlements func(feature string) bool`
|
||||
- `Data map[string]any`
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Layout`
|
||||
`type Layout struct`
|
||||
|
||||
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
with deterministic path-based IDs.
|
||||
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (l *Layout) C(nodes ...Node) *Layout`
|
||||
C appends nodes to the Content (main) slot.
|
||||
Usage example: NewLayout("C").C(Text("body"))
|
||||
- `func (l *Layout) F(nodes ...Node) *Layout`
|
||||
F appends nodes to the Footer slot.
|
||||
Usage example: NewLayout("CF").F(Text("footer"))
|
||||
- `func (l *Layout) H(nodes ...Node) *Layout`
|
||||
H appends nodes to the Header slot.
|
||||
Usage example: NewLayout("HCF").H(Text("title"))
|
||||
- `func (l *Layout) L(nodes ...Node) *Layout`
|
||||
L appends nodes to the Left aside slot.
|
||||
Usage example: NewLayout("LC").L(Text("nav"))
|
||||
- `func (l *Layout) R(nodes ...Node) *Layout`
|
||||
R appends nodes to the Right aside slot.
|
||||
Usage example: NewLayout("CR").R(Text("ads"))
|
||||
- `func (l *Layout) Render(ctx *Context) string`
|
||||
Render produces the semantic HTML for this layout.
|
||||
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||
Only slots present in the variant string are rendered.
|
||||
|
||||
### `Node`
|
||||
`type Node interface`
|
||||
|
||||
Node is anything renderable.
|
||||
Usage example: var n Node = El("div", Text("welcome"))
|
||||
|
||||
Members:
|
||||
- `Render(ctx *Context) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Responsive`
|
||||
`type Responsive struct`
|
||||
|
||||
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (r *Responsive) Render(ctx *Context) string`
|
||||
Render produces HTML with each variant in a data-variant container.
|
||||
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
|
||||
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
Variants render in insertion order.
|
||||
|
||||
### `Translator`
|
||||
`type Translator interface`
|
||||
|
||||
Translator provides Text() lookups for a rendering context.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
can provide any implementation with the same T() method.
|
||||
|
||||
Members:
|
||||
- `T(key string, args ...any) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `Attr`
|
||||
`func Attr(n Node, key, value string) Node`
|
||||
|
||||
Attr sets an attribute on an El node. Returns the node for chaining.
|
||||
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||
It recursively traverses through wrappers like If, Unless, and Entitled.
|
||||
|
||||
### `CompareVariants`
|
||||
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
|
||||
|
||||
CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
|
||||
### `Each`
|
||||
`func Each[T any](items []T, fn func(T) Node) Node`
|
||||
|
||||
Each iterates items and renders each via fn.
|
||||
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
|
||||
### `EachSeq`
|
||||
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
|
||||
|
||||
EachSeq iterates an iter.Seq and renders each via fn.
|
||||
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
|
||||
### `El`
|
||||
`func El(tag string, children ...Node) Node`
|
||||
|
||||
El creates an HTML element node with children.
|
||||
Usage example: El("section", Text("welcome"))
|
||||
|
||||
### `Entitled`
|
||||
`func Entitled(feature string, node Node) Node`
|
||||
|
||||
Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
Usage example: Entitled("beta", Text("preview"))
|
||||
If no entitlement function is set on the context, access is denied by default.
|
||||
|
||||
### `If`
|
||||
`func If(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
If renders child only when condition is true.
|
||||
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
|
||||
### `Imprint`
|
||||
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
|
||||
|
||||
Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContext`
|
||||
`func NewContext() *Context`
|
||||
|
||||
NewContext creates a new rendering context with sensible defaults.
|
||||
Usage example: html := Render(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContextWithService`
|
||||
`func NewContextWithService(svc Translator) *Context`
|
||||
|
||||
NewContextWithService creates a rendering context backed by a specific translator.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
### `NewLayout`
|
||||
`func NewLayout(variant string) *Layout`
|
||||
|
||||
NewLayout creates a new Layout with the given variant string.
|
||||
Usage example: page := NewLayout("HLCRF")
|
||||
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
|
||||
### `NewResponsive`
|
||||
`func NewResponsive() *Responsive`
|
||||
|
||||
NewResponsive creates a new multi-variant responsive compositor.
|
||||
Usage example: r := NewResponsive()
|
||||
|
||||
### `ParseBlockID`
|
||||
`func ParseBlockID(id string) []byte`
|
||||
|
||||
ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
"L-0-C-0" → ['L', 'C']
|
||||
|
||||
### `Raw`
|
||||
`func Raw(content string) Node`
|
||||
|
||||
Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
Usage example: Raw("<strong>trusted</strong>")
|
||||
|
||||
### `Render`
|
||||
`func Render(node Node, ctx *Context) string`
|
||||
|
||||
Render is a convenience function that renders a node tree to HTML.
|
||||
Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
|
||||
### `StripTags`
|
||||
`func StripTags(html string) string`
|
||||
|
||||
StripTags removes HTML tags from rendered output, returning plain text.
|
||||
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
Does not handle script/style element content (go-html does not generate these).
|
||||
|
||||
### `Switch`
|
||||
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
|
||||
|
||||
Switch renders based on runtime selector value.
|
||||
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
|
||||
### `Text`
|
||||
`func Text(key string, args ...any) Node`
|
||||
|
||||
Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
Usage example: Text("welcome", "Ada")
|
||||
Output is HTML-escaped by default. Safe-by-default path.
|
||||
|
||||
### `Unless`
|
||||
`func Unless(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
Unless renders child only when condition is false.
|
||||
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
48
test_helpers_test.go
Normal file
48
test_helpers_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
func containsText(s, substr string) bool {
|
||||
return core.Contains(s, substr)
|
||||
}
|
||||
|
||||
func countText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexText(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func itoaText(v int) string {
|
||||
return core.Sprint(v)
|
||||
}
|
||||
38
text_builder_default.go
Normal file
38
text_builder_default.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
type builderOps interface {
|
||||
WriteByte(byte) error
|
||||
WriteRune(rune) (int, error)
|
||||
WriteString(string) (int, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
type textBuilder struct {
|
||||
inner builderOps
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{inner: core.NewBuilder()}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
return b.inner.WriteByte(c)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
return b.inner.WriteRune(r)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
return b.inner.WriteString(s)
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return b.inner.String()
|
||||
}
|
||||
33
text_builder_js.go
Normal file
33
text_builder_js.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//go:build js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
type textBuilder struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{buf: make([]byte, 0, 128)}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
b.buf = append(b.buf, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
s := string(r)
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return string(b.buf)
|
||||
}
|
||||
11
text_translate.go
Normal file
11
text_translate.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
func translateText(ctx *Context, key string, args ...any) string {
|
||||
if ctx != nil && ctx.service != nil {
|
||||
return ctx.service.T(key, args...)
|
||||
}
|
||||
|
||||
return translateDefault(key, args...)
|
||||
}
|
||||
11
text_translate_default.go
Normal file
11
text_translate_default.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import i18n "dappco.re/go/core/i18n"
|
||||
|
||||
func translateDefault(key string, args ...any) string {
|
||||
return i18n.T(key, args...)
|
||||
}
|
||||
9
text_translate_js.go
Normal file
9
text_translate_js.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//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