Compare commits
125 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d710aadb1 | ||
|
|
18765404ef | ||
|
|
2a16ce6717 | ||
|
|
2a5e2ee5a3 | ||
|
|
b3b44ae432 | ||
|
|
436bd3716f | ||
|
|
9230d3b66c | ||
|
|
daaae16493 | ||
|
|
acaf9d83a0 | ||
|
|
68874bed0b | ||
|
|
e974a153a7 | ||
|
|
6e22cc1f7f | ||
|
|
3bc82e27ce | ||
|
|
799317b788 | ||
|
|
5fc370b691 | ||
|
|
7cbf678738 | ||
|
|
575150d686 | ||
|
|
030a41f732 | ||
|
|
87ebb7958d | ||
|
|
bfacf35c81 | ||
|
|
f3c2bb1ca7 | ||
|
|
cc75f3b533 | ||
|
|
2e8886bbd7 | ||
|
|
d12408ffb9 | ||
|
|
a4d2341211 | ||
|
|
c1e7cfaab0 | ||
|
|
dc00e10ec0 | ||
|
|
9741659442 | ||
|
|
f828848cc0 | ||
|
|
3fa2a86664 | ||
|
|
9b7626eb91 | ||
|
|
c56924d95c | ||
|
|
9a90819aaf | ||
|
|
5b69aec575 | ||
|
|
ad01f04a51 | ||
|
|
19ab9d246a | ||
|
|
831a47f1d3 | ||
|
|
0ecef2e72f | ||
|
|
de3a40000c | ||
|
|
94833edf43 | ||
|
|
bf5ec80c8b | ||
|
|
9c0a925876 | ||
|
|
82ddc736a9 | ||
|
|
fde2f9b884 | ||
|
|
b6120a1929 | ||
|
|
cb7d45f21b | ||
|
|
aa6e064247 | ||
|
|
a184549013 | ||
|
|
8900d25cfe | ||
|
|
b5d170817c | ||
|
|
967182a676 | ||
|
|
850dbdb0b6 | ||
|
|
c6bca226a9 | ||
|
|
1134683e1b | ||
|
|
9806dea825 | ||
|
|
9b620cf673 | ||
|
|
fd1f7cea74 | ||
|
|
24565df459 | ||
|
|
cafa24163d | ||
|
|
94eb419914 | ||
|
|
9def509187 | ||
|
|
3c64352a3b | ||
|
|
3375ad0b22 | ||
|
|
3fcd8daf59 | ||
|
|
cb75de9bf3 | ||
|
|
d74b65913a | ||
|
|
669163fcd6 | ||
|
|
113dab6635 | ||
|
|
0716fe991f | ||
|
|
89d2870e20 | ||
|
|
2ce8876cb5 | ||
|
|
511a10f54b | ||
|
|
1958cc79b1 | ||
|
|
7b95c1fc74 | ||
|
|
6f65fc903c | ||
|
|
3d841efa12 | ||
|
|
667965da19 | ||
|
|
2b796e57eb | ||
|
|
a388848626 | ||
|
|
30f64a3d59 | ||
|
|
a7433675ba | ||
|
|
7091a5f341 | ||
|
|
ec18122233 | ||
|
|
a5e02f6472 | ||
|
|
a925142e4e | ||
|
|
ec2ccc7653 | ||
|
|
c240116c1d | ||
|
|
0fcffb029d | ||
|
|
e8d2a7f7e7 | ||
|
|
ae286563fd | ||
|
|
aa00f27db4 | ||
|
|
aa282056fa | ||
|
|
d4cacb80ec | ||
|
|
957bc85c64 | ||
|
|
a029931f76 | ||
|
|
4c0669ef1a | ||
|
|
d0e7f60dab | ||
|
|
4d767fa0bd | ||
|
|
9721c23202 | ||
|
|
edb53a4a29 | ||
|
|
e4ee677bb7 | ||
|
|
f8558a52ef | ||
|
|
25dc761d0b | ||
|
|
c84bd21cf4 | ||
|
|
a86c8ef770 | ||
|
|
264ecc3f84 | ||
|
|
97a48fc73d | ||
|
|
25d809fc88 | ||
|
|
f7843ae180 | ||
|
|
1d71ac4676 | ||
|
|
7814f669fd | ||
|
|
46a8b7e904 | ||
|
|
8dfce51659 | ||
|
|
ba384aeb12 | ||
|
|
3d2fdf4e22 | ||
|
|
afa0337bbd | ||
|
|
58380d3d87 | ||
|
|
56bd6638db | ||
|
|
8bf49c8935 | ||
|
|
8682eeb929 | ||
|
|
84ad59cd09 | ||
|
|
149d31b140 | ||
|
|
48884f7974 | ||
|
|
739f1f52fc | ||
|
|
baca8f26cf |
62 changed files with 4746 additions and 1763 deletions
|
|
@ -27,7 +27,7 @@ See `docs/architecture.md` for full detail. Summary:
|
||||||
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order
|
- **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)
|
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only)
|
||||||
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
|
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
|
||||||
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
|
- **WASM**: `cmd/wasm/` exports `renderToString()` and `registerComponents()` — size gate: < 3.5 MB raw, < 1 MB gzip
|
||||||
|
|
||||||
## Server/Client Split
|
## Server/Client Split
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# go-html
|
# go-html
|
||||||
|
|
||||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()` and `registerComponents()`.
|
||||||
|
|
||||||
**Module**: `forge.lthn.ai/core/go-html`
|
**Module**: `forge.lthn.ai/core/go-html`
|
||||||
**Licence**: EUPL-1.2
|
**Licence**: EUPL-1.2
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
|
@ -99,7 +100,7 @@ func BenchmarkImprint_Small(b *testing.B) {
|
||||||
func BenchmarkImprint_Large(b *testing.B) {
|
func BenchmarkImprint_Large(b *testing.B) {
|
||||||
items := make([]string, 20)
|
items := make([]string, 20)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
items[i] = "Item " + itoaText(i) + " was created successfully"
|
items[i] = fmt.Sprintf("Item %d was created successfully", i)
|
||||||
}
|
}
|
||||||
page := NewLayout("HLCRF").
|
page := NewLayout("HLCRF").
|
||||||
H(El("h1", Text("Building project"))).
|
H(El("h1", Text("Building project"))).
|
||||||
|
|
@ -206,7 +207,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
|
||||||
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
||||||
nodes := make([]Node, 50)
|
nodes := make([]Node, 50)
|
||||||
for i := range nodes {
|
for i := range nodes {
|
||||||
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
|
nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
|
||||||
}
|
}
|
||||||
layout := NewLayout("HLCRF").
|
layout := NewLayout("HLCRF").
|
||||||
H(Raw("header")).
|
H(Raw("header")).
|
||||||
|
|
@ -241,7 +242,7 @@ func benchEach(b *testing.B, n int) {
|
||||||
items[i] = i
|
items[i] = i
|
||||||
}
|
}
|
||||||
node := Each(items, func(i int) Node {
|
node := Each(items, func(i int) Node {
|
||||||
return El("li", Raw("item-"+itoaText(i)))
|
return El("li", Raw(fmt.Sprintf("item-%d", i)))
|
||||||
})
|
})
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
//go:build !js
|
//go:build !js
|
||||||
|
|
||||||
// Package main provides a build-time CLI for generating Web Component bundles.
|
// Package main provides a build-time CLI for generating Web Component JS bundles.
|
||||||
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
|
// Reads a JSON slot map from stdin, writes the generated JS to stdout, and can
|
||||||
|
// optionally watch a slot file and rewrite an output bundle on change.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
||||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
|
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -dts > components.d.ts
|
||||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
// echo '{"H":"nav-bar","C":"main-content"}' > slots.json
|
||||||
|
// go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
goio "io"
|
goio "io"
|
||||||
|
|
@ -19,31 +22,26 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/html/codegen"
|
"dappco.re/go/core/html/codegen"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
log "dappco.re/go/core/log"
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generate(data []byte, emitTypes bool) (string, error) {
|
var emitTypeDefinitions = flag.Bool("dts", false, "emit TypeScript declarations instead of JavaScript")
|
||||||
var slots map[string]string
|
var watchMode = flag.Bool("watch", false, "poll an input file and rewrite an output bundle when it changes")
|
||||||
if result := core.JSONUnmarshal(data, &slots); !result.OK {
|
var watchInputPath = flag.String("input", "", "path to the JSON slot map used by -watch")
|
||||||
err, _ := result.Value.(error)
|
var watchOutputPath = flag.String("output", "", "path to the generated bundle written by -watch")
|
||||||
return "", log.E("codegen", "invalid JSON", err)
|
var watchPollInterval = flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
||||||
}
|
|
||||||
|
|
||||||
if emitTypes {
|
func run(r goio.Reader, w goio.Writer) error {
|
||||||
return codegen.GenerateTypeScriptDefinitions(slots), nil
|
return runWithMode(r, w, false)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func runTypeDefinitions(r goio.Reader, w goio.Writer) error {
|
||||||
|
return runWithMode(r, w, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithMode(r goio.Reader, w goio.Writer, emitTypes bool) error {
|
||||||
data, err := goio.ReadAll(r)
|
data, err := goio.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return log.E("codegen", "reading stdin", err)
|
return log.E("codegen", "reading stdin", err)
|
||||||
|
|
@ -55,10 +53,20 @@ func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = goio.WriteString(w, out)
|
_, err = goio.WriteString(w, out)
|
||||||
if err != nil {
|
return err
|
||||||
return log.E("codegen", "writing output", err)
|
}
|
||||||
|
|
||||||
|
func generate(data []byte, emitTypes bool) (string, error) {
|
||||||
|
var slots map[string]string
|
||||||
|
if err := json.Unmarshal(data, &slots); err != nil {
|
||||||
|
return "", log.E("codegen", "invalid JSON", err)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
if emitTypes {
|
||||||
|
return codegen.GenerateTypeDefinitions(slots)
|
||||||
|
}
|
||||||
|
|
||||||
|
return codegen.GenerateBundle(slots)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
|
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
|
||||||
|
|
@ -72,22 +80,43 @@ func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool
|
||||||
pollInterval = 250 * time.Millisecond
|
pollInterval = 250 * time.Millisecond
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastInput []byte
|
var lastInput string
|
||||||
|
var lastOutput string
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
input, err := readLocalFile(inputPath)
|
input, err := coreio.Local.Read(inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
return log.E("codegen", "reading input file", err)
|
return log.E("codegen", "reading input file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sameBytes(input, lastInput) {
|
if input != lastInput {
|
||||||
out, err := generate(input, emitTypes)
|
out, err := generate([]byte(input), emitTypes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
// Watch mode should keep running through transient bad edits.
|
||||||
|
log.Error("codegen watch skipped invalid input", "err", err)
|
||||||
|
lastInput = input
|
||||||
|
} else {
|
||||||
|
if out != lastOutput {
|
||||||
|
if err := coreio.Local.Write(outputPath, out); err != nil {
|
||||||
|
return log.E("codegen", "writing output file", err)
|
||||||
|
}
|
||||||
|
lastOutput = out
|
||||||
|
}
|
||||||
|
lastInput = input
|
||||||
}
|
}
|
||||||
if err := writeLocalFile(outputPath, out); err != nil {
|
|
||||||
return log.E("codegen", "writing output file", err)
|
|
||||||
}
|
|
||||||
lastInput = append(lastInput[:0], input...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
@ -96,86 +125,29 @@ func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(pollInterval):
|
case <-ticker.C:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
func main() {
|
||||||
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
|
|
||||||
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
|
|
||||||
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
|
|
||||||
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
|
|
||||||
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *emitWatch {
|
var err error
|
||||||
|
if *watchMode {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
|
err = runDaemon(ctx, *watchInputPath, *watchOutputPath, *emitTypeDefinitions, *watchPollInterval)
|
||||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
} else {
|
||||||
os.Exit(1)
|
if *emitTypeDefinitions {
|
||||||
|
err = runTypeDefinitions(os.Stdin, os.Stdout)
|
||||||
|
} else {
|
||||||
|
err = run(os.Stdin, os.Stdout)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stdin, err := coreio.Local.Open("/dev/stdin")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
|
log.Error("codegen failed", "err", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout, err := coreio.Local.Create("/dev/stdout")
|
|
||||||
if err != nil {
|
|
||||||
_ = stdin.Close()
|
|
||||||
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = stdin.Close()
|
|
||||||
_ = stdout.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := run(stdin, stdout, *emitTypes); err != nil {
|
|
||||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,90 +3,101 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
goio "io"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRun_WritesBundle_Good(t *testing.T) {
|
func TestRun_Good(t *testing.T) {
|
||||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||||
output := core.NewBuilder()
|
var output bytes.Buffer
|
||||||
|
|
||||||
err := run(input, output, false)
|
err := run(input, &output)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
js := output.String()
|
js := output.String()
|
||||||
assert.Contains(t, js, "NavBar")
|
assert.Contains(t, js, "NavBar")
|
||||||
assert.Contains(t, js, "MainContent")
|
assert.Contains(t, js, "MainContent")
|
||||||
assert.Contains(t, js, "customElements.define")
|
assert.Contains(t, js, "customElements.define")
|
||||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_InvalidJSON_Bad(t *testing.T) {
|
func TestRunTypeDefinitions_Good(t *testing.T) {
|
||||||
input := core.NewReader(`not json`)
|
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||||
output := core.NewBuilder()
|
var output bytes.Buffer
|
||||||
|
|
||||||
err := run(input, output, false)
|
err := runTypeDefinitions(input, &output)
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "invalid JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_InvalidTag_Bad(t *testing.T) {
|
|
||||||
input := core.NewReader(`{"H":"notag"}`)
|
|
||||||
output := core.NewBuilder()
|
|
||||||
|
|
||||||
err := run(input, output, false)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "hyphen")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
|
|
||||||
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
|
|
||||||
output := core.NewBuilder()
|
|
||||||
|
|
||||||
err := run(input, output, false)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "lowercase hyphenated name")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_EmptySlots_Good(t *testing.T) {
|
|
||||||
input := core.NewReader(`{}`)
|
|
||||||
output := core.NewBuilder()
|
|
||||||
|
|
||||||
err := run(input, output, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, output.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
|
|
||||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
|
||||||
output := core.NewBuilder()
|
|
||||||
|
|
||||||
err := run(input, output, true)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dts := output.String()
|
dts := output.String()
|
||||||
assert.Contains(t, dts, "declare global")
|
assert.Contains(t, dts, "declare global")
|
||||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
assert.Contains(t, dts, `"main-content": MainContent;`)
|
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||||
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
assert.Contains(t, dts, "export {};")
|
||||||
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
func TestRun_Bad_InvalidJSON(t *testing.T) {
|
||||||
dir := t.TempDir()
|
input := strings.NewReader(`not json`)
|
||||||
inputPath := filepath.Join(dir, "slots.json")
|
var output bytes.Buffer
|
||||||
outputPath := filepath.Join(dir, "bundle.js")
|
|
||||||
|
|
||||||
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
err := run(input, &output)
|
||||||
|
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
|
||||||
|
|
||||||
|
err := run(input, &output)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hyphen")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_Bad_InvalidSlotKey(t *testing.T) {
|
||||||
|
input := strings.NewReader(`{"X":"nav-bar"}`)
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := run(input, &output)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid slot key")
|
||||||
|
assert.Contains(t, err.Error(), `"X"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTypeDefinitions_SkipsInvalidTags(t *testing.T) {
|
||||||
|
input := strings.NewReader(`{"H":"nav-bar","C":"Nav-Bar","F":"nav bar"}`)
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := runTypeDefinitions(input, &output)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dts := output.String()
|
||||||
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
|
assert.NotContains(t, dts, "Nav-Bar")
|
||||||
|
assert.NotContains(t, dts, "nav bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_Good_Empty(t *testing.T) {
|
||||||
|
input := strings.NewReader(`{}`)
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := run(input, &output)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, output.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDaemon_WritesBundle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inputPath := dir + "/slots.json"
|
||||||
|
outputPath := dir + "/bundle.js"
|
||||||
|
|
||||||
|
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -97,7 +108,7 @@ func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
require.Eventually(t, func() bool {
|
||||||
got, err := readTextFile(outputPath)
|
got, err := readTestFile(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -108,70 +119,76 @@ func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||||
require.NoError(t, <-done)
|
require.NoError(t, <-done)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
|
func TestRunDaemon_RecoversFromInvalidJSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inputPath := dir + "/slots.json"
|
||||||
|
outputPath := dir + "/bundle.js"
|
||||||
|
|
||||||
|
require.NoError(t, writeTestFile(inputPath, `not json`))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
got, err := readTestFile(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_RecoversFromMissingInputFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inputPath := dir + "/slots.json"
|
||||||
|
outputPath := dir + "/bundle.js"
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
got, err := readTestFile(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(t *testing.T) {
|
||||||
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "watch mode requires -input")
|
assert.Contains(t, err.Error(), "watch mode requires -input")
|
||||||
}
|
}
|
||||||
|
|
||||||
func countSubstr(s, substr string) int {
|
func writeTestFile(path, content string) error {
|
||||||
if substr == "" {
|
return os.WriteFile(path, []byte(content), 0o600)
|
||||||
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 {
|
func readTestFile(path string) (string, error) {
|
||||||
if substr == "" {
|
data, err := os.ReadFile(path)
|
||||||
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
cmd/wasm/components.go
Normal file
83
cmd/wasm/components.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"}
|
||||||
|
var reservedCustomElementTags = map[string]struct{}{
|
||||||
|
"annotation-xml": {},
|
||||||
|
"color-profile": {},
|
||||||
|
"font-face": {},
|
||||||
|
"font-face-src": {},
|
||||||
|
"font-face-uri": {},
|
||||||
|
"font-face-format": {},
|
||||||
|
"font-face-name": {},
|
||||||
|
"missing-glyph": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd/wasm/components.go: isValidCustomElementTag reports whether tag is a safe
|
||||||
|
// custom element name.
|
||||||
|
// It mirrors the codegen package validation without importing the heavier
|
||||||
|
// template and logging dependencies into the WASM-linked path.
|
||||||
|
func isValidCustomElementTag(tag string) bool {
|
||||||
|
if tag == "" || !strings.Contains(tag, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tag[0] < 'a' || tag[0] > 'z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, reserved := reservedCustomElementTags[tag]; reserved {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd/wasm/components.go: tagToClassName converts a kebab-case tag into a
|
||||||
|
// PascalCase class name.
|
||||||
|
// Example: tagToClassName("nav-bar") returns NavBar.
|
||||||
|
func tagToClassName(tag string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for part := range strings.SplitSeq(tag, "-") {
|
||||||
|
if len(part) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(strings.ToUpper(part[:1]))
|
||||||
|
b.WriteString(part[1:])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd/wasm/components.go: jsStringLiteral returns a quoted JavaScript string literal.
|
||||||
|
func jsStringLiteral(s string) string {
|
||||||
|
return strconv.Quote(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd/wasm/components.go: customElementClassSource returns a JavaScript class
|
||||||
|
// expression that mirrors the codegen bundle's closed-shadow custom element
|
||||||
|
// behaviour.
|
||||||
|
func customElementClassSource(tag, slot string) string {
|
||||||
|
className := tagToClassName(tag)
|
||||||
|
return "class " + className + " extends HTMLElement {" +
|
||||||
|
"#shadow;" +
|
||||||
|
"constructor(){super();this.#shadow=this.attachShadow({mode:\"closed\"});}" +
|
||||||
|
"connectedCallback(){this.#shadow.textContent=\"\";const slot=this.getAttribute(\"data-slot\")||" + jsStringLiteral(slot) + ";" +
|
||||||
|
"this.dispatchEvent(new CustomEvent(\"wc-ready\",{detail:{tag:" + jsStringLiteral(tag) + ",slot},bubbles:true,composed:true}));}" +
|
||||||
|
"render(html){const tpl=document.createElement(\"template\");tpl.insertAdjacentHTML(\"afterbegin\",html);" +
|
||||||
|
"this.#shadow.textContent=\"\";this.#shadow.appendChild(tpl.content.cloneNode(true));}" +
|
||||||
|
"}"
|
||||||
|
}
|
||||||
|
|
@ -13,50 +13,78 @@ import (
|
||||||
// This is intentional: the WASM module is a rendering engine for trusted content
|
// This is intentional: the WASM module is a rendering engine for trusted content
|
||||||
// produced server-side or by the application's own templates.
|
// produced server-side or by the application's own templates.
|
||||||
func renderToString(_ js.Value, args []js.Value) any {
|
func renderToString(_ js.Value, args []js.Value) any {
|
||||||
if len(args) < 1 || args[0].Type() != js.TypeString {
|
if len(args) < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if args[0].Type() != js.TypeString {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
variant := args[0].String()
|
variant := args[0].String()
|
||||||
if variant == "" {
|
locale := ""
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := html.NewContext()
|
|
||||||
|
|
||||||
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
||||||
ctx.SetLocale(args[1].String())
|
locale = args[1].String()
|
||||||
}
|
}
|
||||||
|
slots := make(map[string]html.Node)
|
||||||
layout := html.NewLayout(variant)
|
|
||||||
|
|
||||||
if len(args) >= 3 && args[2].Type() == js.TypeObject {
|
if len(args) >= 3 && args[2].Type() == js.TypeObject {
|
||||||
slots := args[2]
|
jsSlots := args[2]
|
||||||
for _, slot := range []string{"H", "L", "C", "R", "F"} {
|
for _, slot := range []string{"H", "L", "C", "R", "F"} {
|
||||||
content := slots.Get(slot)
|
content := jsSlots.Get(slot)
|
||||||
if content.Type() == js.TypeString && content.String() != "" {
|
if content.Type() == js.TypeString && content.String() != "" {
|
||||||
switch slot {
|
slots[slot] = html.Raw(content.String())
|
||||||
case "H":
|
|
||||||
layout.H(html.Raw(content.String()))
|
|
||||||
case "L":
|
|
||||||
layout.L(html.Raw(content.String()))
|
|
||||||
case "C":
|
|
||||||
layout.C(html.Raw(content.String()))
|
|
||||||
case "R":
|
|
||||||
layout.R(html.Raw(content.String()))
|
|
||||||
case "F":
|
|
||||||
layout.F(html.Raw(content.String()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return layout.Render(ctx)
|
return renderLayout(variant, locale, slots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerComponents defines custom elements from the HLCRF slot map.
|
||||||
|
// The input mirrors the codegen slot mapping: keys are HLCRF slot letters and
|
||||||
|
// values are custom element tag names.
|
||||||
|
func registerComponents(_ js.Value, args []js.Value) any {
|
||||||
|
if len(args) < 1 || args[0].Type() != js.TypeObject {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := args[0]
|
||||||
|
customElements := js.Global().Get("customElements")
|
||||||
|
seenTags := make(map[string]struct{}, len(canonicalSlotOrder))
|
||||||
|
registered := 0
|
||||||
|
|
||||||
|
for _, slot := range canonicalSlotOrder {
|
||||||
|
content := slots.Get(slot)
|
||||||
|
if content.Type() != js.TypeString {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := content.String()
|
||||||
|
if !isValidCustomElementTag(tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := seenTags[tag]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing := customElements.Call("get", tag); existing.Truthy() {
|
||||||
|
seenTags[tag] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
factory := js.Global().Get("Function").New("return " + customElementClassSource(tag, slot) + ";")
|
||||||
|
ctor := factory.Invoke()
|
||||||
|
customElements.Call("define", tag, ctor)
|
||||||
|
seenTags[tag] = struct{}{}
|
||||||
|
registered++
|
||||||
|
}
|
||||||
|
|
||||||
|
return registered
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
js.Global().Set("gohtml", js.ValueOf(map[string]any{
|
js.Global().Set("gohtml", js.ValueOf(map[string]any{
|
||||||
"renderToString": js.FuncOf(renderToString),
|
"renderToString": js.FuncOf(renderToString),
|
||||||
|
"registerComponents": js.FuncOf(registerComponents),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
select {}
|
select {}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,20 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
core "dappco.re/go/core"
|
"encoding/json"
|
||||||
|
|
||||||
"dappco.re/go/core/html/codegen"
|
"dappco.re/go/core/html/codegen"
|
||||||
log "dappco.re/go/core/log"
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
|
// cmd/wasm/register.go: buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
|
||||||
|
// Example: js, err := buildComponentJS(`{"H":"site-header","C":"app-content"}`).
|
||||||
// This is the pure-Go part testable without WASM.
|
// This is the pure-Go part testable without WASM.
|
||||||
// Excluded from WASM builds — encoding/json and text/template are too heavy.
|
// Excluded from WASM builds — encoding/json and text/template are too heavy.
|
||||||
// Use cmd/codegen/ CLI instead for build-time generation.
|
// Use cmd/codegen/ CLI instead for build-time generation.
|
||||||
func buildComponentJS(slotsJSON string) (string, error) {
|
func buildComponentJS(slotsJSON string) (string, error) {
|
||||||
var slots map[string]string
|
var slots map[string]string
|
||||||
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
|
if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
|
||||||
err, _ := result.Value.(error)
|
|
||||||
return "", log.E("buildComponentJS", "unmarshal JSON", err)
|
return "", log.E("buildComponentJS", "unmarshal JSON", err)
|
||||||
}
|
}
|
||||||
return codegen.GenerateBundle(slots)
|
return codegen.GenerateBundle(slots)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
|
func TestBuildComponentJS_Good(t *testing.T) {
|
||||||
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
|
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
|
||||||
js, err := buildComponentJS(slotsJSON)
|
js, err := buildComponentJS(slotsJSON)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -18,7 +18,59 @@ func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
|
||||||
assert.Contains(t, js, "customElements.define")
|
assert.Contains(t, js, "customElements.define")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
|
func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
|
||||||
_, err := buildComponentJS("not json")
|
_, err := buildComponentJS("not json")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagToClassName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
tag string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{tag: "nav-bar", want: "NavBar"},
|
||||||
|
{tag: "my-super-widget", want: "MySuperWidget"},
|
||||||
|
{tag: "x", want: "X"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.tag, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tagToClassName(tt.tag))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidCustomElementTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
tag string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{tag: "nav-bar", want: true},
|
||||||
|
{tag: "main-content", want: true},
|
||||||
|
{tag: "NavBar", want: false},
|
||||||
|
{tag: "nav", want: false},
|
||||||
|
{tag: "nav_bar", want: false},
|
||||||
|
{tag: "annotation-xml", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.tag, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isValidCustomElementTag(tt.tag))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomElementClassSource(t *testing.T) {
|
||||||
|
src := customElementClassSource(`nav-bar`, `H`)
|
||||||
|
|
||||||
|
assert.Contains(t, src, `class NavBar extends HTMLElement`)
|
||||||
|
assert.Contains(t, src, `#shadow;`)
|
||||||
|
assert.Contains(t, src, `mode:"closed"`)
|
||||||
|
assert.Contains(t, src, `#shadow`)
|
||||||
|
assert.Contains(t, src, `data-slot`)
|
||||||
|
assert.Contains(t, src, `wc-ready`)
|
||||||
|
assert.Contains(t, src, `bubbles:true`)
|
||||||
|
assert.Contains(t, src, `composed:true`)
|
||||||
|
assert.Contains(t, src, `nav-bar`)
|
||||||
|
assert.Contains(t, src, `H`)
|
||||||
|
}
|
||||||
|
|
|
||||||
35
cmd/wasm/render_shared.go
Normal file
35
cmd/wasm/render_shared.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import html "dappco.re/go/core/html"
|
||||||
|
|
||||||
|
// renderLayout builds an HLCRF layout from slot nodes and renders it.
|
||||||
|
// The helper is shared by the JS entrypoint and host-side tests so the
|
||||||
|
// slot-to-layout mapping stays covered outside the wasm build.
|
||||||
|
func renderLayout(variant, locale string, slots map[string]html.Node) string {
|
||||||
|
ctx := html.NewContext(locale)
|
||||||
|
layout := html.NewLayout(variant)
|
||||||
|
|
||||||
|
for _, slot := range canonicalSlotOrder {
|
||||||
|
node, ok := slots[slot]
|
||||||
|
if !ok || node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch slot {
|
||||||
|
case "H":
|
||||||
|
layout.H(node)
|
||||||
|
case "L":
|
||||||
|
layout.L(node)
|
||||||
|
case "C":
|
||||||
|
layout.C(node)
|
||||||
|
case "R":
|
||||||
|
layout.R(node)
|
||||||
|
case "F":
|
||||||
|
layout.F(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout.Render(ctx)
|
||||||
|
}
|
||||||
49
cmd/wasm/render_shared_test.go
Normal file
49
cmd/wasm/render_shared_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
html "dappco.re/go/core/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderLayout_RendersSlotsInVariantOrder(t *testing.T) {
|
||||||
|
got := renderLayout("HCF", "en-GB", map[string]html.Node{
|
||||||
|
"H": html.Raw("head"),
|
||||||
|
"C": html.Raw("body"),
|
||||||
|
"F": html.Raw("foot"),
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `<header role="banner" data-block="H-0">head</header>` +
|
||||||
|
`<main role="main" data-block="C-0">body</main>` +
|
||||||
|
`<footer role="contentinfo" data-block="F-0">foot</footer>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderLayout() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderLayout_UsesLocaleAwareTextNodes(t *testing.T) {
|
||||||
|
got := renderLayout("C", "fr-FR", map[string]html.Node{
|
||||||
|
"C": html.El("p", html.Text("prompt.yes")),
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `<main role="main" data-block="C-0"><p>o</p></main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderLayout() with locale = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderLayout_IgnoresMissingAndUnknownSlots(t *testing.T) {
|
||||||
|
got := renderLayout("C", "en-GB", map[string]html.Node{
|
||||||
|
"C": html.Raw("content"),
|
||||||
|
"X": html.Raw("ignored"),
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `<main role="main" data-block="C-0">content</main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderLayout() with unknown slots = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,14 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
process "dappco.re/go/core/process"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -20,44 +21,34 @@ const (
|
||||||
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
|
func TestWASMBinarySize_Good(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping WASM build test in short mode")
|
t.Skip("skipping WASM build test in short mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
out := core.Path(dir, "gohtml.wasm")
|
out := filepath.Join(dir, "gohtml.wasm")
|
||||||
|
|
||||||
factory := process.NewService(process.Options{})
|
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
|
||||||
serviceValue, err := factory(core.New())
|
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||||
require.NoError(t, err)
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
svc, ok := serviceValue.(*process.Service)
|
|
||||||
require.True(t, ok, "process service factory returned %T", serviceValue)
|
|
||||||
|
|
||||||
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
|
|
||||||
Command: "go",
|
|
||||||
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
|
|
||||||
Dir: ".",
|
|
||||||
Env: []string{"GOOS=js", "GOARCH=wasm"},
|
|
||||||
})
|
|
||||||
require.NoError(t, err, "WASM build failed: %s", output)
|
require.NoError(t, err, "WASM build failed: %s", output)
|
||||||
|
|
||||||
rawStr, err := coreio.Local.Read(out)
|
rawStr, err := coreio.Local.Read(out)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
rawBytes := []byte(rawStr)
|
raw := []byte(rawStr)
|
||||||
|
|
||||||
buf := core.NewBuilder()
|
var buf bytes.Buffer
|
||||||
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
|
gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = gz.Write(rawBytes)
|
_, err = gz.Write(raw)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, gz.Close())
|
require.NoError(t, gz.Close())
|
||||||
|
|
||||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
|
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len())
|
||||||
|
|
||||||
assert.Less(t, buf.Len(), wasmGzLimit,
|
assert.Less(t, buf.Len(), wasmGzLimit,
|
||||||
"WASM gzip size %d exceeds 1MB limit", buf.Len())
|
"WASM gzip size %d exceeds 1MB limit", buf.Len())
|
||||||
assert.Less(t, len(rawBytes), wasmRawLimit,
|
assert.Less(t, len(raw), wasmRawLimit,
|
||||||
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
|
"WASM raw size %d exceeds 3MB limit", len(raw))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,42 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
log "dappco.re/go/core/log"
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isValidCustomElementTag reports whether tag is a safe custom element name.
|
// isValidCustomElementTag reports whether tag is a safe custom element name.
|
||||||
// The generator rejects values that would fail at customElements.define() time.
|
// The generator rejects values that would fail at customElements.define() time.
|
||||||
|
var reservedCustomElementTags = map[string]struct{}{
|
||||||
|
"annotation-xml": {},
|
||||||
|
"color-profile": {},
|
||||||
|
"font-face": {},
|
||||||
|
"font-face-src": {},
|
||||||
|
"font-face-uri": {},
|
||||||
|
"font-face-format": {},
|
||||||
|
"font-face-name": {},
|
||||||
|
"missing-glyph": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReservedCustomElementTag(tag string) bool {
|
||||||
|
_, reserved := reservedCustomElementTags[tag]
|
||||||
|
return reserved
|
||||||
|
}
|
||||||
|
|
||||||
func isValidCustomElementTag(tag string) bool {
|
func isValidCustomElementTag(tag string) bool {
|
||||||
if tag == "" || !core.Contains(tag, "-") {
|
if tag == "" || !strings.Contains(tag, "-") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag[0] < 'a' || tag[0] > 'z' {
|
if tag[0] < 'a' || tag[0] > 'z' {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if isReservedCustomElementTag(tag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for i := range len(tag) {
|
for i := range len(tag) {
|
||||||
ch := tag[i]
|
ch := tag[i]
|
||||||
|
|
@ -47,7 +64,7 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.#shadow.textContent = "";
|
this.#shadow.textContent = "";
|
||||||
const slot = this.getAttribute("data-slot") || "{{.Slot}}";
|
const slot = this.getAttribute("data-slot") || "{{.Slot}}";
|
||||||
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot } }));
|
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot }, bubbles: true, composed: true }));
|
||||||
}
|
}
|
||||||
render(html) {
|
render(html) {
|
||||||
const tpl = document.createElement("template");
|
const tpl = document.createElement("template");
|
||||||
|
|
@ -57,14 +74,19 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
|
||||||
}
|
}
|
||||||
}`))
|
}`))
|
||||||
|
|
||||||
// GenerateClass produces a JS class definition for a custom element.
|
// codegen/codegen.go: GenerateClass produces a JS class definition for a custom element.
|
||||||
// Usage example: js, err := GenerateClass("nav-bar", "H")
|
//
|
||||||
|
// Example: cls, err := GenerateClass("nav-bar", "H")
|
||||||
func GenerateClass(tag, slot string) (string, error) {
|
func GenerateClass(tag, slot string) (string, error) {
|
||||||
if !isValidCustomElementTag(tag) {
|
if !isValidCustomElementTag(tag) {
|
||||||
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
message := "custom element tag must be a lowercase hyphenated name: " + tag
|
||||||
|
if isReservedCustomElementTag(tag) {
|
||||||
|
message = "custom element tag is reserved by the Web Components spec: " + tag
|
||||||
|
}
|
||||||
|
return "", log.E("codegen.GenerateClass", message, nil)
|
||||||
}
|
}
|
||||||
b := core.NewBuilder()
|
var b strings.Builder
|
||||||
err := wcTemplate.Execute(b, struct {
|
err := wcTemplate.Execute(&b, struct {
|
||||||
ClassName, Tag, Slot string
|
ClassName, Tag, Slot string
|
||||||
}{
|
}{
|
||||||
ClassName: TagToClassName(tag),
|
ClassName: TagToClassName(tag),
|
||||||
|
|
@ -77,52 +99,164 @@ func GenerateClass(tag, slot string) (string, error) {
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRegistration produces the customElements.define() call.
|
// codegen/codegen.go: GenerateRegistration produces the customElements.define() call.
|
||||||
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
//
|
||||||
|
// Example: GenerateRegistration("nav-bar", "NavBar")
|
||||||
func GenerateRegistration(tag, className string) string {
|
func GenerateRegistration(tag, className string) string {
|
||||||
return `customElements.define("` + tag + `", ` + className + `);`
|
var b strings.Builder
|
||||||
|
b.WriteString(`customElements.define("`)
|
||||||
|
b.WriteString(tag)
|
||||||
|
b.WriteString(`", `)
|
||||||
|
b.WriteString(className)
|
||||||
|
b.WriteString(`);`)
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
// codegen/codegen.go: TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||||
// Usage example: className := TagToClassName("nav-bar")
|
//
|
||||||
|
// Example: className := TagToClassName("nav-bar") // NavBar
|
||||||
func TagToClassName(tag string) string {
|
func TagToClassName(tag string) string {
|
||||||
b := core.NewBuilder()
|
var b strings.Builder
|
||||||
for _, p := range core.Split(tag, "-") {
|
for p := range strings.SplitSeq(tag, "-") {
|
||||||
if len(p) > 0 {
|
if len(p) > 0 {
|
||||||
b.WriteString(core.Upper(p[:1]))
|
b.WriteString(strings.ToUpper(p[:1]))
|
||||||
b.WriteString(p[1:])
|
b.WriteString(p[1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateBundle produces all WC class definitions and registrations
|
// codegen/codegen.go: GenerateBundle produces all WC class definitions and registrations
|
||||||
// for a set of HLCRF slot assignments.
|
// for a set of HLCRF slot assignments.
|
||||||
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
//
|
||||||
|
// Example: js, err := GenerateBundle(map[string]string{"H":"nav-bar", "C":"main-content"})
|
||||||
func GenerateBundle(slots map[string]string) (string, error) {
|
func GenerateBundle(slots map[string]string) (string, error) {
|
||||||
seen := make(map[string]bool)
|
if err := validateSlotKeys(slots); err != nil {
|
||||||
b := core.NewBuilder()
|
return "", err
|
||||||
keys := make([]string, 0, len(slots))
|
|
||||||
for slot := range slots {
|
|
||||||
keys = append(keys, slot)
|
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
for _, slot := range keys {
|
var b strings.Builder
|
||||||
tag := slots[slot]
|
|
||||||
if seen[tag] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[tag] = true
|
|
||||||
|
|
||||||
cls, err := GenerateClass(tag, slot)
|
for _, entry := range orderedSlotEntries(slots) {
|
||||||
|
cls, err := GenerateClass(entry.Tag, entry.Slot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
return "", err
|
||||||
}
|
}
|
||||||
b.WriteString(cls)
|
b.WriteString(cls)
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
|
b.WriteString(GenerateRegistration(entry.Tag, TagToClassName(entry.Tag)))
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// codegen/codegen.go: GenerateTypeDefinitions produces a TypeScript declaration file for the
|
||||||
|
// generated custom elements.
|
||||||
|
//
|
||||||
|
// Example: dts, err := GenerateTypeDefinitions(map[string]string{"H":"nav-bar"})
|
||||||
|
func GenerateTypeDefinitions(slots map[string]string) (string, error) {
|
||||||
|
if err := validateSlotKeys(slots); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := orderedSlotEntries(slots)
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !isValidCustomElementTag(entry.Tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
className := TagToClassName(entry.Tag)
|
||||||
|
b.WriteString("export declare class ")
|
||||||
|
b.WriteString(className)
|
||||||
|
b.WriteString(" extends HTMLElement {\n")
|
||||||
|
b.WriteString(" connectedCallback(): void;\n")
|
||||||
|
b.WriteString(" render(html: string): void;\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\ndeclare global {\n")
|
||||||
|
b.WriteString(" interface WcReadyDetail {\n")
|
||||||
|
b.WriteString(" tag: string;\n")
|
||||||
|
b.WriteString(" slot: string;\n")
|
||||||
|
b.WriteString(" }\n")
|
||||||
|
b.WriteString(" interface HTMLElementEventMap {\n")
|
||||||
|
b.WriteString(` "wc-ready": CustomEvent<WcReadyDetail>;` + "\n")
|
||||||
|
b.WriteString(" }\n")
|
||||||
|
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !isValidCustomElementTag(entry.Tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
className := TagToClassName(entry.Tag)
|
||||||
|
b.WriteString(` "`)
|
||||||
|
b.WriteString(entry.Tag)
|
||||||
|
b.WriteString(`": `)
|
||||||
|
b.WriteString(className)
|
||||||
|
b.WriteString(";\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" }\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
b.WriteString("\nexport {};\n")
|
||||||
|
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type slotEntry struct {
|
||||||
|
Slot string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"}
|
||||||
|
|
||||||
|
const validSlotKeys = "H, L, C, R, F"
|
||||||
|
|
||||||
|
func validateSlotKeys(slots map[string]string) error {
|
||||||
|
if len(slots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := map[string]struct{}{
|
||||||
|
"H": {},
|
||||||
|
"L": {},
|
||||||
|
"C": {},
|
||||||
|
"R": {},
|
||||||
|
"F": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalid []string
|
||||||
|
for slot := range slots {
|
||||||
|
if _, ok := valid[slot]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invalid = append(invalid, slot)
|
||||||
|
}
|
||||||
|
if len(invalid) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(invalid)
|
||||||
|
quoted := make([]string, 0, len(invalid))
|
||||||
|
for _, slot := range invalid {
|
||||||
|
quoted = append(quoted, strconv.Quote(slot))
|
||||||
|
}
|
||||||
|
return log.E("codegen", "invalid slot key(s): "+strings.Join(quoted, ", ")+"; valid keys: "+validSlotKeys, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedSlotEntries(slots map[string]string) []slotEntry {
|
||||||
|
entries := make([]slotEntry, 0, len(slots))
|
||||||
|
seenTags := make(map[string]struct{}, len(slots))
|
||||||
|
|
||||||
|
for _, slot := range canonicalSlotOrder {
|
||||||
|
tag, ok := slots[slot]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := seenTags[tag]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTags[tag] = struct{}{}
|
||||||
|
entries = append(entries, slotEntry{Slot: slot, Tag: tag})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,16 +8,18 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateClass_ValidTag_Good(t *testing.T) {
|
func TestGenerateClass_Good(t *testing.T) {
|
||||||
js, err := GenerateClass("photo-grid", "C")
|
js, err := GenerateClass("photo-grid", "C")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||||
assert.Contains(t, js, "attachShadow")
|
assert.Contains(t, js, "attachShadow")
|
||||||
assert.Contains(t, js, `mode: "closed"`)
|
assert.Contains(t, js, `mode: "closed"`)
|
||||||
|
assert.Contains(t, js, `bubbles: true`)
|
||||||
|
assert.Contains(t, js, `composed: true`)
|
||||||
assert.Contains(t, js, "photo-grid")
|
assert.Contains(t, js, "photo-grid")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
|
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
|
||||||
_, err := GenerateClass("invalid", "C")
|
_, err := GenerateClass("invalid", "C")
|
||||||
assert.Error(t, err, "custom element names must contain a hyphen")
|
assert.Error(t, err, "custom element names must contain a hyphen")
|
||||||
|
|
||||||
|
|
@ -28,16 +28,19 @@ func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
|
||||||
|
|
||||||
_, err = GenerateClass("nav bar", "C")
|
_, err = GenerateClass("nav bar", "C")
|
||||||
assert.Error(t, err, "custom element names must reject spaces")
|
assert.Error(t, err, "custom element names must reject spaces")
|
||||||
|
|
||||||
|
_, err = GenerateClass("annotation-xml", "C")
|
||||||
|
assert.Error(t, err, "reserved custom element names must be rejected")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
|
func TestGenerateRegistration_Good(t *testing.T) {
|
||||||
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
||||||
assert.Contains(t, js, "customElements.define")
|
assert.Contains(t, js, "customElements.define")
|
||||||
assert.Contains(t, js, `"photo-grid"`)
|
assert.Contains(t, js, `"photo-grid"`)
|
||||||
assert.Contains(t, js, "PhotoGrid")
|
assert.Contains(t, js, "PhotoGrid")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTagToClassName_KebabCase_Good(t *testing.T) {
|
func TestTagToClassName_Good(t *testing.T) {
|
||||||
tests := []struct{ tag, want string }{
|
tests := []struct{ tag, want string }{
|
||||||
{"photo-grid", "PhotoGrid"},
|
{"photo-grid", "PhotoGrid"},
|
||||||
{"nav-breadcrumb", "NavBreadcrumb"},
|
{"nav-breadcrumb", "NavBreadcrumb"},
|
||||||
|
|
@ -49,108 +52,124 @@ func TestTagToClassName_KebabCase_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
|
func TestGenerateBundle_Good(t *testing.T) {
|
||||||
slots := map[string]string{
|
slots := map[string]string{
|
||||||
"H": "nav-bar",
|
|
||||||
"C": "main-content",
|
"C": "main-content",
|
||||||
"F": "nav-bar",
|
"H": "nav-bar",
|
||||||
|
"F": "page-footer",
|
||||||
}
|
}
|
||||||
js, err := GenerateBundle(slots)
|
js, err := GenerateBundle(slots)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, js, "NavBar")
|
assert.Contains(t, js, "NavBar")
|
||||||
assert.Contains(t, js, "MainContent")
|
assert.Contains(t, js, "MainContent")
|
||||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
assert.Contains(t, js, "PageFooter")
|
||||||
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
|
assert.Equal(t, 3, strings.Count(js, "extends HTMLElement"))
|
||||||
|
|
||||||
|
h := strings.Index(js, "NavBar")
|
||||||
|
c := strings.Index(js, "MainContent")
|
||||||
|
f := strings.Index(js, "PageFooter")
|
||||||
|
assert.True(t, h >= 0 && c >= 0 && f >= 0, "expected all generated classes in output")
|
||||||
|
assert.True(t, h < c && c < f, "expected canonical HLCRF order in generated bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
|
func TestGenerateBundle_DeduplicatesTags(t *testing.T) {
|
||||||
slots := map[string]string{
|
slots := map[string]string{
|
||||||
"Z": "zed-panel",
|
"H": "nav-bar",
|
||||||
"A": "alpha-panel",
|
"C": "nav-bar",
|
||||||
"M": "main-content",
|
"F": "page-footer",
|
||||||
}
|
}
|
||||||
|
|
||||||
js, err := GenerateBundle(slots)
|
js, err := GenerateBundle(slots)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
alpha := strings.Index(js, "class AlphaPanel")
|
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||||
main := strings.Index(js, "class MainContent")
|
assert.Equal(t, 1, strings.Count(js, "class NavBar extends HTMLElement"))
|
||||||
zed := strings.Index(js, "class ZedPanel")
|
assert.Equal(t, 1, strings.Count(js, "class PageFooter extends HTMLElement"))
|
||||||
|
|
||||||
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) {
|
func TestGenerateBundle_Bad_InvalidSlotKey(t *testing.T) {
|
||||||
slots := map[string]string{
|
slots := map[string]string{
|
||||||
"Z": "zed-panel",
|
"H": "nav-bar",
|
||||||
"A": "alpha-panel",
|
"X": "custom-widget",
|
||||||
"M": "alpha-panel",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dts := GenerateTypeScriptDefinitions(slots)
|
_, err := GenerateBundle(slots)
|
||||||
|
require.Error(t, err)
|
||||||
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
|
assert.Contains(t, err.Error(), "invalid slot key")
|
||||||
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
|
assert.Contains(t, err.Error(), `"X"`)
|
||||||
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
|
assert.Contains(t, err.Error(), "valid keys: H, L, C, R, F")
|
||||||
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) {
|
func TestGenerateBundle_Bad_ReservedTag(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"H": "annotation-xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := GenerateBundle(slots)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "reserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTypeDefinitions_Good(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"H": "nav-bar",
|
||||||
|
"C": "main-content",
|
||||||
|
}
|
||||||
|
|
||||||
|
dts, err := GenerateTypeDefinitions(slots)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, dts, "declare global")
|
||||||
|
assert.Contains(t, dts, "interface WcReadyDetail")
|
||||||
|
assert.Contains(t, dts, `interface HTMLElementEventMap`)
|
||||||
|
assert.Contains(t, dts, `"wc-ready": CustomEvent<WcReadyDetail>;`)
|
||||||
|
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
||||||
|
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
||||||
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
|
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||||
|
assert.Contains(t, dts, "export {};")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTypeDefinitions_DeduplicatesTags(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"H": "nav-bar",
|
||||||
|
"C": "nav-bar",
|
||||||
|
"F": "page-footer",
|
||||||
|
}
|
||||||
|
|
||||||
|
dts, err := GenerateTypeDefinitions(slots)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, strings.Count(dts, "extends HTMLElement"))
|
||||||
|
assert.Equal(t, 1, strings.Count(dts, `"nav-bar": NavBar;`))
|
||||||
|
assert.Equal(t, 1, strings.Count(dts, `"page-footer": PageFooter;`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTypeDefinitions_SkipsInvalidTags(t *testing.T) {
|
||||||
slots := map[string]string{
|
slots := map[string]string{
|
||||||
"H": "nav-bar",
|
"H": "nav-bar",
|
||||||
"C": "Nav-Bar",
|
"C": "Nav-Bar",
|
||||||
"F": "nav bar",
|
"F": "nav bar",
|
||||||
}
|
}
|
||||||
|
|
||||||
dts := GenerateTypeScriptDefinitions(slots)
|
dts, err := GenerateTypeDefinitions(slots)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
assert.NotContains(t, dts, "Nav-Bar")
|
assert.NotContains(t, dts, "Nav-Bar")
|
||||||
assert.NotContains(t, dts, "nav bar")
|
assert.NotContains(t, dts, "nav bar")
|
||||||
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
|
assert.Equal(t, 1, strings.Count(dts, "extends HTMLElement"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func countSubstr(s, substr string) int {
|
func TestGenerateTypeDefinitions_SkipsReservedTags(t *testing.T) {
|
||||||
if substr == "" {
|
slots := map[string]string{
|
||||||
return len(s) + 1
|
"H": "annotation-xml",
|
||||||
|
"C": "nav-bar",
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
dts, err := GenerateTypeDefinitions(slots)
|
||||||
for i := 0; i <= len(s)-len(substr); {
|
require.NoError(t, err)
|
||||||
j := indexSubstr(s[i:], substr)
|
|
||||||
if j < 0 {
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
i += j + len(substr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
}
|
assert.NotContains(t, dts, "annotation-xml")
|
||||||
|
assert.Equal(t, 1, strings.Count(dts, "extends HTMLElement"))
|
||||||
func indexSubstr(s, substr string) int {
|
|
||||||
if substr == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if len(substr) > len(s) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i <= len(s)-len(substr); i++ {
|
|
||||||
if s[i:i+len(substr)] == substr {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
// Package codegen generates Web Component bundles for go-html slot maps.
|
|
||||||
//
|
|
||||||
// Use it at build time, or through the cmd/codegen CLI:
|
|
||||||
//
|
|
||||||
// bundle, err := GenerateBundle(map[string]string{
|
|
||||||
// "H": "site-header",
|
|
||||||
// "C": "app-main",
|
|
||||||
// })
|
|
||||||
package codegen
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package codegen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
|
|
||||||
// a set of custom elements generated from HLCRF slot assignments.
|
|
||||||
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
|
|
||||||
func GenerateTypeScriptDefinitions(slots map[string]string) string {
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
declared := make(map[string]bool)
|
|
||||||
b := core.NewBuilder()
|
|
||||||
|
|
||||||
keys := make([]string, 0, len(slots))
|
|
||||||
for slot := range slots {
|
|
||||||
keys = append(keys, slot)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
b.WriteString("declare global {\n")
|
|
||||||
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
|
||||||
for _, slot := range keys {
|
|
||||||
tag := slots[slot]
|
|
||||||
if !isValidCustomElementTag(tag) || seen[tag] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[tag] = true
|
|
||||||
b.WriteString(" \"")
|
|
||||||
b.WriteString(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()
|
|
||||||
}
|
|
||||||
189
context.go
189
context.go
|
|
@ -1,16 +1,22 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
// Translator provides Text() lookups for a rendering context.
|
// context.go: Translator provides Text() lookups for a rendering context.
|
||||||
// Usage example: ctx := NewContextWithService(myTranslator)
|
// Example: type service struct{}
|
||||||
//
|
//
|
||||||
// The default server build uses go-i18n. Alternate builds, including WASM,
|
// func (service) T(key string, args ...any) string { return key }
|
||||||
// can provide any implementation with the same T() method.
|
//
|
||||||
|
// ctx := NewContextWithService(service{}, "en-GB")
|
||||||
type Translator interface {
|
type Translator interface {
|
||||||
T(key string, args ...any) string
|
T(key string, args ...any) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context carries rendering state through the node tree.
|
type translatorCloner interface {
|
||||||
// Usage example: ctx := NewContext()
|
Clone() Translator
|
||||||
|
}
|
||||||
|
|
||||||
|
// context.go: Context carries rendering state through the node tree.
|
||||||
|
// Example: NewContext("en-GB") initialises locale-specific rendering state.
|
||||||
|
// Locale and translator selection are managed through dedicated setters.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Identity string
|
Identity string
|
||||||
Locale string
|
Locale string
|
||||||
|
|
@ -19,6 +25,24 @@ type Context struct {
|
||||||
service Translator
|
service Translator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone returns a shallow copy of the context with an independent Data map.
|
||||||
|
// Example: next := ctx.Clone().SetData("theme", "dark").
|
||||||
|
func (ctx *Context) Clone() *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *ctx
|
||||||
|
if ctx.Data != nil {
|
||||||
|
clone.Data = make(map[string]any, len(ctx.Data))
|
||||||
|
for key, value := range ctx.Data {
|
||||||
|
clone.Data[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clone.service = cloneTranslator(clone.service, clone.Locale)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
func applyLocaleToService(svc Translator, locale string) {
|
func applyLocaleToService(svc Translator, locale string) {
|
||||||
if svc == nil || locale == "" {
|
if svc == nil || locale == "" {
|
||||||
return
|
return
|
||||||
|
|
@ -36,45 +60,172 @@ func applyLocaleToService(svc Translator, locale string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContext creates a new rendering context with sensible defaults.
|
// ensureContextDefaults initialises lazily-created context fields.
|
||||||
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
|
func ensureContextDefaults(ctx *Context) {
|
||||||
|
if ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Data == nil {
|
||||||
|
ctx.Data = make(map[string]any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseContext ensures render paths always have a usable context.
|
||||||
|
// A nil input is replaced with a fresh default context.
|
||||||
|
func normaliseContext(ctx *Context) *Context {
|
||||||
|
if ctx != nil {
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return NewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// context.go: NewContext creates a new rendering context with sensible defaults.
|
||||||
|
// Example: ctx := NewContext("en-GB").
|
||||||
|
// An optional locale may be provided as the first argument.
|
||||||
func NewContext(locale ...string) *Context {
|
func NewContext(locale ...string) *Context {
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
Data: make(map[string]any),
|
Data: make(map[string]any),
|
||||||
|
service: newDefaultTranslator(),
|
||||||
}
|
}
|
||||||
if len(locale) > 0 {
|
if len(locale) > 0 {
|
||||||
ctx.SetLocale(locale[0])
|
ctx.Locale = locale[0]
|
||||||
|
applyLocaleToService(ctx.service, ctx.Locale)
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContextWithService creates a rendering context backed by a specific translator.
|
// context.go: NewContextWithService creates a rendering context backed by a specific translator.
|
||||||
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
|
// Example: ctx := NewContextWithService(svc, "fr-FR").
|
||||||
|
// An optional locale may be provided as the second argument.
|
||||||
func NewContextWithService(svc Translator, locale ...string) *Context {
|
func NewContextWithService(svc Translator, locale ...string) *Context {
|
||||||
ctx := NewContext(locale...)
|
ctx := &Context{
|
||||||
ctx.SetService(svc)
|
Data: make(map[string]any),
|
||||||
|
service: svc,
|
||||||
|
}
|
||||||
|
if len(locale) > 0 {
|
||||||
|
ctx.Locale = locale[0]
|
||||||
|
}
|
||||||
|
applyLocaleToService(ctx.service, ctx.Locale)
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetService swaps the translator used by the context.
|
// SetService swaps the translator used by the context and reapplies the
|
||||||
// Usage example: ctx.SetService(myTranslator)
|
// current locale to it.
|
||||||
|
// Example: ctx.SetService(svc).
|
||||||
func (ctx *Context) SetService(svc Translator) *Context {
|
func (ctx *Context) SetService(svc Translator) *Context {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
ctx.service = svc
|
ctx.service = svc
|
||||||
applyLocaleToService(svc, ctx.Locale)
|
applyLocaleToService(ctx.service, ctx.Locale)
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLocale updates the context locale and reapplies it to the active translator.
|
// SetEntitlements updates the feature gate callback used by Entitled nodes and
|
||||||
// Usage example: ctx.SetLocale("en-GB")
|
// returns the same context.
|
||||||
|
// Example: ctx.SetEntitlements(func(feature string) bool { return feature == "premium" }).
|
||||||
|
func (ctx *Context) SetEntitlements(entitlements func(feature string) bool) *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
|
ctx.Entitlements = entitlements
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIdentity updates the context identity and returns the same context.
|
||||||
|
// Example: ctx.SetIdentity("user-123").
|
||||||
|
func (ctx *Context) SetIdentity(identity string) *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
|
ctx.Identity = identity
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData stores an arbitrary per-request value on the context and returns the
|
||||||
|
// same context.
|
||||||
|
// Example: ctx.SetData("theme", "dark").
|
||||||
|
func (ctx *Context) SetData(key string, value any) *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
|
ctx.Data[key] = value
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithData returns a cloned context with one additional data value set.
|
||||||
|
// Example: next := ctx.WithData("theme", "dark").
|
||||||
|
func (ctx *Context) WithData(key string, value any) *Context {
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone.SetData(key, value)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithIdentity returns a cloned context with a different identity value.
|
||||||
|
// Example: next := ctx.WithIdentity("user-123").
|
||||||
|
func (ctx *Context) WithIdentity(identity string) *Context {
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone.SetIdentity(identity)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLocale returns a cloned context with a different locale value.
|
||||||
|
// Example: next := ctx.WithLocale("fr-FR").
|
||||||
|
func (ctx *Context) WithLocale(locale string) *Context {
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone.SetLocale(locale)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithService returns a cloned context with a different translator.
|
||||||
|
// Example: next := ctx.WithService(svc).
|
||||||
|
func (ctx *Context) WithService(svc Translator) *Context {
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone.SetService(svc)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEntitlements returns a cloned context with a different feature gate callback.
|
||||||
|
// Example: next := ctx.WithEntitlements(func(feature string) bool { return feature == "premium" }).
|
||||||
|
func (ctx *Context) WithEntitlements(entitlements func(feature string) bool) *Context {
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone.SetEntitlements(entitlements)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLocale updates the context locale and reapplies it to the active
|
||||||
|
// translator.
|
||||||
|
// Example: ctx.SetLocale("en-US").
|
||||||
func (ctx *Context) SetLocale(locale string) *Context {
|
func (ctx *Context) SetLocale(locale string) *Context {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureContextDefaults(ctx)
|
||||||
ctx.Locale = locale
|
ctx.Locale = locale
|
||||||
applyLocaleToService(ctx.service, ctx.Locale)
|
applyLocaleToService(ctx.service, ctx.Locale)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
|
||||||
464
context_test.go
464
context_test.go
|
|
@ -6,85 +6,445 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewContext_OptionalLocale_Good(t *testing.T) {
|
type localeTranslator struct {
|
||||||
ctx := NewContext("en-GB")
|
language string
|
||||||
|
|
||||||
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) {
|
func (t *localeTranslator) T(key string, args ...any) string {
|
||||||
svc, _ := i18n.New()
|
if key == "prompt.yes" && t.language == "fr" {
|
||||||
ctx := NewContextWithService(svc, "fr-FR")
|
return "o"
|
||||||
|
|
||||||
if ctx == nil {
|
|
||||||
t.Fatal("NewContextWithService returned nil")
|
|
||||||
}
|
}
|
||||||
if ctx.Locale != "fr-FR" {
|
if key == "prompt.yes" && t.language == "en" {
|
||||||
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
|
return "y"
|
||||||
}
|
|
||||||
if ctx.service == nil {
|
|
||||||
t.Fatal("NewContextWithService should set translator service")
|
|
||||||
}
|
}
|
||||||
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
|
func (t *localeTranslator) SetLanguage(language string) error {
|
||||||
svc, _ := i18n.New()
|
t.language = language
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *localeTranslator) Clone() Translator {
|
||||||
|
if t == nil {
|
||||||
|
return (*localeTranslator)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *t
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
type resettingTranslator struct {
|
||||||
|
language string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *resettingTranslator) T(key string, args ...any) string {
|
||||||
|
if key == "prompt.yes" && t.language == "fr" {
|
||||||
|
return "o"
|
||||||
|
}
|
||||||
|
if key == "prompt.yes" && t.language == "en" {
|
||||||
|
return "y"
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *resettingTranslator) SetLanguage(language string) error {
|
||||||
|
t.language = language
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *resettingTranslator) Clone() Translator {
|
||||||
|
if t == nil {
|
||||||
|
return (*resettingTranslator)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resettingTranslator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_NewContextWithService_AppliesLocale(t *testing.T) {
|
||||||
|
svc := &localeTranslator{}
|
||||||
ctx := NewContextWithService(svc, "fr-FR")
|
ctx := NewContextWithService(svc, "fr-FR")
|
||||||
|
|
||||||
got := Text("prompt.yes").Render(ctx)
|
if svc.language != "fr" {
|
||||||
if got != "o" {
|
t.Fatalf("NewContextWithService should apply locale to translator, got %q", svc.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
|
func TestContext_NewContext_AppliesLocaleToDefaultService(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
|
||||||
ctx := NewContext("fr-FR")
|
ctx := NewContext("fr-FR")
|
||||||
|
|
||||||
if got := ctx.SetService(svc); got != ctx {
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
t.Fatal("SetService should return the same context for chaining")
|
t.Fatalf("NewContext(locale) translation = %q, want %q", got, "o")
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestContext_NewContextWithService_UsesLocale(t *testing.T) {
|
||||||
var ctx *Context
|
svc := &localeTranslator{}
|
||||||
if got := ctx.SetService(nil); got != nil {
|
ctx := NewContextWithService(svc, "en-GB")
|
||||||
t.Fatal("SetService on nil context should return nil")
|
|
||||||
|
if svc.language != "en" {
|
||||||
|
t.Fatalf("NewContextWithService should apply locale to translator, got %q", svc.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "y" {
|
||||||
|
t.Fatalf("NewContextWithService translation = %q, want %q", got, "y")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
|
func TestContext_SetLocale_ReappliesToTranslator(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc := &localeTranslator{}
|
||||||
ctx := NewContextWithService(svc)
|
ctx := NewContextWithService(svc, "en-GB")
|
||||||
|
|
||||||
if got := ctx.SetLocale("fr-FR"); got != ctx {
|
ctx.SetLocale("fr-FR")
|
||||||
t.Fatal("SetLocale should return the same context for chaining")
|
|
||||||
|
if ctx.Locale != "fr-FR" {
|
||||||
|
t.Fatalf("SetLocale should update context locale, got %q", ctx.Locale)
|
||||||
}
|
}
|
||||||
|
if svc.language != "fr" {
|
||||||
got := Text("prompt.yes").Render(ctx)
|
t.Fatalf("SetLocale should reapply locale to translator, got %q", svc.language)
|
||||||
if got != "o" {
|
}
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
|
func TestContext_SetService_ReappliesCurrentLocale(t *testing.T) {
|
||||||
var ctx *Context
|
ctx := NewContext("fr-FR")
|
||||||
if got := ctx.SetLocale("en-GB"); got != nil {
|
svc := &localeTranslator{}
|
||||||
t.Fatal("SetLocale on nil context should return nil")
|
|
||||||
|
if got := ctx.SetService(svc); got != ctx {
|
||||||
|
t.Fatalf("SetService should return the same context")
|
||||||
|
}
|
||||||
|
if ctx.service != svc {
|
||||||
|
t.Fatalf("SetService should replace the active translator")
|
||||||
|
}
|
||||||
|
if svc.language != "fr" {
|
||||||
|
t.Fatalf("SetService should apply the existing locale to the new translator, got %q", svc.language)
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
|
t.Fatalf("SetService translation = %q, want %q", got, "o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetEntitlements_UpdatesFeatureGate(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
if got := ctx.SetEntitlements(func(feature string) bool { return feature == "premium" }); got != ctx {
|
||||||
|
t.Fatalf("SetEntitlements should return the same context")
|
||||||
|
}
|
||||||
|
if ctx.Entitlements == nil {
|
||||||
|
t.Fatal("SetEntitlements should store the callback")
|
||||||
|
}
|
||||||
|
if !ctx.Entitlements("premium") {
|
||||||
|
t.Fatal("SetEntitlements should preserve the requested callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetIdentity_UpdatesIdentity(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
if got := ctx.SetIdentity("user-123"); got != ctx {
|
||||||
|
t.Fatalf("SetIdentity should return the same context")
|
||||||
|
}
|
||||||
|
if ctx.Identity != "user-123" {
|
||||||
|
t.Fatalf("SetIdentity should update context identity, got %q", ctx.Identity)
|
||||||
|
}
|
||||||
|
if ctx.Data == nil {
|
||||||
|
t.Fatal("SetIdentity should preserve initialised Data map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetData_StoresValue(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
if got := ctx.SetData("theme", "dark"); got != ctx {
|
||||||
|
t.Fatalf("SetData should return the same context")
|
||||||
|
}
|
||||||
|
if got := ctx.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("SetData should store the requested value, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetData_InitialisesNilMap(t *testing.T) {
|
||||||
|
ctx := &Context{}
|
||||||
|
|
||||||
|
ctx.SetData("theme", "light")
|
||||||
|
|
||||||
|
if ctx.Data == nil {
|
||||||
|
t.Fatal("SetData should initialise the Data map on demand")
|
||||||
|
}
|
||||||
|
if got := ctx.Data["theme"]; got != "light" {
|
||||||
|
t.Fatalf("SetData should store the requested value in a nil map context, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_CloneCopiesDataWithoutSharingMap(t *testing.T) {
|
||||||
|
svc := &localeTranslator{}
|
||||||
|
ctx := NewContextWithService(svc, "en-GB")
|
||||||
|
ctx.SetIdentity("user-123")
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
|
||||||
|
clone := ctx.Clone()
|
||||||
|
|
||||||
|
if clone == ctx {
|
||||||
|
t.Fatal("Clone should return a distinct context instance")
|
||||||
|
}
|
||||||
|
if clone.service == ctx.service {
|
||||||
|
t.Fatal("Clone should duplicate cloneable translators")
|
||||||
|
}
|
||||||
|
if clone.Locale != ctx.Locale {
|
||||||
|
t.Fatalf("Clone should preserve locale, got %q want %q", clone.Locale, ctx.Locale)
|
||||||
|
}
|
||||||
|
if clone.Identity != ctx.Identity {
|
||||||
|
t.Fatalf("Clone should preserve identity, got %q want %q", clone.Identity, ctx.Identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.SetData("theme", "light")
|
||||||
|
if got := ctx.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("Clone should not share Data map with original, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_CloneDoesNotShareDefaultTranslator(t *testing.T) {
|
||||||
|
ctx := NewContext("en-GB")
|
||||||
|
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
t.Fatal("Clone should return a context")
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.SetLocale("fr-FR")
|
||||||
|
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "y" {
|
||||||
|
t.Fatalf("Clone should not mutate original default translator, got %q", got)
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(clone); got != "o" {
|
||||||
|
t.Fatalf("Clone should keep its own default translator, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_CloneClonesMutableTranslator(t *testing.T) {
|
||||||
|
svc := &localeTranslator{}
|
||||||
|
ctx := NewContextWithService(svc, "en-GB")
|
||||||
|
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
t.Fatal("Clone should return a context")
|
||||||
|
}
|
||||||
|
if clone.service == ctx.service {
|
||||||
|
t.Fatal("Clone should isolate cloneable translators")
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.SetLocale("fr-FR")
|
||||||
|
|
||||||
|
if got := svc.language; got != "en" {
|
||||||
|
t.Fatalf("Clone should not mutate the original translator, got %q", got)
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "y" {
|
||||||
|
t.Fatalf("Clone should leave original context translation unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(clone); got != "o" {
|
||||||
|
t.Fatalf("Clone should reapply locale to the cloned translator, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_CloneReappliesLocaleAfterTranslatorClone(t *testing.T) {
|
||||||
|
svc := &resettingTranslator{}
|
||||||
|
ctx := NewContextWithService(svc, "fr-FR")
|
||||||
|
|
||||||
|
clone := ctx.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
t.Fatal("Clone should return a context")
|
||||||
|
}
|
||||||
|
if clone.service == ctx.service {
|
||||||
|
t.Fatal("Clone should duplicate cloneable translators")
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(clone); got != "o" {
|
||||||
|
t.Fatalf("Clone should reapply locale after cloning the translator, got %q", got)
|
||||||
|
}
|
||||||
|
if got := clone.Locale; got != "fr-FR" {
|
||||||
|
t.Fatalf("Clone should preserve locale, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_WithDataReturnsClonedContext(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
|
||||||
|
next := ctx.WithData("locale", "fr-FR")
|
||||||
|
|
||||||
|
if next == ctx {
|
||||||
|
t.Fatal("WithData should return a cloned context")
|
||||||
|
}
|
||||||
|
if got := ctx.Data["locale"]; got != nil {
|
||||||
|
t.Fatalf("WithData should not mutate the original context, got %v", got)
|
||||||
|
}
|
||||||
|
if got := next.Data["locale"]; got != "fr-FR" {
|
||||||
|
t.Fatalf("WithData should set the requested value on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
if got := next.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("WithData should preserve existing data on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_WithIdentityReturnsClonedContext(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
ctx.SetIdentity("user-001")
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
|
||||||
|
next := ctx.WithIdentity("user-123")
|
||||||
|
|
||||||
|
if next == ctx {
|
||||||
|
t.Fatal("WithIdentity should return a cloned context")
|
||||||
|
}
|
||||||
|
if got := ctx.Identity; got != "user-001" {
|
||||||
|
t.Fatalf("WithIdentity should not mutate the original context, got %q", got)
|
||||||
|
}
|
||||||
|
if got := next.Identity; got != "user-123" {
|
||||||
|
t.Fatalf("WithIdentity should set the requested identity on the clone, got %q", got)
|
||||||
|
}
|
||||||
|
if got := next.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("WithIdentity should preserve existing data on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_WithLocaleReturnsClonedContext(t *testing.T) {
|
||||||
|
svc := &localeTranslator{}
|
||||||
|
ctx := NewContextWithService(svc, "en-GB")
|
||||||
|
ctx.SetIdentity("user-001")
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
|
||||||
|
next := ctx.WithLocale("fr-FR")
|
||||||
|
|
||||||
|
if next == ctx {
|
||||||
|
t.Fatal("WithLocale should return a cloned context")
|
||||||
|
}
|
||||||
|
if got := ctx.Locale; got != "en-GB" {
|
||||||
|
t.Fatalf("WithLocale should not mutate the original context locale, got %q", got)
|
||||||
|
}
|
||||||
|
if got := next.Locale; got != "fr-FR" {
|
||||||
|
t.Fatalf("WithLocale should set the requested locale on the clone, got %q", got)
|
||||||
|
}
|
||||||
|
if got := next.service; got == ctx.service {
|
||||||
|
t.Fatal("WithLocale should duplicate cloneable translators on the clone")
|
||||||
|
}
|
||||||
|
if svc.language != "en" {
|
||||||
|
t.Fatalf("WithLocale should not mutate the original translator, got %q", svc.language)
|
||||||
|
}
|
||||||
|
if got := Text("prompt.yes").Render(next); got != "o" {
|
||||||
|
t.Fatalf("WithLocale should reapply locale to the cloned service, got %q", got)
|
||||||
|
}
|
||||||
|
if got := next.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("WithLocale should preserve existing data on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_WithServiceReturnsClonedContext(t *testing.T) {
|
||||||
|
ctx := NewContext("fr-FR")
|
||||||
|
ctx.SetIdentity("user-001")
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
|
||||||
|
svc := &localeTranslator{}
|
||||||
|
next := ctx.WithService(svc)
|
||||||
|
|
||||||
|
if next == ctx {
|
||||||
|
t.Fatal("WithService should return a cloned context")
|
||||||
|
}
|
||||||
|
if got := ctx.service; got == svc {
|
||||||
|
t.Fatal("WithService should not mutate the original context service")
|
||||||
|
}
|
||||||
|
if got := next.service; got != svc {
|
||||||
|
t.Fatalf("WithService should set the requested service on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
if svc.language != "fr" {
|
||||||
|
t.Fatalf("WithService should apply the existing locale to the new translator, got %q", svc.language)
|
||||||
|
}
|
||||||
|
if got := next.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("WithService should preserve existing data on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
if got := next.Locale; got != "fr-FR" {
|
||||||
|
t.Fatalf("WithService should preserve the locale on the clone, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_WithEntitlementsReturnsClonedContext(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
ctx.SetIdentity("user-001")
|
||||||
|
ctx.SetData("theme", "dark")
|
||||||
|
ctx.SetEntitlements(func(feature string) bool { return feature == "basic" })
|
||||||
|
|
||||||
|
next := ctx.WithEntitlements(func(feature string) bool { return feature == "premium" })
|
||||||
|
|
||||||
|
if next == ctx {
|
||||||
|
t.Fatal("WithEntitlements should return a cloned context")
|
||||||
|
}
|
||||||
|
if ctx.Entitlements == nil {
|
||||||
|
t.Fatal("WithEntitlements should not clear the original callback")
|
||||||
|
}
|
||||||
|
if !ctx.Entitlements("basic") {
|
||||||
|
t.Fatal("WithEntitlements should preserve the original callback")
|
||||||
|
}
|
||||||
|
if next.Entitlements == nil {
|
||||||
|
t.Fatal("WithEntitlements should store the new callback on the clone")
|
||||||
|
}
|
||||||
|
if next.Entitlements("basic") {
|
||||||
|
t.Fatal("WithEntitlements should replace the callback on the clone")
|
||||||
|
}
|
||||||
|
if !next.Entitlements("premium") {
|
||||||
|
t.Fatal("WithEntitlements should preserve the new callback")
|
||||||
|
}
|
||||||
|
if got := next.Data["theme"]; got != "dark" {
|
||||||
|
t.Fatalf("WithEntitlements should preserve existing data on the clone, got %v", got)
|
||||||
|
}
|
||||||
|
if got := next.Identity; got != "user-001" {
|
||||||
|
t.Fatalf("WithEntitlements should preserve identity on the clone, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_Setters_NilReceiver(t *testing.T) {
|
||||||
|
var ctx *Context
|
||||||
|
|
||||||
|
if got := ctx.SetIdentity("user-123"); got != nil {
|
||||||
|
t.Fatalf("nil Context.SetIdentity should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.SetData("theme", "dark"); got != nil {
|
||||||
|
t.Fatalf("nil Context.SetData should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.SetLocale("en-GB"); got != nil {
|
||||||
|
t.Fatalf("nil Context.SetLocale should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.SetService(&localeTranslator{}); got != nil {
|
||||||
|
t.Fatalf("nil Context.SetService should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.SetEntitlements(func(string) bool { return true }); got != nil {
|
||||||
|
t.Fatalf("nil Context.SetEntitlements should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.WithLocale("en-GB"); got != nil {
|
||||||
|
t.Fatalf("nil Context.WithLocale should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.WithService(&localeTranslator{}); got != nil {
|
||||||
|
t.Fatalf("nil Context.WithService should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ctx.WithEntitlements(func(string) bool { return true }); got != nil {
|
||||||
|
t.Fatalf("nil Context.WithEntitlements should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_RenderFallsBackToDefaultTranslator(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
i18n.SetDefault(svc)
|
||||||
|
require.NoError(t, svc.SetLanguage("fr"))
|
||||||
|
|
||||||
|
ctx := &Context{}
|
||||||
|
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
|
t.Fatalf("Text() fallback translation = %q, want %q", got, "o")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
default_translator_default.go
Normal file
11
default_translator_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 newDefaultTranslator() Translator {
|
||||||
|
return &i18n.Service{}
|
||||||
|
}
|
||||||
46
default_translator_js.go
Normal file
46
default_translator_js.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
type defaultTranslator struct {
|
||||||
|
language string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *defaultTranslator) T(key string, args ...any) string {
|
||||||
|
if key == "prompt.yes" {
|
||||||
|
switch t.language {
|
||||||
|
case "fr":
|
||||||
|
return "o"
|
||||||
|
case "en":
|
||||||
|
return "y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *defaultTranslator) SetLanguage(language string) error {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.language = language
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *defaultTranslator) Clone() Translator {
|
||||||
|
if t == nil {
|
||||||
|
return (*defaultTranslator)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *t
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDefaultTranslator() Translator {
|
||||||
|
return &defaultTranslator{}
|
||||||
|
}
|
||||||
15
default_translator_js_test.go
Normal file
15
default_translator_js_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefaultTranslatorJS_AppliesLocale(t *testing.T) {
|
||||||
|
ctx := NewContext("fr-FR")
|
||||||
|
|
||||||
|
if got := Text("prompt.yes").Render(ctx); got != "o" {
|
||||||
|
t.Fatalf("Text(prompt.yes) with js default translator = %q, want %q", got, "o")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
deps/core/go.mod
vendored
Normal file
3
deps/core/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module dappco.re/go/core
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
3
deps/go-i18n/go.mod
vendored
Normal file
3
deps/go-i18n/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module dappco.re/go/core/i18n
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
72
deps/go-i18n/i18n.go
vendored
Normal file
72
deps/go-i18n/i18n.go
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: Service is a minimal translation service for local verification.
|
||||||
|
type Service struct {
|
||||||
|
language string
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultService = &Service{}
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: New returns a new translation service.
|
||||||
|
// Example: svc, err := New().
|
||||||
|
func New() (*Service, error) {
|
||||||
|
return &Service{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: SetDefault sets the process-wide default service.
|
||||||
|
// Example: SetDefault(svc).
|
||||||
|
func SetDefault(svc *Service) {
|
||||||
|
if svc == nil {
|
||||||
|
svc = &Service{}
|
||||||
|
}
|
||||||
|
defaultService = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: SetLanguage records the active language for locale-aware lookups.
|
||||||
|
// Example: _ = svc.SetLanguage("en-GB").
|
||||||
|
func (s *Service) SetLanguage(language string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
base := language
|
||||||
|
for i := 0; i < len(base); i++ {
|
||||||
|
if base[i] == '-' || base[i] == '_' {
|
||||||
|
base = base[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.language = strings.ToLower(base)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: T returns a translated string for key.
|
||||||
|
// Example: label := T("prompt.yes").
|
||||||
|
func T(key string, args ...any) string {
|
||||||
|
return defaultService.T(key, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deps/go-i18n/i18n.go: T returns a translated string for key.
|
||||||
|
// Example: label := svc.T("prompt.yes").
|
||||||
|
func (s *Service) T(key string, args ...any) string {
|
||||||
|
if s != nil {
|
||||||
|
switch key {
|
||||||
|
case "prompt.yes":
|
||||||
|
switch s.language {
|
||||||
|
case "fr":
|
||||||
|
return "o"
|
||||||
|
case "en":
|
||||||
|
return "y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(key, args...)
|
||||||
|
}
|
||||||
113
deps/go-i18n/reversal/reversal.go
vendored
Normal file
113
deps/go-i18n/reversal/reversal.go
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package reversal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token represents a normalised word token.
|
||||||
|
type Token struct {
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokeniser splits text into word tokens.
|
||||||
|
type Tokeniser struct{}
|
||||||
|
|
||||||
|
// NewTokeniser returns a tokeniser.
|
||||||
|
func NewTokeniser() *Tokeniser {
|
||||||
|
return &Tokeniser{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokenise extracts lower-cased word tokens from text.
|
||||||
|
func (t *Tokeniser) Tokenise(text string) []Token {
|
||||||
|
fields := strings.FieldsFunc(text, func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||||
|
})
|
||||||
|
tokens := make([]Token, 0, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if f == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens = append(tokens, Token{Text: strings.ToLower(f)})
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrammarImprint captures token statistics for semantic comparison.
|
||||||
|
type GrammarImprint struct {
|
||||||
|
TokenCount int
|
||||||
|
UniqueVerbs int
|
||||||
|
tokens []Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewImprint creates an imprint from tokens.
|
||||||
|
func NewImprint(tokens []Token) GrammarImprint {
|
||||||
|
verbs := make(map[string]struct{})
|
||||||
|
for _, tok := range tokens {
|
||||||
|
if looksLikeVerb(tok.Text) {
|
||||||
|
verbs[tok.Text] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cp := make([]Token, len(tokens))
|
||||||
|
copy(cp, tokens)
|
||||||
|
return GrammarImprint{
|
||||||
|
TokenCount: len(tokens),
|
||||||
|
UniqueVerbs: len(verbs),
|
||||||
|
tokens: cp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similar scores overlap between two imprints on a 0..1 scale.
|
||||||
|
func (g GrammarImprint) Similar(other GrammarImprint) float64 {
|
||||||
|
if g.TokenCount == 0 && other.TokenCount == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
left := make(map[string]struct{}, len(g.tokens))
|
||||||
|
for _, tok := range g.tokens {
|
||||||
|
left[tok.Text] = struct{}{}
|
||||||
|
}
|
||||||
|
right := make(map[string]struct{}, len(other.tokens))
|
||||||
|
for _, tok := range other.tokens {
|
||||||
|
right[tok.Text] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(left) == 0 && len(right) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := 0
|
||||||
|
for tok := range left {
|
||||||
|
if _, ok := right[tok]; ok {
|
||||||
|
shared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
union := len(left)
|
||||||
|
for tok := range right {
|
||||||
|
if _, ok := left[tok]; !ok {
|
||||||
|
union++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if union == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Max(0, math.Min(1, float64(shared)/float64(union)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeVerb(s string) bool {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"ing", "ed", "en", "ify", "ise", "ize"} {
|
||||||
|
if strings.HasSuffix(s, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "build", "delete", "remove", "complete", "launch", "render", "read", "write", "open", "close":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
3
deps/go-io/go.mod
vendored
Normal file
3
deps/go-io/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module dappco.re/go/core/io
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
22
deps/go-io/io.go
vendored
Normal file
22
deps/go-io/io.go
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package io
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// Local provides local filesystem helpers.
|
||||||
|
var Local localFS
|
||||||
|
|
||||||
|
type localFS struct{}
|
||||||
|
|
||||||
|
// Read returns the file contents as a string.
|
||||||
|
func (localFS) Read(path string) (string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write stores content at path, replacing any existing file.
|
||||||
|
func (localFS) Write(path, content string) error {
|
||||||
|
return os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
}
|
||||||
3
deps/go-log/go.mod
vendored
Normal file
3
deps/go-log/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module dappco.re/go/core/log
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
25
deps/go-log/log.go
vendored
Normal file
25
deps/go-log/log.go
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deps/go-log/log.go: E wraps an error with scope and message.
|
||||||
|
// Example: err := E("cmd/scan", "load manifest", io.EOF).
|
||||||
|
func E(scope, message string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New(scope + ": " + message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %s: %w", scope, message, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deps/go-log/log.go: Error writes an error-level message.
|
||||||
|
// Example: Error("manifest validation failed", "path", path).
|
||||||
|
// This stub is a no-op for tests.
|
||||||
|
func Error(msg string, _ ...any) {}
|
||||||
|
|
||||||
|
// deps/go-log/log.go: Info writes an info-level message.
|
||||||
|
// Example: Info("registry reloaded", "count", 3).
|
||||||
|
// This stub is a no-op for tests.
|
||||||
|
func Info(msg string, _ ...any) {}
|
||||||
12
doc.go
12
doc.go
|
|
@ -1,12 +0,0 @@
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
// Package html renders semantic HTML from composable node trees.
|
|
||||||
//
|
|
||||||
// A typical page combines Layout, El, Text, and Render:
|
|
||||||
//
|
|
||||||
// page := NewLayout("HCF").
|
|
||||||
// H(El("h1", Text("page.title"))).
|
|
||||||
// C(El("main", Text("page.body"))).
|
|
||||||
// F(El("small", Text("page.footer")))
|
|
||||||
// out := Render(page, NewContext())
|
|
||||||
package html
|
|
||||||
|
|
@ -17,18 +17,13 @@ type Node interface {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
|
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers:
|
||||||
|
|
||||||
| Constructor | Behaviour |
|
| Constructor | Behaviour |
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
|
| `El(tag, ...Node)` | HTML element with children. 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. |
|
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Switch`, and iterator wrappers. Returns the node for chaining. |
|
||||||
| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. |
|
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
|
||||||
| `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. |
|
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
|
||||||
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
|
| `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. |
|
| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
|
||||||
|
|
@ -37,6 +32,49 @@ All concrete node types are unexported structs with exported constructor functio
|
||||||
| `Switch(selector, cases)` | Renders one of several named cases based on a runtime selector function. Returns empty string when no case matches. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
|
Accessibility-oriented helpers are also provided for common attribute patterns:
|
||||||
|
|
||||||
|
- `AriaLabel(node, label)`
|
||||||
|
- `AriaDescribedBy(node, ids...)`
|
||||||
|
- `AriaLabelledBy(node, ids...)`
|
||||||
|
- `AriaControls(node, ids...)`
|
||||||
|
- `AriaHasPopup(node, popup)`
|
||||||
|
- `AriaOwns(node, ids...)`
|
||||||
|
- `AriaKeyShortcuts(node, shortcuts...)`
|
||||||
|
- `AriaCurrent(node, current)`
|
||||||
|
- `AriaBusy(node, busy)`
|
||||||
|
- `AriaLive(node, live)`
|
||||||
|
- `AriaAtomic(node, atomic)`
|
||||||
|
- `AriaDescription(node, description)`
|
||||||
|
- `AriaDetails(node, ids...)`
|
||||||
|
- `AriaErrorMessage(node, ids...)`
|
||||||
|
- `AriaRoleDescription(node, description)`
|
||||||
|
- `Role(node, role)`
|
||||||
|
- `Lang(node, locale)`
|
||||||
|
- `Dir(node, direction)`
|
||||||
|
- `Alt(node, text)`
|
||||||
|
- `Title(node, text)`
|
||||||
|
- `Placeholder(node, text)`
|
||||||
|
- `Class(node, classes...)`
|
||||||
|
- `AriaHidden(node, hidden)`
|
||||||
|
- `AriaExpanded(node, expanded)`
|
||||||
|
- `AriaDisabled(node, disabled)`
|
||||||
|
- `AriaModal(node, modal)`
|
||||||
|
- `AriaChecked(node, checked)`
|
||||||
|
- `AriaInvalid(node, invalid)`
|
||||||
|
- `AriaRequired(node, required)`
|
||||||
|
- `AriaReadOnly(node, readonly)`
|
||||||
|
- `Disabled(node, disabled)`
|
||||||
|
- `Checked(node, checked)`
|
||||||
|
- `Required(node, required)`
|
||||||
|
- `ReadOnly(node, readonly)`
|
||||||
|
- `Selected(node, selected)`
|
||||||
|
- `TabIndex(node, index)`
|
||||||
|
- `AutoFocus(node)`
|
||||||
|
- `ID(node, id)`
|
||||||
|
- `For(node, target)`
|
||||||
|
- `Autocomplete(node, value)`
|
||||||
|
|
||||||
### Safety Guarantees
|
### Safety Guarantees
|
||||||
|
|
||||||
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
|
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
|
||||||
|
|
@ -55,16 +93,16 @@ type Context struct {
|
||||||
Locale string // BCP 47 locale string
|
Locale string // BCP 47 locale string
|
||||||
Entitlements func(feature string) bool // feature gate callback
|
Entitlements func(feature string) bool // feature gate callback
|
||||||
Data map[string]any // arbitrary per-request data
|
Data map[string]any // arbitrary per-request data
|
||||||
service Translator // unexported; set via constructor
|
service *i18n.Service // unexported; set via constructor
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Two constructors are provided:
|
Two constructors are provided:
|
||||||
|
|
||||||
- `NewContext()` creates a context with sensible defaults and an empty `Data` map.
|
- `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`.
|
- `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance.
|
||||||
|
|
||||||
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 `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. Callers can replace the active translator with `SetService()` or `WithService()`, which reapply the current locale to the new service.
|
||||||
|
|
||||||
## HLCRF Layout
|
## HLCRF Layout
|
||||||
|
|
||||||
|
|
@ -89,7 +127,7 @@ NewLayout("C") // content only
|
||||||
NewLayout("LC") // left sidebar and content
|
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.
|
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 during rendering, but `ValidateLayoutVariant()` and `Layout.VariantError()` report the invalid input.
|
||||||
|
|
||||||
### Deterministic Block IDs
|
### Deterministic Block IDs
|
||||||
|
|
||||||
|
|
@ -166,12 +204,17 @@ html.NewResponsive().
|
||||||
|
|
||||||
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic.
|
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.
|
`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.
|
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.
|
||||||
|
|
||||||
|
Two helpers support CSS targeting:
|
||||||
|
|
||||||
|
```go
|
||||||
|
VariantSelector("desktop") // [data-variant="desktop"]
|
||||||
|
ScopeVariant("desktop", ".nav") // [data-variant="desktop"] .nav
|
||||||
|
```
|
||||||
|
|
||||||
## Grammar Pipeline (Server-Side Only)
|
## 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.
|
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.
|
||||||
|
|
@ -215,10 +258,11 @@ A single-variant `Responsive` produces an empty score map (no pairs to compare).
|
||||||
|
|
||||||
## WASM Module
|
## WASM Module
|
||||||
|
|
||||||
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function:
|
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes two JavaScript functions:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
gohtml.renderToString(variant, locale, slots)
|
gohtml.renderToString(variant, locale, slots)
|
||||||
|
gohtml.registerComponents(slots)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
@ -229,6 +273,8 @@ gohtml.renderToString(variant, locale, slots)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
`registerComponents(slots)` accepts the same slot-map shape used by the codegen CLI and registers closed-shadow custom elements in the browser at runtime. It skips invalid or duplicate tags and mirrors the generated component lifecycle by dispatching a bubbling, composed `wc-ready` event when each element connects.
|
||||||
|
|
||||||
### Size Budget
|
### Size Budget
|
||||||
|
|
||||||
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
|
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
|
||||||
|
|
@ -263,7 +309,7 @@ The `codegen` package (`codegen/codegen.go`) generates ES2022 class definitions
|
||||||
|
|
||||||
1. A class extending `HTMLElement` with a private `#shadow` field.
|
1. A class extending `HTMLElement` with a private `#shadow` field.
|
||||||
2. `constructor()` attaching a closed shadow root (`mode: "closed"`).
|
2. `constructor()` attaching a closed shadow root (`mode: "closed"`).
|
||||||
3. `connectedCallback()` dispatching a `wc-ready` custom event with the tag name and slot.
|
3. `connectedCallback()` dispatching a bubbling, composed `wc-ready` custom event with the tag name and slot.
|
||||||
4. `render(html)` method that sets shadow content from a `<template>` clone.
|
4. `render(html)` method that sets shadow content from a `<template>` clone.
|
||||||
5. A `customElements.define()` registration call.
|
5. A `customElements.define()` registration call.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ go test ./cmd/codegen/
|
||||||
go test ./cmd/wasm/
|
go test ./cmd/wasm/
|
||||||
```
|
```
|
||||||
|
|
||||||
The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
|
The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
|
||||||
|
|
||||||
### Test Dependencies
|
### Test Dependencies
|
||||||
|
|
||||||
|
|
@ -145,23 +145,15 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
|
||||||
|
|
||||||
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
|
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:
|
To run the daemon mode, point it at an input JSON file and an output bundle path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo '{"H":"site-header","C":"app-content"}' \
|
go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
||||||
| go run ./cmd/codegen/ -types \
|
|
||||||
> components.d.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For local development, `-watch` polls an input JSON file and rewrites the
|
Watch mode keeps polling through transient missing files and invalid JSON edits, then rewrites the output bundle once the input becomes valid again.
|
||||||
output file whenever the slot map changes:
|
|
||||||
|
|
||||||
```bash
|
Add `-dts` to emit TypeScript declarations instead of JavaScript in either mode.
|
||||||
go run ./cmd/codegen/ \
|
|
||||||
-watch \
|
|
||||||
-input slots.json \
|
|
||||||
-output components.js
|
|
||||||
```
|
|
||||||
|
|
||||||
To test the CLI:
|
To test the CLI:
|
||||||
|
|
||||||
|
|
@ -296,7 +288,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||||
### Codegen Tests with Testify
|
### Codegen Tests with Testify
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestGenerateClass_ValidTag(t *testing.T) {
|
func TestGenerateClass_Good(t *testing.T) {
|
||||||
js, err := GenerateClass("photo-grid", "C")
|
js, err := GenerateClass("photo-grid", "C")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||||
|
|
@ -309,6 +301,6 @@ func TestGenerateClass_ValidTag(t *testing.T) {
|
||||||
|
|
||||||
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
|
- `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.
|
- `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.
|
- `Context.service` is unexported and fixed at construction time. Use `NewContextWithService()` to supply a custom translator, or `NewContext(locale...)` to apply a locale to the default translator up front.
|
||||||
- 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.
|
- 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.
|
- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
|
||||||
|
|
||||||
### Size gate test (`aae5d21`)
|
### Size gate test (`aae5d21`)
|
||||||
|
|
||||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||||
|
|
||||||
- Gzip size < 1,048,576 bytes (1 MB).
|
- Gzip size < 1,048,576 bytes (1 MB).
|
||||||
- Raw size < 3,145,728 bytes (3 MB).
|
- Raw size < 3,145,728 bytes (3 MB).
|
||||||
|
|
@ -95,17 +95,17 @@ The test is skipped under `go test -short` and is guarded with `//go:build !js`.
|
||||||
|
|
||||||
These are not regressions; they are design choices or deferred work recorded for future consideration.
|
These are not regressions; they are design choices or deferred work recorded for future consideration.
|
||||||
|
|
||||||
1. **Invalid layout variants are silent.** `NewLayout("XYZ")` produces empty output. No error, no warning. Adding validation would require changing the return type of `NewLayout` from `*Layout` to `(*Layout, error)`, which is a breaking API change.
|
1. **Invalid layout variants are reported, not fatal.** `NewLayout("XYZ")` still produces empty output at render time, but `ValidateLayoutVariant()` and `Layout.VariantError()` surface the invalid characters without changing the constructor signature.
|
||||||
|
|
||||||
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
|
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
|
||||||
|
|
||||||
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
||||||
|
|
||||||
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
|
4. **Context.service is private, but swappable through setters.** The i18n service remains unexported, but `SetService()` and `WithService()` let callers replace it while keeping the current locale applied.
|
||||||
|
|
||||||
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
|
5. **TypeScript definitions are generated.** `codegen.GenerateTypeDefinitions()` produces a `.d.ts` companion for the generated Web Components.
|
||||||
|
|
||||||
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
|
6. **CSS scoping helper added.** `VariantSelector()` and `ScopeVariant()` generate selectors for `data-variant` containers, making responsive variants easier to target from CSS.
|
||||||
|
|
||||||
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
||||||
|
|
||||||
|
|
@ -113,8 +113,8 @@ These are not regressions; they are design choices or deferred work recorded for
|
||||||
|
|
||||||
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
||||||
|
|
||||||
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
|
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. Implemented via `GenerateTypeDefinitions()`.
|
||||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
- **Accessibility helpers** — `aria-label`, `alt`, `aria-hidden`, and `tabindex` helpers. The layout has semantic HTML and ARIA roles, and the node layer now exposes common accessibility attribute shortcuts beyond `Attr()`.
|
||||||
- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
|
- **Layout variant validation** — `ValidateLayoutVariant()` and `Layout.VariantError()` report unrecognised slot characters while preserving silent render-time skipping.
|
||||||
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
- **CSS scoping helper** — `VariantSelector()` and `ScopeVariant()` generate selectors for responsive variants identified by `data-variant` attributes.
|
||||||
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
||||||
|
|
|
||||||
|
|
@ -39,28 +39,28 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
|
||||||
|
|
||||||
| Path | Purpose |
|
| 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 |
|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AriaControls`, `AriaHasPopup`, `AriaOwns`, `AriaKeyShortcuts`, `Alt`/`AltText`, `Title`, `Placeholder`, `Autocomplete`, `AriaBusy`, `AriaLive`, `AriaAtomic`, `AriaDescription`, `AriaDetails`, `AriaErrorMessage`, `AriaRoleDescription`, `AriaDisabled`, `AriaModal`, `AriaChecked`, `AriaInvalid`, `AriaRequired`, `AriaPressed`, `AriaSelected`, `Hidden`, `Disabled`, `Checked`, `Required`, `ReadOnly`, `Selected`, `TabIndex`, and `AutoFocus` helpers |
|
||||||
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
||||||
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
|
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers, CSS scoping helpers) |
|
||||||
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
|
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
|
||||||
| `render.go` | `Render()` convenience function |
|
| `render.go` | `Render()` convenience function |
|
||||||
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
|
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
|
||||||
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
|
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
|
||||||
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
|
| `codegen/codegen.go` | Web Component class generation and TypeScript declarations (closed Shadow DOM) |
|
||||||
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
|
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output, `-watch` for file polling |
|
||||||
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
|
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` and `registerComponents()` to JavaScript |
|
||||||
|
|
||||||
## Key Concepts
|
## 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`).
|
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), plus accessibility and visibility helpers such as `AriaLabel()`, `AriaControls()`, `AriaHasPopup()`, `AriaOwns()`, `AriaKeyShortcuts()`, `AriaCurrent()`, `AriaBusy()`, `AriaLive()`, `AriaAtomic()`, `AriaDescription()`, `AriaDetails()`, `AriaErrorMessage()`, `AriaRoleDescription()`, `AriaHidden()`, `Hidden()`, `AriaDisabled()`, `AriaModal()`, `AriaChecked()`, `AriaInvalid()`, `AriaRequired()`, `AriaReadOnly()`, `Disabled()`, `Checked()`, `Required()`, `ReadOnly()`, `Selected()`, `TabIndex()`, and the common HTML attribute helpers `Alt()`, `AltText()`, `Title()`, `Placeholder()`, `Autocomplete()`, `ID()`, `For()`, `Class()`, and `AutoFocus()`.
|
||||||
|
|
||||||
**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`.
|
**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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
**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. It also supports `-watch` for polling an input JSON file and rewriting an output bundle in place.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|
|
||||||
249
edge_test.go
249
edge_test.go
|
|
@ -1,7 +1,8 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
|
@ -9,7 +10,7 @@ import (
|
||||||
|
|
||||||
// --- Unicode / RTL edge cases ---
|
// --- Unicode / RTL edge cases ---
|
||||||
|
|
||||||
func TestText_Emoji_Ugly(t *testing.T) {
|
func TestText_Emoji(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -32,7 +33,7 @@ func TestText_Emoji_Ugly(t *testing.T) {
|
||||||
t.Error("Text with emoji should not produce empty output")
|
t.Error("Text with emoji should not produce empty output")
|
||||||
}
|
}
|
||||||
// Emoji should pass through (they are not HTML special chars)
|
// Emoji should pass through (they are not HTML special chars)
|
||||||
if !containsText(got, tt.input) {
|
if !strings.Contains(got, tt.input) {
|
||||||
// Some chars may get escaped, but emoji bytes should survive
|
// Some chars may get escaped, but emoji bytes should survive
|
||||||
t.Logf("note: emoji text rendered as %q", got)
|
t.Logf("note: emoji text rendered as %q", got)
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +41,7 @@ func TestText_Emoji_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEl_Emoji_Ugly(t *testing.T) {
|
func TestEl_Emoji(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("span", Raw("\U0001F680 Launch"))
|
node := El("span", Raw("\U0001F680 Launch"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -50,7 +51,7 @@ func TestEl_Emoji_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_RTL_Ugly(t *testing.T) {
|
func TestText_RTL(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -75,19 +76,19 @@ func TestText_RTL_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEl_RTL_Ugly(t *testing.T) {
|
func TestEl_RTL(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
if !containsText(got, `dir="rtl"`) {
|
if !strings.Contains(got, `dir="rtl"`) {
|
||||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
|
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_ZeroWidth_Ugly(t *testing.T) {
|
func TestText_ZeroWidth(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -113,7 +114,7 @@ func TestText_ZeroWidth_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_MixedScripts_Ugly(t *testing.T) {
|
func TestText_MixedScripts(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -140,7 +141,7 @@ func TestText_MixedScripts_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Unicode_Ugly(t *testing.T) {
|
func TestStripTags_Unicode(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|
@ -162,19 +163,19 @@ func TestStripTags_Unicode_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
|
func TestAttr_UnicodeValue(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
want := "title=\"\U0001F680 Rocket Launch\""
|
want := "title=\"\U0001F680 Rocket Launch\""
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deep nesting stress tests ---
|
// --- Deep nesting stress tests ---
|
||||||
|
|
||||||
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// Build 10 levels of nested layouts
|
// Build 10 levels of nested layouts
|
||||||
|
|
@ -186,7 +187,7 @@ func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
|
|
||||||
// Should contain the deepest content
|
// Should contain the deepest content
|
||||||
if !containsText(got, "deepest") {
|
if !strings.Contains(got, "deepest") {
|
||||||
t.Error("10 levels deep: missing leaf content")
|
t.Error("10 levels deep: missing leaf content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,17 +196,17 @@ func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||||
for i := 1; i < 10; i++ {
|
for i := 1; i < 10; i++ {
|
||||||
expectedBlock += "-C-0"
|
expectedBlock += "-C-0"
|
||||||
}
|
}
|
||||||
if !containsText(got, `data-block="`+expectedBlock+`"`) {
|
if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
|
||||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have exactly 10 <main> tags
|
// Must have exactly 10 <main> tags
|
||||||
if count := countText(got, "<main"); count != 10 {
|
if count := strings.Count(got, "<main"); count != 10 {
|
||||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
current := NewLayout("C").C(Raw("bottom"))
|
current := NewLayout("C").C(Raw("bottom"))
|
||||||
|
|
@ -215,15 +216,15 @@ func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
||||||
|
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, "bottom") {
|
if !strings.Contains(got, "bottom") {
|
||||||
t.Error("20 levels deep: missing leaf content")
|
t.Error("20 levels deep: missing leaf content")
|
||||||
}
|
}
|
||||||
if count := countText(got, "<main"); count != 20 {
|
if count := strings.Count(got, "<main"); count != 20 {
|
||||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||||
|
|
@ -237,12 +238,12 @@ func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
if !containsText(got, "leaf") {
|
if !strings.Contains(got, "leaf") {
|
||||||
t.Error("mixed deep nesting: missing leaf content")
|
t.Error("mixed deep nesting: missing leaf content")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
func TestEach_LargeIteration_1000(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
items := make([]int, 1000)
|
items := make([]int, 1000)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
|
@ -250,23 +251,23 @@ func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
node := Each(items, func(i int) Node {
|
node := Each(items, func(i int) Node {
|
||||||
return El("li", Raw(itoaText(i)))
|
return El("li", Raw(fmt.Sprintf("%d", i)))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := countText(got, "<li>"); count != 1000 {
|
if count := strings.Count(got, "<li>"); count != 1000 {
|
||||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||||
}
|
}
|
||||||
if !containsText(got, "<li>0</li>") {
|
if !strings.Contains(got, "<li>0</li>") {
|
||||||
t.Error("Each with 1000 items: missing first item")
|
t.Error("Each with 1000 items: missing first item")
|
||||||
}
|
}
|
||||||
if !containsText(got, "<li>999</li>") {
|
if !strings.Contains(got, "<li>999</li>") {
|
||||||
t.Error("Each with 1000 items: missing last item")
|
t.Error("Each with 1000 items: missing last item")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
func TestEach_LargeIteration_5000(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
items := make([]int, 5000)
|
items := make([]int, 5000)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
|
@ -274,43 +275,43 @@ func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
node := Each(items, func(i int) Node {
|
node := Each(items, func(i int) Node {
|
||||||
return El("span", Raw(itoaText(i)))
|
return El("span", Raw(fmt.Sprintf("%d", i)))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := countText(got, "<span>"); count != 5000 {
|
if count := strings.Count(got, "<span>"); count != 5000 {
|
||||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_NestedEach_Ugly(t *testing.T) {
|
func TestEach_NestedEach(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
rows := []int{0, 1, 2}
|
rows := []int{0, 1, 2}
|
||||||
cols := []string{"a", "b", "c"}
|
cols := []string{"a", "b", "c"}
|
||||||
|
|
||||||
node := Each(rows, func(row int) Node {
|
node := Each(rows, func(row int) Node {
|
||||||
return El("tr", Each(cols, func(col string) Node {
|
return El("tr", Each(cols, func(col string) Node {
|
||||||
return El("td", Raw(itoaText(row)+"-"+col))
|
return El("td", Raw(fmt.Sprintf("%d-%s", row, col)))
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := countText(got, "<tr>"); count != 3 {
|
if count := strings.Count(got, "<tr>"); count != 3 {
|
||||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||||
}
|
}
|
||||||
if count := countText(got, "<td>"); count != 9 {
|
if count := strings.Count(got, "<td>"); count != 9 {
|
||||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||||
}
|
}
|
||||||
if !containsText(got, "1-b") {
|
if !strings.Contains(got, "1-b") {
|
||||||
t.Error("nested Each: missing cell content '1-b'")
|
t.Error("nested Each: missing cell content '1-b'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Layout variant validation ---
|
// --- Layout variant validation ---
|
||||||
|
|
||||||
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -342,96 +343,7 @@ func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_VariantError_Bad(t *testing.T) {
|
func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
variant string
|
|
||||||
wantInvalid bool
|
|
||||||
wantErrString string
|
|
||||||
build func(*Layout)
|
|
||||||
wantRender string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid variant",
|
|
||||||
variant: "HCF",
|
|
||||||
wantInvalid: false,
|
|
||||||
build: func(layout *Layout) {
|
|
||||||
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
|
||||||
},
|
|
||||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main><footer role="contentinfo" data-block="F-0">footer</footer>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed invalid variant",
|
|
||||||
variant: "HXC",
|
|
||||||
wantInvalid: true,
|
|
||||||
wantErrString: "html: invalid layout variant HXC",
|
|
||||||
build: func(layout *Layout) {
|
|
||||||
layout.H(Raw("header")).C(Raw("main"))
|
|
||||||
},
|
|
||||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main>`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
layout := NewLayout(tt.variant)
|
|
||||||
if tt.build != nil {
|
|
||||||
tt.build(layout)
|
|
||||||
}
|
|
||||||
if tt.wantInvalid {
|
|
||||||
if layout.VariantError() == nil {
|
|
||||||
t.Fatalf("VariantError() = nil, want sentinel error for %q", tt.variant)
|
|
||||||
}
|
|
||||||
if !errors.Is(layout.VariantError(), ErrInvalidLayoutVariant) {
|
|
||||||
t.Fatalf("VariantError() = %v, want errors.Is(..., ErrInvalidLayoutVariant)", layout.VariantError())
|
|
||||||
}
|
|
||||||
if got := layout.VariantError().Error(); got != tt.wantErrString {
|
|
||||||
t.Fatalf("VariantError().Error() = %q, want %q", got, tt.wantErrString)
|
|
||||||
}
|
|
||||||
} else if layout.VariantError() != nil {
|
|
||||||
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
|
||||||
}
|
|
||||||
|
|
||||||
got := layout.Render(NewContext())
|
|
||||||
if got != tt.wantRender {
|
|
||||||
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateLayoutVariant_Good(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
variant string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{name: "valid", variant: "HCF", wantErr: false},
|
|
||||||
{name: "invalid", variant: "HXC", wantErr: true},
|
|
||||||
{name: "empty", variant: "", wantErr: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := ValidateLayoutVariant(tt.variant)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("ValidateLayoutVariant(%q) = nil, want error", tt.variant)
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidLayoutVariant) {
|
|
||||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want ErrInvalidLayoutVariant", tt.variant, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
||||||
|
|
@ -439,75 +351,60 @@ func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
||||||
H(Raw("header")).C(Raw("main"))
|
H(Raw("header")).C(Raw("main"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, "header") {
|
if !strings.Contains(got, "header") {
|
||||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, "main") {
|
if !strings.Contains(got, "main") {
|
||||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
||||||
}
|
}
|
||||||
// Should only have 2 semantic elements
|
// Should only have 2 semantic elements
|
||||||
if count := countText(got, "data-block="); count != 2 {
|
if count := strings.Count(got, "data-block="); count != 2 {
|
||||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
|
func TestLayout_DuplicateVariantChars(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// "CCC" — C appears three times. Should render C slot content three times.
|
// "CCC" — C appears three times. Each occurrence should get its own block index.
|
||||||
layout := NewLayout("CCC").C(Raw("content"))
|
layout := NewLayout("CCC").C(Raw("content"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
count := countText(got, "content")
|
count := strings.Count(got, "content")
|
||||||
if count != 3 {
|
if count != 3 {
|
||||||
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
||||||
}
|
}
|
||||||
|
for _, want := range []string{`data-block="C-0"`, `data-block="C-1"`, `data-block="C-2"`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("CCC variant should contain %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_EmptySlots_Ugly(t *testing.T) {
|
func TestLayout_EmptySlots(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// Variant includes all slots but none are populated — should produce empty output.
|
// Variant includes all slots but none are populated — empty semantic containers
|
||||||
|
// should still render so the structure remains stable.
|
||||||
layout := NewLayout("HLCRF")
|
layout := NewLayout("HLCRF")
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
if got != "" {
|
for _, want := range []string{
|
||||||
t.Errorf("layout with no slot content should produce empty output, got %q", got)
|
`<header role="banner" data-block="H-0"></header>`,
|
||||||
}
|
`<aside role="complementary" data-block="L-0"></aside>`,
|
||||||
}
|
`<main role="main" data-block="C-0"></main>`,
|
||||||
|
`<aside role="complementary" data-block="R-0"></aside>`,
|
||||||
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
|
`<footer role="contentinfo" data-block="F-0"></footer>`,
|
||||||
ctx := NewContext()
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
inner := NewLayout("C").C(Raw("wrapped"))
|
t.Errorf("layout with empty slots missing %q in:\n%s", want, got)
|
||||||
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
|
}
|
||||||
|
|
||||||
got := outer.Render(ctx)
|
|
||||||
|
|
||||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
|
||||||
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
|
|
||||||
ctx := NewContext()
|
|
||||||
|
|
||||||
inner := NewLayout("C").C(Raw("wrapped"))
|
|
||||||
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
|
|
||||||
"match": inner,
|
|
||||||
"miss": Raw("ignored"),
|
|
||||||
}))
|
|
||||||
|
|
||||||
got := outer.Render(ctx)
|
|
||||||
|
|
||||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
|
||||||
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Render convenience function edge cases ---
|
// --- Render convenience function edge cases ---
|
||||||
|
|
||||||
func TestRender_NilContext_Ugly(t *testing.T) {
|
func TestRender_NilContext(t *testing.T) {
|
||||||
node := Raw("test")
|
node := Raw("test")
|
||||||
got := Render(node, nil)
|
got := Render(node, nil)
|
||||||
if got != "test" {
|
if got != "test" {
|
||||||
|
|
@ -515,7 +412,7 @@ func TestRender_NilContext_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_NilContext_Ugly(t *testing.T) {
|
func TestImprint_NilContext(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -527,7 +424,7 @@ func TestImprint_NilContext_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
func TestCompareVariants_NilContext(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -541,7 +438,7 @@ func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
func TestCompareVariants_SingleVariant(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -556,31 +453,31 @@ func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
||||||
|
|
||||||
// --- escapeHTML / escapeAttr edge cases ---
|
// --- escapeHTML / escapeAttr edge cases ---
|
||||||
|
|
||||||
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) {
|
func TestEscapeAttr_AllSpecialChars(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(El("div"), "data-val", `&<>"'`)
|
node := Attr(El("div"), "data-val", `&<>"'`)
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if containsText(got, `"&<>"'"`) {
|
if strings.Contains(got, `"&<>"'"`) {
|
||||||
t.Error("attribute value with special chars must be fully escaped")
|
t.Error("attribute value with special chars must be fully escaped")
|
||||||
}
|
}
|
||||||
if !containsText(got, "&<>"'") {
|
if !strings.Contains(got, "&<>"'") {
|
||||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_EmptyTag_Ugly(t *testing.T) {
|
func TestElNode_EmptyTag(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("", Raw("content"))
|
node := El("", Raw("content"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
// Empty tag is weird but should not panic
|
// Empty tag is weird but should not panic
|
||||||
if !containsText(got, "content") {
|
if !strings.Contains(got, "content") {
|
||||||
t.Errorf("El with empty tag should still render children, got %q", got)
|
t.Errorf("El with empty tag should still render children, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
func TestSwitchNode_NoMatch(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
cases := map[string]Node{
|
cases := map[string]Node{
|
||||||
"a": Raw("alpha"),
|
"a": Raw("alpha"),
|
||||||
|
|
@ -593,7 +490,7 @@ func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEntitled_NilContext_Ugly(t *testing.T) {
|
func TestEntitled_NilContext(t *testing.T) {
|
||||||
node := Entitled("premium", Raw("content"))
|
node := Entitled("premium", Raw("content"))
|
||||||
got := node.Render(nil)
|
got := node.Render(nil)
|
||||||
if got != "" {
|
if got != "" {
|
||||||
|
|
|
||||||
17
go.mod
17
go.mod
|
|
@ -3,19 +3,24 @@ module dappco.re/go/core/html
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.8.0-alpha.1
|
dappco.re/go/core/i18n v0.1.8
|
||||||
dappco.re/go/core/i18n v0.2.1
|
|
||||||
dappco.re/go/core/io v0.2.0
|
dappco.re/go/core/io v0.2.0
|
||||||
dappco.re/go/core/log v0.1.0
|
dappco.re/go/core/log v0.1.0
|
||||||
dappco.re/go/core/process v0.3.0
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core/inference v0.1.4 // indirect
|
|
||||||
dappco.re/go/core/log v0.0.4 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace (
|
||||||
|
dappco.re/go/core => ./deps/core
|
||||||
|
dappco.re/go/core/i18n => ./deps/go-i18n
|
||||||
|
dappco.re/go/core/io => ./deps/go-io
|
||||||
|
dappco.re/go/core/log => ./deps/go-log
|
||||||
|
)
|
||||||
|
|
|
||||||
22
go.sum
22
go.sum
|
|
@ -1,31 +1,21 @@
|
||||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
|
||||||
golang.org/x/text v0.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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
|
func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -26,7 +26,7 @@ func TestIntegration_RenderThenReverse_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
|
func TestIntegration_ResponsiveImprint(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
|
||||||
293
layout.go
293
layout.go
|
|
@ -1,14 +1,22 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// Compile-time interface check.
|
// Compile-time interface check.
|
||||||
var _ Node = (*Layout)(nil)
|
var _ Node = (*Layout)(nil)
|
||||||
|
|
||||||
// ErrInvalidLayoutVariant reports that a layout variant string contains at least
|
// ErrInvalidLayoutVariant reports that a layout variant string contains at
|
||||||
// one unrecognised slot character.
|
// least one unrecognised slot character.
|
||||||
|
// Example: errors.Is(ValidateLayoutVariant("HXC"), ErrInvalidLayoutVariant).
|
||||||
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
|
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
|
||||||
|
|
||||||
|
const validLayoutSlots = "H, L, C, R, F"
|
||||||
|
|
||||||
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
|
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
|
||||||
type slotMeta struct {
|
type slotMeta struct {
|
||||||
tag string
|
tag string
|
||||||
|
|
@ -24,171 +32,137 @@ var slotRegistry = map[byte]slotMeta{
|
||||||
'F': {tag: "footer", role: "contentinfo"},
|
'F': {tag: "footer", role: "contentinfo"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
// layout.go: Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||||
// with deterministic path-based IDs.
|
// with deterministic path-based IDs.
|
||||||
// Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
// Example: NewLayout("HCF").H(Raw("head")).C(Raw("body")).F(Raw("foot")).
|
||||||
type Layout struct {
|
type Layout struct {
|
||||||
variant string // "HLCRF", "HCF", "C", etc.
|
variant string // "HLCRF", "HCF", "C", etc.
|
||||||
path string // "" for root, "L-0-" for nested
|
path string // "" for root, "L-0-" for nested
|
||||||
slots map[byte][]Node // H, L, C, R, F → children
|
slots map[byte][]Node // H, L, C, R, F → children
|
||||||
|
attrs map[string]string
|
||||||
variantErr error
|
variantErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
|
// Clone returns a deep copy of the layout tree.
|
||||||
if node == nil {
|
// Example: next := layout.Clone().
|
||||||
return ""
|
func (l *Layout) Clone() *Layout {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if renderer, ok := node.(layoutPathRenderer); ok {
|
clone, ok := l.cloneNode().(*Layout)
|
||||||
return renderer.renderWithLayoutPath(ctx, path)
|
if !ok {
|
||||||
}
|
return nil
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLayout creates a new Layout with the given variant string.
|
// layout.go: NewLayout creates a new Layout with the given variant string.
|
||||||
// Usage example: page := NewLayout("HLCRF")
|
// Example: page := NewLayout("HCF").
|
||||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||||
func NewLayout(variant string) *Layout {
|
func NewLayout(variant string) *Layout {
|
||||||
l := &Layout{
|
l := &Layout{
|
||||||
variant: variant,
|
variant: variant,
|
||||||
slots: make(map[byte][]Node),
|
slots: make(map[byte][]Node),
|
||||||
|
attrs: make(map[string]string),
|
||||||
}
|
}
|
||||||
l.variantErr = ValidateLayoutVariant(variant)
|
l.variantErr = ValidateLayoutVariant(variant)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateLayoutVariant reports whether a layout variant string contains only
|
// layout.go: ValidateLayoutVariant reports whether a layout variant string contains only
|
||||||
// recognised slot characters.
|
// recognised slot characters.
|
||||||
//
|
// Example: ValidateLayoutVariant("HCF").
|
||||||
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
|
|
||||||
// layoutVariantError for invalid ones.
|
|
||||||
func ValidateLayoutVariant(variant string) error {
|
func ValidateLayoutVariant(variant string) error {
|
||||||
var invalid bool
|
var invalidSlots []byte
|
||||||
|
var invalidPositions []int
|
||||||
for i := range len(variant) {
|
for i := range len(variant) {
|
||||||
if _, ok := slotRegistry[variant[i]]; ok {
|
if _, ok := slotRegistry[variant[i]]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
invalid = true
|
invalidSlots = append(invalidSlots, variant[i])
|
||||||
break
|
invalidPositions = append(invalidPositions, i)
|
||||||
}
|
}
|
||||||
if !invalid {
|
if len(invalidSlots) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &layoutVariantError{variant: variant}
|
return &LayoutVariantError{
|
||||||
|
variant: variant,
|
||||||
|
invalidSlots: invalidSlots,
|
||||||
|
invalidPositions: invalidPositions,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Layout) slotsForSlot(slot byte) []Node {
|
// layout.go: H appends nodes to the Header slot.
|
||||||
if l == nil {
|
// Example: NewLayout("HCF").H(Raw("head")).
|
||||||
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 {
|
func (l *Layout) H(nodes ...Node) *Layout {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
|
l.slots['H'] = append(l.slots['H'], nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// L appends nodes to the Left aside slot.
|
// layout.go: L appends nodes to the Left aside slot.
|
||||||
// Usage example: NewLayout("LC").L(Text("nav"))
|
// Example: NewLayout("HLCRF").L(Raw("nav")).
|
||||||
func (l *Layout) L(nodes ...Node) *Layout {
|
func (l *Layout) L(nodes ...Node) *Layout {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
|
l.slots['L'] = append(l.slots['L'], nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// C appends nodes to the Content (main) slot.
|
// layout.go: C appends nodes to the Content (main) slot.
|
||||||
// Usage example: NewLayout("C").C(Text("body"))
|
// Example: NewLayout("C").C(Raw("body")).
|
||||||
func (l *Layout) C(nodes ...Node) *Layout {
|
func (l *Layout) C(nodes ...Node) *Layout {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
|
l.slots['C'] = append(l.slots['C'], nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// R appends nodes to the Right aside slot.
|
// layout.go: R appends nodes to the Right aside slot.
|
||||||
// Usage example: NewLayout("CR").R(Text("ads"))
|
// Example: NewLayout("HLCRF").R(Raw("aside")).
|
||||||
func (l *Layout) R(nodes ...Node) *Layout {
|
func (l *Layout) R(nodes ...Node) *Layout {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
|
l.slots['R'] = append(l.slots['R'], nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// F appends nodes to the Footer slot.
|
// layout.go: F appends nodes to the Footer slot.
|
||||||
// Usage example: NewLayout("CF").F(Text("footer"))
|
// Example: NewLayout("HCF").F(Raw("foot")).
|
||||||
func (l *Layout) F(nodes ...Node) *Layout {
|
func (l *Layout) F(nodes ...Node) *Layout {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
|
l.slots['F'] = append(l.slots['F'], nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// blockID returns the deterministic data-block attribute value for a slot.
|
func (l *Layout) setAttr(key, value string) {
|
||||||
func (l *Layout) blockID(slot byte) string {
|
if l == nil {
|
||||||
return l.path + string(slot) + "-0"
|
return
|
||||||
|
}
|
||||||
|
if l.attrs == nil {
|
||||||
|
l.attrs = make(map[string]string)
|
||||||
|
}
|
||||||
|
l.attrs[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantError reports whether the layout variant string contained any invalid
|
// BlockID returns the deterministic data-block attribute value for a slot
|
||||||
|
// occurrence within this layout variant.
|
||||||
|
// Example: NewLayout("C").BlockID('C', 0) returns "C-0".
|
||||||
|
func (l *Layout) BlockID(slot byte, index int) string {
|
||||||
|
return l.path + string(slot) + "-" + strconv.Itoa(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout.go: VariantError reports whether the layout variant string contained any invalid
|
||||||
// slot characters when the layout was constructed.
|
// slot characters when the layout was constructed.
|
||||||
|
// Example: NewLayout("HXC").VariantError().
|
||||||
func (l *Layout) VariantError() error {
|
func (l *Layout) VariantError() error {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -196,35 +170,65 @@ func (l *Layout) VariantError() error {
|
||||||
return l.variantErr
|
return l.variantErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render produces the semantic HTML for this layout.
|
// layout.go: VariantValid reports whether the layout variant string contains
|
||||||
// Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
// only recognised slot characters.
|
||||||
|
// Example: NewLayout("HCF").VariantValid().
|
||||||
|
func (l *Layout) VariantValid() bool {
|
||||||
|
return l == nil || l.variantErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Layout) cloneNode() Node {
|
||||||
|
if l == nil {
|
||||||
|
return (*Layout)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *l
|
||||||
|
if l.attrs != nil {
|
||||||
|
clone.attrs = maps.Clone(l.attrs)
|
||||||
|
}
|
||||||
|
if l.slots != nil {
|
||||||
|
clone.slots = make(map[byte][]Node, len(l.slots))
|
||||||
|
for slot, children := range l.slots {
|
||||||
|
clonedChildren := make([]Node, len(children))
|
||||||
|
for i := range children {
|
||||||
|
clonedChildren[i] = cloneNode(children[i])
|
||||||
|
}
|
||||||
|
clone.slots[slot] = clonedChildren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout.go: Render produces the semantic HTML for this layout.
|
||||||
|
// Example: NewLayout("C").C(Raw("body")).Render(NewContext()).
|
||||||
// Only slots present in the variant string are rendered.
|
// Only slots present in the variant string are rendered.
|
||||||
func (l *Layout) Render(ctx *Context) string {
|
func (l *Layout) Render(ctx *Context) string {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
ctx = normaliseContext(ctx)
|
||||||
ctx = NewContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
b := newTextBuilder()
|
var b strings.Builder
|
||||||
|
|
||||||
|
slotCounts := make(map[byte]int)
|
||||||
for i := range len(l.variant) {
|
for i := range len(l.variant) {
|
||||||
slot := l.variant[i]
|
slot := l.variant[i]
|
||||||
children := l.slots[slot]
|
children := l.slots[slot]
|
||||||
if len(children) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, ok := slotRegistry[slot]
|
meta, ok := slotRegistry[slot]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
bid := l.blockID(slot)
|
index := slotCounts[slot]
|
||||||
|
slotCounts[slot] = index + 1
|
||||||
|
bid := l.BlockID(slot, index)
|
||||||
|
|
||||||
b.WriteByte('<')
|
b.WriteByte('<')
|
||||||
b.WriteString(escapeHTML(meta.tag))
|
b.WriteString(escapeHTML(meta.tag))
|
||||||
|
writeSortedAttrs(&b, l.attrs, func(key string) bool {
|
||||||
|
return key == "role" || key == "data-block"
|
||||||
|
})
|
||||||
b.WriteString(` role="`)
|
b.WriteString(` role="`)
|
||||||
b.WriteString(escapeAttr(meta.role))
|
b.WriteString(escapeAttr(meta.role))
|
||||||
b.WriteString(`" data-block="`)
|
b.WriteString(`" data-block="`)
|
||||||
|
|
@ -232,10 +236,7 @@ func (l *Layout) Render(ctx *Context) string {
|
||||||
b.WriteString(`">`)
|
b.WriteString(`">`)
|
||||||
|
|
||||||
for _, child := range children {
|
for _, child := range children {
|
||||||
if child == nil {
|
b.WriteString(renderNodeWithPath(child, ctx, bid+"-"))
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("</")
|
b.WriteString("</")
|
||||||
|
|
@ -246,14 +247,76 @@ func (l *Layout) Render(ctx *Context) string {
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type layoutVariantError struct {
|
// LayoutVariantError describes the invalid characters found in a layout
|
||||||
variant string
|
// variant string.
|
||||||
|
// Example: var variantErr *LayoutVariantError
|
||||||
|
//
|
||||||
|
// if errors.As(err, &variantErr) {
|
||||||
|
// _ = variantErr.InvalidSlots()
|
||||||
|
// }
|
||||||
|
type LayoutVariantError struct {
|
||||||
|
variant string
|
||||||
|
invalidSlots []byte
|
||||||
|
invalidPositions []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *layoutVariantError) Error() string {
|
func (e *LayoutVariantError) Error() string {
|
||||||
return "html: invalid layout variant " + e.variant
|
if e == nil {
|
||||||
|
return ErrInvalidLayoutVariant.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("html: invalid layout variant ")
|
||||||
|
b.WriteString(e.variant)
|
||||||
|
if len(e.invalidSlots) == 0 {
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(" (invalid slot")
|
||||||
|
if len(e.invalidSlots) > 1 {
|
||||||
|
b.WriteString("s")
|
||||||
|
}
|
||||||
|
b.WriteString(": ")
|
||||||
|
for i, slot := range e.invalidSlots {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(", ")
|
||||||
|
}
|
||||||
|
b.WriteString(strconv.QuoteRuneToASCII(rune(slot)))
|
||||||
|
if i < len(e.invalidPositions) {
|
||||||
|
b.WriteString(" at position ")
|
||||||
|
b.WriteString(strconv.Itoa(e.invalidPositions[i] + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("; valid slots: ")
|
||||||
|
b.WriteString(validLayoutSlots)
|
||||||
|
b.WriteByte(')')
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *layoutVariantError) Unwrap() error {
|
func (e *LayoutVariantError) Unwrap() error {
|
||||||
return ErrInvalidLayoutVariant
|
return ErrInvalidLayoutVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidSlots returns a copy of the invalid slot characters that were present
|
||||||
|
// in the original variant string.
|
||||||
|
// Example: string(variantErr.InvalidSlots()) // "1X?"
|
||||||
|
func (e *LayoutVariantError) InvalidSlots() []byte {
|
||||||
|
if e == nil || len(e.invalidSlots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), e.invalidSlots...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidPositions returns a copy of the 1-based positions of the invalid slot
|
||||||
|
// characters in the original variant string.
|
||||||
|
// Example: variantErr.InvalidPositions() // []int{2, 3, 4}
|
||||||
|
func (e *LayoutVariantError) InvalidPositions() []int {
|
||||||
|
if e == nil || len(e.invalidPositions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
positions := make([]int, len(e.invalidPositions))
|
||||||
|
for i, pos := range e.invalidPositions {
|
||||||
|
positions[i] = pos + 1
|
||||||
|
}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
|
||||||
28
layout_external_test.go
Normal file
28
layout_external_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package html_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
html "dappco.re/go/core/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateLayoutVariant_ExportsPositions(t *testing.T) {
|
||||||
|
err := html.ValidateLayoutVariant("H1X?")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateLayoutVariant returned nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var variantErr *html.LayoutVariantError
|
||||||
|
if !errors.As(err, &variantErr) {
|
||||||
|
t.Fatalf("errors.As(%T) failed, want *html.LayoutVariantError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := string(variantErr.InvalidSlots()); got != "1X?" {
|
||||||
|
t.Fatalf("InvalidSlots() = %q, want %q", got, "1X?")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := variantErr.InvalidPositions(); len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
|
||||||
|
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2, 3, 4})
|
||||||
|
}
|
||||||
|
}
|
||||||
236
layout_test.go
236
layout_test.go
|
|
@ -1,10 +1,12 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLayout_HLCRF_Good(t *testing.T) {
|
func TestLayout_HLCRF(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
layout := NewLayout("HLCRF").
|
layout := NewLayout("HLCRF").
|
||||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||||
|
|
@ -12,34 +14,34 @@ func TestLayout_HLCRF_Good(t *testing.T) {
|
||||||
|
|
||||||
// Must contain semantic elements
|
// Must contain semantic elements
|
||||||
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
|
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must contain ARIA roles
|
// Must contain ARIA roles
|
||||||
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
|
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must contain data-block IDs
|
// Must contain data-block IDs
|
||||||
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must contain content
|
// Must contain content
|
||||||
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_HCF_Good(t *testing.T) {
|
func TestLayout_HCF(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
layout := NewLayout("HCF").
|
layout := NewLayout("HCF").
|
||||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||||
|
|
@ -47,42 +49,42 @@ func TestLayout_HCF_Good(t *testing.T) {
|
||||||
|
|
||||||
// HCF should have header, main, footer
|
// HCF should have header, main, footer
|
||||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCF must NOT have L or R slots
|
// HCF must NOT have L or R slots
|
||||||
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
|
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
|
||||||
if containsText(got, unwanted) {
|
if strings.Contains(got, unwanted) {
|
||||||
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_ContentOnly_Good(t *testing.T) {
|
func TestLayout_ContentOnly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
layout := NewLayout("C").
|
layout := NewLayout("C").
|
||||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
// Only C slot should render
|
// Only C slot should render
|
||||||
if !containsText(got, `data-block="C-0"`) {
|
if !strings.Contains(got, `data-block="C-0"`) {
|
||||||
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, "<main") {
|
if !strings.Contains(got, "<main") {
|
||||||
t.Errorf("C layout missing <main in:\n%s", got)
|
t.Errorf("C layout missing <main in:\n%s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No other slots
|
// No other slots
|
||||||
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||||
if containsText(got, unwanted) {
|
if strings.Contains(got, unwanted) {
|
||||||
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_FluentAPI_Good(t *testing.T) {
|
func TestLayout_FluentAPI(t *testing.T) {
|
||||||
layout := NewLayout("HLCRF")
|
layout := NewLayout("HLCRF")
|
||||||
|
|
||||||
// Fluent methods should return the same layout for chaining
|
// Fluent methods should return the same layout for chaining
|
||||||
|
|
@ -97,53 +99,213 @@ func TestLayout_FluentAPI_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
|
func TestLayout_CloneReturnsIndependentCopy(t *testing.T) {
|
||||||
|
original := NewLayout("HLCRF").
|
||||||
|
H(Raw("header")).
|
||||||
|
C(Raw("main")).
|
||||||
|
F(Raw("footer"))
|
||||||
|
|
||||||
|
clone := original.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
t.Fatal("Clone should return a layout")
|
||||||
|
}
|
||||||
|
if clone == original {
|
||||||
|
t.Fatal("Clone should return a distinct layout instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.H(Raw("cloned-header"))
|
||||||
|
clone.C(Raw("cloned-main"))
|
||||||
|
|
||||||
|
originalGot := original.Render(NewContext())
|
||||||
|
cloneGot := clone.Render(NewContext())
|
||||||
|
|
||||||
|
if strings.Contains(originalGot, "cloned-header") || strings.Contains(originalGot, "cloned-main") {
|
||||||
|
t.Fatalf("Clone should not mutate original layout, got:\n%s", originalGot)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cloneGot, "cloned-header") || !strings.Contains(cloneGot, "cloned-main") {
|
||||||
|
t.Fatalf("Clone should preserve mutations on the copy, got:\n%s", cloneGot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
// "C" variant: populating L and R should have no effect
|
// "C" variant: populating L and R should have no effect
|
||||||
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, "main") {
|
if !strings.Contains(got, "main") {
|
||||||
t.Errorf("C variant should render main content, got:\n%s", got)
|
t.Errorf("C variant should render main content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if containsText(got, "left") {
|
if strings.Contains(got, "left") {
|
||||||
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if containsText(got, "right") {
|
if strings.Contains(got, "right") {
|
||||||
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_Methods_NilLayout_Ugly(t *testing.T) {
|
func TestLayout_RendersEmptySlots(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
layout := NewLayout("HCF")
|
||||||
|
|
||||||
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
|
for _, want := range []string{`<header role="banner" data-block="H-0"></header>`, `<main role="main" data-block="C-0"></main>`, `<footer role="contentinfo" data-block="F-0"></footer>`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("empty slot should still render %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLayoutVariant(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_VariantError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variant string
|
||||||
|
wantErr bool
|
||||||
|
wantErrString string
|
||||||
|
wantRender string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid variant",
|
||||||
|
variant: "HCF",
|
||||||
|
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",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrString: "html: invalid layout variant HXC (invalid slot: 'X' at position 2; valid slots: H, L, C, R, F)",
|
||||||
|
wantRender: `<header role="banner" data-block="H-0">header</header>` +
|
||||||
|
`<main role="main" data-block="C-0">main</main>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple invalid slots",
|
||||||
|
variant: "H1X?",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrString: "html: invalid layout variant H1X? (invalid slots: '1' at position 2, 'X' at position 3, '?' at position 4; valid slots: H, L, C, R, F)",
|
||||||
|
wantRender: `<header role="banner" data-block="H-0">header</header>`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
layout := NewLayout(tt.variant)
|
||||||
|
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err, ok := layout.VariantError().(*LayoutVariantError); ok {
|
||||||
|
switch tt.variant {
|
||||||
|
case "HXC":
|
||||||
|
if got := string(err.InvalidSlots()); got != "X" {
|
||||||
|
t.Fatalf("InvalidSlots() = %q, want %q", got, "X")
|
||||||
|
}
|
||||||
|
if got := err.InvalidPositions(); len(got) != 1 || got[0] != 2 {
|
||||||
|
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2})
|
||||||
|
}
|
||||||
|
case "H1X?":
|
||||||
|
if got := string(err.InvalidSlots()); got != "1X?" {
|
||||||
|
t.Fatalf("InvalidSlots() = %q, want %q", got, "1X?")
|
||||||
|
}
|
||||||
|
if got := err.InvalidPositions(); len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
|
||||||
|
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2, 3, 4})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatalf("VariantError() has unexpected concrete type %T", layout.VariantError())
|
||||||
|
}
|
||||||
|
} else if layout.VariantError() != nil {
|
||||||
|
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
||||||
|
}
|
||||||
|
if got := layout.VariantValid(); got != !tt.wantErr {
|
||||||
|
t.Fatalf("VariantValid() = %v, want %v", got, !tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := layout.Render(NewContext())
|
||||||
|
if got != tt.wantRender {
|
||||||
|
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_RenderNilReceiver(t *testing.T) {
|
||||||
|
var layout *Layout
|
||||||
|
got := layout.Render(NewContext())
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("nil Layout should render empty string, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_BuilderNilReceiver(t *testing.T) {
|
||||||
var layout *Layout
|
var layout *Layout
|
||||||
|
|
||||||
if layout.H(Raw("h")) != nil {
|
if got := layout.H(Raw("header")); got != nil {
|
||||||
t.Fatal("expected nil layout from H on nil receiver")
|
t.Fatalf("nil Layout.H() should return nil, got %v", got)
|
||||||
}
|
}
|
||||||
if layout.L(Raw("l")) != nil {
|
if got := layout.L(Raw("left")); got != nil {
|
||||||
t.Fatal("expected nil layout from L on nil receiver")
|
t.Fatalf("nil Layout.L() should return nil, got %v", got)
|
||||||
}
|
}
|
||||||
if layout.C(Raw("c")) != nil {
|
if got := layout.C(Raw("main")); got != nil {
|
||||||
t.Fatal("expected nil layout from C on nil receiver")
|
t.Fatalf("nil Layout.C() should return nil, got %v", got)
|
||||||
}
|
}
|
||||||
if layout.R(Raw("r")) != nil {
|
if got := layout.R(Raw("right")); got != nil {
|
||||||
t.Fatal("expected nil layout from R on nil receiver")
|
t.Fatalf("nil Layout.R() should return nil, got %v", got)
|
||||||
}
|
}
|
||||||
if layout.F(Raw("f")) != nil {
|
if got := layout.F(Raw("footer")); got != nil {
|
||||||
t.Fatal("expected nil layout from F on nil receiver")
|
t.Fatalf("nil Layout.F() should return nil, got %v", got)
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestLayout_RenderNilContext(t *testing.T) {
|
||||||
layout := NewLayout("C").C(Raw("content"))
|
layout := NewLayout("C").C(Raw("content"))
|
||||||
|
|
||||||
got := layout.Render(nil)
|
got := layout.Render(nil)
|
||||||
want := `<main role="main" data-block="C-0">content</main>`
|
|
||||||
if got != want {
|
if !strings.Contains(got, `data-block="C-0"`) {
|
||||||
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
|
t.Fatalf("Layout.Render(nil) should still render the block ID, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "content") {
|
||||||
|
t.Fatalf("Layout.Render(nil) should still render content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
800
node.go
800
node.go
|
|
@ -6,14 +6,19 @@ import (
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node is anything renderable.
|
// node.go: Node is anything renderable.
|
||||||
// Usage example: var n Node = El("div", Text("welcome"))
|
// Example: El("p", Text("page.body")) returns a Node that can be passed to Render().
|
||||||
type Node interface {
|
type Node interface {
|
||||||
Render(ctx *Context) string
|
Render(ctx *Context) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cloneableNode interface {
|
||||||
|
cloneNode() Node
|
||||||
|
}
|
||||||
|
|
||||||
// Compile-time interface checks.
|
// Compile-time interface checks.
|
||||||
var (
|
var (
|
||||||
_ Node = (*rawNode)(nil)
|
_ Node = (*rawNode)(nil)
|
||||||
|
|
@ -26,8 +31,68 @@ var (
|
||||||
_ Node = (*eachNode[any])(nil)
|
_ Node = (*eachNode[any])(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
type layoutPathRenderer interface {
|
// renderNode renders a node while treating nil values as empty output.
|
||||||
renderWithLayoutPath(ctx *Context, path string) string
|
func renderNode(n Node, ctx *Context) string {
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return n.Render(normaliseContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNodeWithPath renders a node while preserving layout path prefixes for
|
||||||
|
// nested layouts that may be wrapped in conditional or switch nodes.
|
||||||
|
func renderNodeWithPath(n Node, ctx *Context, path string) string {
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
|
switch t := n.(type) {
|
||||||
|
case *Layout:
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
clone := *t
|
||||||
|
clone.path = path
|
||||||
|
return clone.Render(ctx)
|
||||||
|
case interface{ renderWithPath(*Context, string) string }:
|
||||||
|
return t.renderWithPath(ctx, path)
|
||||||
|
case *ifNode:
|
||||||
|
if t == nil || t.cond == nil || t.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if t.cond(ctx) {
|
||||||
|
return renderNodeWithPath(t.node, ctx, path)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case *unlessNode:
|
||||||
|
if t == nil || t.cond == nil || t.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !t.cond(ctx) {
|
||||||
|
return renderNodeWithPath(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 renderNodeWithPath(t.node, ctx, path)
|
||||||
|
case *switchNode:
|
||||||
|
if t == nil || t.selector == nil || t.cases == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := t.selector(ctx)
|
||||||
|
if node, ok := t.cases[key]; ok {
|
||||||
|
return renderNodeWithPath(node, ctx, path)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return n.Render(ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// voidElements is the set of HTML elements that must not have a closing tag.
|
// voidElements is the set of HTML elements that must not have a closing tag.
|
||||||
|
|
@ -52,14 +117,36 @@ func escapeAttr(s string) string {
|
||||||
return html.EscapeString(s)
|
return html.EscapeString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeSortedAttrs renders a deterministic attribute list to the builder.
|
||||||
|
// An optional skip callback can omit reserved keys while preserving ordering
|
||||||
|
// for the remaining attributes.
|
||||||
|
func writeSortedAttrs(b *strings.Builder, attrs map[string]string, skip func(string) bool) {
|
||||||
|
if len(attrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := slices.Collect(maps.Keys(attrs))
|
||||||
|
slices.Sort(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if skip != nil && skip(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(escapeHTML(key))
|
||||||
|
b.WriteString(`="`)
|
||||||
|
b.WriteString(escapeAttr(attrs[key]))
|
||||||
|
b.WriteByte('"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- rawNode ---
|
// --- rawNode ---
|
||||||
|
|
||||||
type rawNode struct {
|
type rawNode struct {
|
||||||
content string
|
content string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
// node.go: Raw creates a node that renders without escaping.
|
||||||
// Usage example: Raw("<strong>trusted</strong>")
|
// Example: Raw("<strong>trusted</strong>") preserves the HTML verbatim.
|
||||||
func Raw(content string) Node {
|
func Raw(content string) Node {
|
||||||
return &rawNode{content: content}
|
return &rawNode{content: content}
|
||||||
}
|
}
|
||||||
|
|
@ -79,8 +166,8 @@ type elNode struct {
|
||||||
attrs map[string]string
|
attrs map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// El creates an HTML element node with children.
|
// node.go: El creates an HTML element node with children.
|
||||||
// Usage example: El("section", Text("welcome"))
|
// Example: El("nav", Text("nav.label")) renders a semantic element with nested nodes.
|
||||||
func El(tag string, children ...Node) Node {
|
func El(tag string, children ...Node) Node {
|
||||||
return &elNode{
|
return &elNode{
|
||||||
tag: tag,
|
tag: tag,
|
||||||
|
|
@ -89,61 +176,507 @@ func El(tag string, children ...Node) Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attr sets an attribute on an El node. Returns the node for chaining.
|
// node.go: Attr sets an attribute on an El node and returns the same node for chaining.
|
||||||
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
// Example: Attr(El("img"), "alt", "Logo") adds an escaped alt attribute.
|
||||||
// It recursively traverses through wrappers like If, Unless, Entitled, and Each.
|
//
|
||||||
|
// It recursively traverses wrappers like If, Unless, Entitled, Switch,
|
||||||
|
// and Each/EachSeq so the attribute lands on the rendered element.
|
||||||
func Attr(n Node, key, value string) Node {
|
func Attr(n Node, key, value string) Node {
|
||||||
if n == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
switch t := n.(type) {
|
switch t := n.(type) {
|
||||||
case *elNode:
|
case *elNode:
|
||||||
t.attrs[key] = value
|
t.attrs[key] = value
|
||||||
case *ifNode:
|
case *ifNode:
|
||||||
Attr(t.node, key, value)
|
t.node = Attr(cloneNode(t.node), key, value)
|
||||||
case *unlessNode:
|
case *unlessNode:
|
||||||
Attr(t.node, key, value)
|
t.node = Attr(cloneNode(t.node), key, value)
|
||||||
case *entitledNode:
|
case *entitledNode:
|
||||||
Attr(t.node, key, value)
|
t.node = Attr(cloneNode(t.node), key, value)
|
||||||
case *switchNode:
|
case *switchNode:
|
||||||
for _, child := range t.cases {
|
cloned := make(map[string]Node, len(t.cases))
|
||||||
Attr(child, key, value)
|
for caseKey, caseNode := range t.cases {
|
||||||
|
cloned[caseKey] = Attr(cloneNode(caseNode), key, value)
|
||||||
}
|
}
|
||||||
case attrApplier:
|
t.cases = cloned
|
||||||
t.applyAttr(key, value)
|
case interface{ setAttr(string, string) }:
|
||||||
|
t.setAttr(key, value)
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// AriaLabel sets an aria-label attribute on an element node.
|
func cloneNode(n Node) Node {
|
||||||
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cloner, ok := n.(cloneableNode); ok {
|
||||||
|
return cloner.cloneNode()
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaLabel sets the aria-label attribute on an element node.
|
||||||
|
// Example: AriaLabel(El("button"), "Open menu").
|
||||||
func AriaLabel(n Node, label string) Node {
|
func AriaLabel(n Node, label string) Node {
|
||||||
return Attr(n, "aria-label", label)
|
if value := trimmedNonEmpty(label); value != "" {
|
||||||
|
return Attr(n, "aria-label", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// AltText sets an alt attribute on an element node.
|
// node.go: AriaDescribedBy sets the aria-describedby attribute on an element node.
|
||||||
// Usage example: AltText(El("img"), "Profile photo")
|
// Example: AriaDescribedBy(El("input"), "help-text", "error-text").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaDescribedBy(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-describedby", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaLabelledBy sets the aria-labelledby attribute on an element node.
|
||||||
|
// Example: AriaLabelledBy(El("section"), "section-title").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaLabelledBy(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-labelledby", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaControls sets the aria-controls attribute on an element node.
|
||||||
|
// Example: AriaControls(El("button"), "menu-panel").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaControls(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-controls", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaHasPopup sets the aria-haspopup attribute on an element node.
|
||||||
|
// Example: AriaHasPopup(El("button"), "menu").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func AriaHasPopup(n Node, popup string) Node {
|
||||||
|
if value := trimmedNonEmpty(popup); value != "" {
|
||||||
|
return Attr(n, "aria-haspopup", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaOwns sets the aria-owns attribute on an element node.
|
||||||
|
// Example: AriaOwns(El("div"), "owned-panel").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaOwns(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-owns", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaKeyShortcuts sets the aria-keyshortcuts attribute on an element node.
|
||||||
|
// Example: AriaKeyShortcuts(El("button"), "Ctrl+S", "Meta+S").
|
||||||
|
// Multiple shortcuts are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaKeyShortcuts(n Node, shortcuts ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(shortcuts...); value != "" {
|
||||||
|
return Attr(n, "aria-keyshortcuts", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaCurrent sets the aria-current attribute on an element node.
|
||||||
|
// Example: AriaCurrent(El("a"), "page").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func AriaCurrent(n Node, current string) Node {
|
||||||
|
if value := trimmedNonEmpty(current); value != "" {
|
||||||
|
return Attr(n, "aria-current", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaBusy sets the aria-busy attribute on an element node.
|
||||||
|
// Example: AriaBusy(El("section"), true).
|
||||||
|
func AriaBusy(n Node, busy bool) Node {
|
||||||
|
if busy {
|
||||||
|
return Attr(n, "aria-busy", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-busy", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaLive sets the aria-live attribute on an element node.
|
||||||
|
// Example: AriaLive(El("div"), "polite").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func AriaLive(n Node, live string) Node {
|
||||||
|
if value := trimmedNonEmpty(live); value != "" {
|
||||||
|
return Attr(n, "aria-live", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaAtomic sets the aria-atomic attribute on a live region node.
|
||||||
|
// Example: AriaAtomic(El("div"), true).
|
||||||
|
func AriaAtomic(n Node, atomic bool) Node {
|
||||||
|
if atomic {
|
||||||
|
return Attr(n, "aria-atomic", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-atomic", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaDescription sets the aria-description attribute on an element node.
|
||||||
|
// Example: AriaDescription(El("button"), "Opens the navigation menu").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func AriaDescription(n Node, description string) Node {
|
||||||
|
if value := trimmedNonEmpty(description); value != "" {
|
||||||
|
return Attr(n, "aria-description", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaDetails sets the aria-details attribute on an element node.
|
||||||
|
// Example: AriaDetails(El("input"), "field-help").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaDetails(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-details", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaErrorMessage sets the aria-errormessage attribute on an element node.
|
||||||
|
// Example: AriaErrorMessage(El("input"), "field-error").
|
||||||
|
// Multiple IDs are joined with spaces, matching the HTML attribute format.
|
||||||
|
func AriaErrorMessage(n Node, ids ...string) Node {
|
||||||
|
if value := joinUniqueNonEmpty(ids...); value != "" {
|
||||||
|
return Attr(n, "aria-errormessage", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaRoleDescription sets the aria-roledescription attribute on an
|
||||||
|
// element node.
|
||||||
|
// Example: AriaRoleDescription(El("section"), "carousel").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func AriaRoleDescription(n Node, description string) Node {
|
||||||
|
if value := trimmedNonEmpty(description); value != "" {
|
||||||
|
return Attr(n, "aria-roledescription", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Role sets the role attribute on an element node.
|
||||||
|
// Example: Role(El("aside"), "complementary").
|
||||||
|
func Role(n Node, role string) Node {
|
||||||
|
if value := trimmedNonEmpty(role); value != "" {
|
||||||
|
return Attr(n, "role", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Lang sets the lang attribute on an element node.
|
||||||
|
// Example: Lang(El("html"), "en-GB").
|
||||||
|
func Lang(n Node, locale string) Node {
|
||||||
|
if value := trimmedNonEmpty(locale); value != "" {
|
||||||
|
return Attr(n, "lang", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Dir sets the dir attribute on an element node.
|
||||||
|
// Example: Dir(El("p"), "rtl").
|
||||||
|
func Dir(n Node, direction string) Node {
|
||||||
|
if value := trimmedNonEmpty(direction); value != "" {
|
||||||
|
return Attr(n, "dir", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Alt sets the alt attribute on an element node.
|
||||||
|
// Example: Alt(El("img"), "Product screenshot").
|
||||||
|
func Alt(n Node, text string) Node {
|
||||||
|
if value := trimmedNonEmpty(text); value != "" {
|
||||||
|
return Attr(n, "alt", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Title sets the title attribute on an element node.
|
||||||
|
// Example: Title(El("abbr"), "World Wide Web").
|
||||||
|
func Title(n Node, text string) Node {
|
||||||
|
if value := trimmedNonEmpty(text); value != "" {
|
||||||
|
return Attr(n, "title", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Placeholder sets the placeholder attribute on an element node.
|
||||||
|
// Example: Placeholder(El("input"), "Search by keyword").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func Placeholder(n Node, text string) Node {
|
||||||
|
if value := trimmedNonEmpty(text); value != "" {
|
||||||
|
return Attr(n, "placeholder", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Autocomplete sets the autocomplete attribute on an element node.
|
||||||
|
// Example: Autocomplete(El("input"), "email").
|
||||||
|
// An empty value leaves the node unchanged so callers can opt out cleanly.
|
||||||
|
func Autocomplete(n Node, value string) Node {
|
||||||
|
if value = trimmedNonEmpty(value); value != "" {
|
||||||
|
return Attr(n, "autocomplete", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AltText is a compatibility alias for Alt.
|
||||||
|
// Example: AltText(El("img"), "Product screenshot").
|
||||||
|
// Prefer Alt for new call sites so the canonical image helper stays predictable.
|
||||||
func AltText(n Node, text string) Node {
|
func AltText(n Node, text string) Node {
|
||||||
return Attr(n, "alt", text)
|
return Alt(n, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TabIndex sets a tabindex attribute on an element node.
|
// node.go: Class sets the class attribute on an element node.
|
||||||
// Usage example: TabIndex(El("button", Text("save")), 0)
|
// Example: Class(El("div"), "card", "card--primary").
|
||||||
|
// Multiple class tokens are joined with spaces.
|
||||||
|
func Class(n Node, classes ...string) Node {
|
||||||
|
if value := joinNonEmpty(classes...); value != "" {
|
||||||
|
return Attr(n, "class", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaHidden sets the aria-hidden attribute on an element node.
|
||||||
|
// Example: AriaHidden(El("svg"), true).
|
||||||
|
func AriaHidden(n Node, hidden bool) Node {
|
||||||
|
if hidden {
|
||||||
|
return Attr(n, "aria-hidden", "true")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Hidden sets the HTML hidden attribute on an element node.
|
||||||
|
// Example: Hidden(El("section"), true).
|
||||||
|
// Hidden is a standard HTML visibility flag and omits the attribute when false.
|
||||||
|
func Hidden(n Node, hidden bool) Node {
|
||||||
|
if hidden {
|
||||||
|
return Attr(n, "hidden", "hidden")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Disabled sets the HTML disabled attribute on an element node.
|
||||||
|
// Example: Disabled(El("button"), true).
|
||||||
|
// Disabled follows standard HTML boolean attribute semantics and omits the
|
||||||
|
// attribute when false.
|
||||||
|
func Disabled(n Node, disabled bool) Node {
|
||||||
|
if disabled {
|
||||||
|
return Attr(n, "disabled", "disabled")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Checked sets the HTML checked attribute on an element node.
|
||||||
|
// Example: Checked(El("input"), true).
|
||||||
|
// Checked follows standard HTML boolean attribute semantics and omits the
|
||||||
|
// attribute when false.
|
||||||
|
func Checked(n Node, checked bool) Node {
|
||||||
|
if checked {
|
||||||
|
return Attr(n, "checked", "checked")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Required sets the HTML required attribute on an element node.
|
||||||
|
// Example: Required(El("input"), true).
|
||||||
|
// Required follows standard HTML boolean attribute semantics and omits the
|
||||||
|
// attribute when false.
|
||||||
|
func Required(n Node, required bool) Node {
|
||||||
|
if required {
|
||||||
|
return Attr(n, "required", "required")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: ReadOnly sets the HTML readonly attribute on an element node.
|
||||||
|
// Example: ReadOnly(El("input"), true).
|
||||||
|
// ReadOnly follows standard HTML boolean attribute semantics and omits the
|
||||||
|
// attribute when false.
|
||||||
|
func ReadOnly(n Node, readonly bool) Node {
|
||||||
|
if readonly {
|
||||||
|
return Attr(n, "readonly", "readonly")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: Selected sets the HTML selected attribute on an element node.
|
||||||
|
// Example: Selected(El("option"), true).
|
||||||
|
// Selected follows standard HTML boolean attribute semantics and omits the
|
||||||
|
// attribute when false.
|
||||||
|
func Selected(n Node, selected bool) Node {
|
||||||
|
if selected {
|
||||||
|
return Attr(n, "selected", "selected")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaExpanded sets the aria-expanded attribute on an element node.
|
||||||
|
// Example: AriaExpanded(El("button"), true).
|
||||||
|
func AriaExpanded(n Node, expanded bool) Node {
|
||||||
|
if expanded {
|
||||||
|
return Attr(n, "aria-expanded", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-expanded", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaDisabled sets the aria-disabled attribute on an element node.
|
||||||
|
// Example: AriaDisabled(El("button"), true).
|
||||||
|
func AriaDisabled(n Node, disabled bool) Node {
|
||||||
|
if disabled {
|
||||||
|
return Attr(n, "aria-disabled", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-disabled", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaModal sets the aria-modal attribute on an element node.
|
||||||
|
// Example: AriaModal(El("dialog"), true).
|
||||||
|
func AriaModal(n Node, modal bool) Node {
|
||||||
|
if modal {
|
||||||
|
return Attr(n, "aria-modal", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-modal", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaChecked sets the aria-checked attribute on an element node.
|
||||||
|
// Example: AriaChecked(El("input"), true).
|
||||||
|
func AriaChecked(n Node, checked bool) Node {
|
||||||
|
if checked {
|
||||||
|
return Attr(n, "aria-checked", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-checked", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaInvalid sets the aria-invalid attribute on an element node.
|
||||||
|
// Example: AriaInvalid(El("input"), true).
|
||||||
|
func AriaInvalid(n Node, invalid bool) Node {
|
||||||
|
if invalid {
|
||||||
|
return Attr(n, "aria-invalid", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-invalid", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaRequired sets the aria-required attribute on an element node.
|
||||||
|
// Example: AriaRequired(El("input"), true).
|
||||||
|
func AriaRequired(n Node, required bool) Node {
|
||||||
|
if required {
|
||||||
|
return Attr(n, "aria-required", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-required", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaReadOnly sets the aria-readonly attribute on an element node.
|
||||||
|
// Example: AriaReadOnly(El("input"), true).
|
||||||
|
func AriaReadOnly(n Node, readonly bool) Node {
|
||||||
|
if readonly {
|
||||||
|
return Attr(n, "aria-readonly", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-readonly", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaPressed sets the aria-pressed attribute on an element node.
|
||||||
|
// Example: AriaPressed(El("button"), true).
|
||||||
|
func AriaPressed(n Node, pressed bool) Node {
|
||||||
|
if pressed {
|
||||||
|
return Attr(n, "aria-pressed", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-pressed", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: AriaSelected sets the aria-selected attribute on an element node.
|
||||||
|
// Example: AriaSelected(El("option"), true).
|
||||||
|
func AriaSelected(n Node, selected bool) Node {
|
||||||
|
if selected {
|
||||||
|
return Attr(n, "aria-selected", "true")
|
||||||
|
}
|
||||||
|
return Attr(n, "aria-selected", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: TabIndex sets the tabindex attribute on an element node.
|
||||||
|
// Example: TabIndex(El("button"), 0).
|
||||||
func TabIndex(n Node, index int) Node {
|
func TabIndex(n Node, index int) Node {
|
||||||
return Attr(n, "tabindex", strconv.Itoa(index))
|
return Attr(n, "tabindex", strconv.Itoa(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoFocus sets an autofocus attribute on an element node.
|
// node.go: AutoFocus sets the autofocus attribute on an element node.
|
||||||
// Usage example: AutoFocus(El("input"))
|
// Example: AutoFocus(El("input")).
|
||||||
func AutoFocus(n Node) Node {
|
func AutoFocus(n Node) Node {
|
||||||
return Attr(n, "autofocus", "autofocus")
|
return Attr(n, "autofocus", "autofocus")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role sets a role attribute on an element node.
|
// node.go: ID sets the id attribute on an element node.
|
||||||
// Usage example: Role(El("nav", Text("links")), "navigation")
|
// Example: ID(El("section"), "main-content").
|
||||||
func Role(n Node, role string) Node {
|
func ID(n Node, id string) Node {
|
||||||
return Attr(n, "role", role)
|
if value := trimmedNonEmpty(id); value != "" {
|
||||||
|
return Attr(n, "id", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// node.go: For sets the for attribute on an element node.
|
||||||
|
// Example: For(El("label"), "email-input").
|
||||||
|
func For(n Node, target string) Node {
|
||||||
|
if value := trimmedNonEmpty(target); value != "" {
|
||||||
|
return Attr(n, "for", value)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNonEmpty(parts ...string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
for i := range parts {
|
||||||
|
part := strings.TrimSpace(parts[i])
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, part)
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(filtered, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinUniqueNonEmpty(parts ...string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
filtered := make([]string, 0, len(parts))
|
||||||
|
for i := range parts {
|
||||||
|
part := strings.TrimSpace(parts[i])
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[part]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[part] = struct{}{}
|
||||||
|
filtered = append(filtered, part)
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(filtered, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimmedNonEmpty(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *elNode) Render(ctx *Context) string {
|
func (n *elNode) Render(ctx *Context) string {
|
||||||
|
|
@ -151,21 +684,20 @@ func (n *elNode) Render(ctx *Context) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
b := newTextBuilder()
|
return n.renderWithPath(normaliseContext(ctx), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *elNode) renderWithPath(ctx *Context, path string) string {
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteByte('<')
|
b.WriteByte('<')
|
||||||
b.WriteString(escapeHTML(n.tag))
|
b.WriteString(escapeHTML(n.tag))
|
||||||
|
|
||||||
// Sort attribute keys for deterministic output.
|
writeSortedAttrs(&b, n.attrs, nil)
|
||||||
keys := slices.Collect(maps.Keys(n.attrs))
|
|
||||||
slices.Sort(keys)
|
|
||||||
for _, key := range keys {
|
|
||||||
b.WriteByte(' ')
|
|
||||||
b.WriteString(escapeHTML(key))
|
|
||||||
b.WriteString(`="`)
|
|
||||||
b.WriteString(escapeAttr(n.attrs[key]))
|
|
||||||
b.WriteByte('"')
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteByte('>')
|
b.WriteByte('>')
|
||||||
|
|
||||||
|
|
@ -174,10 +706,7 @@ func (n *elNode) Render(ctx *Context) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range len(n.children) {
|
for i := range len(n.children) {
|
||||||
if n.children[i] == nil {
|
b.WriteString(renderNodeWithPath(n.children[i], ctx, path))
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(n.children[i].Render(ctx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("</")
|
b.WriteString("</")
|
||||||
|
|
@ -187,6 +716,24 @@ func (n *elNode) Render(ctx *Context) string {
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *elNode) cloneNode() Node {
|
||||||
|
if n == nil {
|
||||||
|
return (*elNode)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *n
|
||||||
|
if len(n.children) > 0 {
|
||||||
|
clone.children = make([]Node, len(n.children))
|
||||||
|
for i := range n.children {
|
||||||
|
clone.children[i] = cloneNode(n.children[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.attrs != nil {
|
||||||
|
clone.attrs = maps.Clone(n.attrs)
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
// --- escapeHTML ---
|
// --- escapeHTML ---
|
||||||
|
|
||||||
// escapeHTML escapes a string for safe inclusion in HTML text content.
|
// escapeHTML escapes a string for safe inclusion in HTML text content.
|
||||||
|
|
@ -201,8 +748,8 @@ type textNode struct {
|
||||||
args []any
|
args []any
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text creates a node that renders through the go-i18n grammar pipeline.
|
// node.go: Text creates a node that renders through the go-i18n grammar pipeline.
|
||||||
// Usage example: Text("welcome", "Ada")
|
// Example: Text("page.title") renders translated text and escapes it for HTML.
|
||||||
// Output is HTML-escaped by default. Safe-by-default path.
|
// Output is HTML-escaped by default. Safe-by-default path.
|
||||||
func Text(key string, args ...any) Node {
|
func Text(key string, args ...any) Node {
|
||||||
return &textNode{key: key, args: args}
|
return &textNode{key: key, args: args}
|
||||||
|
|
@ -212,6 +759,7 @@ func (n *textNode) Render(ctx *Context) string {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
return escapeHTML(translateText(ctx, n.key, n.args...))
|
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,8 +770,8 @@ type ifNode struct {
|
||||||
node Node
|
node Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// If renders child only when condition is true.
|
// node.go: If renders child only when condition is true.
|
||||||
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
// Example: If(func(*Context) bool { return true }, Raw("shown")).
|
||||||
func If(cond func(*Context) bool, node Node) Node {
|
func If(cond func(*Context) bool, node Node) Node {
|
||||||
return &ifNode{cond: cond, node: node}
|
return &ifNode{cond: cond, node: node}
|
||||||
}
|
}
|
||||||
|
|
@ -232,12 +780,24 @@ func (n *ifNode) Render(ctx *Context) string {
|
||||||
if n == nil || n.cond == nil || n.node == nil {
|
if n == nil || n.cond == nil || n.node == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
if n.cond(ctx) {
|
if n.cond(ctx) {
|
||||||
return n.node.Render(ctx)
|
return renderNodeWithPath(n.node, ctx, "")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *ifNode) cloneNode() Node {
|
||||||
|
if n == nil {
|
||||||
|
return (*ifNode)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *n
|
||||||
|
clone.node = cloneNode(n.node)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
// --- unlessNode ---
|
// --- unlessNode ---
|
||||||
|
|
||||||
type unlessNode struct {
|
type unlessNode struct {
|
||||||
|
|
@ -245,8 +805,8 @@ type unlessNode struct {
|
||||||
node Node
|
node Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unless renders child only when condition is false.
|
// node.go: Unless renders child only when condition is false.
|
||||||
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
// Example: Unless(func(*Context) bool { return true }, Raw("hidden")).
|
||||||
func Unless(cond func(*Context) bool, node Node) Node {
|
func Unless(cond func(*Context) bool, node Node) Node {
|
||||||
return &unlessNode{cond: cond, node: node}
|
return &unlessNode{cond: cond, node: node}
|
||||||
}
|
}
|
||||||
|
|
@ -255,12 +815,24 @@ func (n *unlessNode) Render(ctx *Context) string {
|
||||||
if n == nil || n.cond == nil || n.node == nil {
|
if n == nil || n.cond == nil || n.node == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
if !n.cond(ctx) {
|
if !n.cond(ctx) {
|
||||||
return n.node.Render(ctx)
|
return renderNodeWithPath(n.node, ctx, "")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *unlessNode) cloneNode() Node {
|
||||||
|
if n == nil {
|
||||||
|
return (*unlessNode)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *n
|
||||||
|
clone.node = cloneNode(n.node)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
// --- entitledNode ---
|
// --- entitledNode ---
|
||||||
|
|
||||||
type entitledNode struct {
|
type entitledNode struct {
|
||||||
|
|
@ -268,9 +840,10 @@ type entitledNode struct {
|
||||||
node Node
|
node Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entitled renders child only when entitlement is granted. Absent, not hidden.
|
// node.go: Entitled renders child only when entitlement is granted.
|
||||||
// Usage example: Entitled("beta", Text("preview"))
|
// Example: Entitled("premium", Raw("paid feature")).
|
||||||
// If no entitlement function is set on the context, access is denied by default.
|
// Content is absent, not hidden. If no entitlement function is set on the
|
||||||
|
// context, access is denied by default.
|
||||||
func Entitled(feature string, node Node) Node {
|
func Entitled(feature string, node Node) Node {
|
||||||
return &entitledNode{feature: feature, node: node}
|
return &entitledNode{feature: feature, node: node}
|
||||||
}
|
}
|
||||||
|
|
@ -279,10 +852,22 @@ func (n *entitledNode) Render(ctx *Context) string {
|
||||||
if n == nil || n.node == nil {
|
if n == nil || n.node == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
|
if ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return n.node.Render(ctx)
|
return renderNodeWithPath(n.node, ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *entitledNode) cloneNode() Node {
|
||||||
|
if n == nil {
|
||||||
|
return (*entitledNode)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *n
|
||||||
|
clone.node = cloneNode(n.node)
|
||||||
|
return &clone
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- switchNode ---
|
// --- switchNode ---
|
||||||
|
|
@ -292,29 +877,40 @@ type switchNode struct {
|
||||||
cases map[string]Node
|
cases map[string]Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch renders based on runtime selector value.
|
// node.go: Switch renders based on runtime selector value.
|
||||||
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
// Example: Switch(selector, map[string]Node{"desktop": Raw("wide")}).
|
||||||
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
||||||
return &switchNode{selector: selector, cases: cases}
|
return &switchNode{selector: selector, cases: cases}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *switchNode) Render(ctx *Context) string {
|
func (n *switchNode) Render(ctx *Context) string {
|
||||||
if n == nil || n.selector == nil {
|
if n == nil || n.selector == nil || n.cases == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
key := n.selector(ctx)
|
key := n.selector(ctx)
|
||||||
if n.cases == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if node, ok := n.cases[key]; ok {
|
if node, ok := n.cases[key]; ok {
|
||||||
if node == nil {
|
return renderNodeWithPath(node, ctx, "")
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return node.Render(ctx)
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *switchNode) cloneNode() Node {
|
||||||
|
if n == nil {
|
||||||
|
return (*switchNode)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *n
|
||||||
|
if n.cases != nil {
|
||||||
|
clone.cases = make(map[string]Node, len(n.cases))
|
||||||
|
for caseKey, caseNode := range n.cases {
|
||||||
|
clone.cases[caseKey] = cloneNode(caseNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
// --- eachNode ---
|
// --- eachNode ---
|
||||||
|
|
||||||
type eachNode[T any] struct {
|
type eachNode[T any] struct {
|
||||||
|
|
@ -322,49 +918,59 @@ type eachNode[T any] struct {
|
||||||
fn func(T) Node
|
fn func(T) Node
|
||||||
}
|
}
|
||||||
|
|
||||||
type attrApplier interface {
|
// node.go: Each iterates items and renders each via fn.
|
||||||
applyAttr(key, value string)
|
// Example: Each(items, func(item Item) Node { return El("li", Text(item.Name)) }).
|
||||||
}
|
|
||||||
|
|
||||||
// Each iterates items and renders each via fn.
|
|
||||||
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
|
||||||
func Each[T any](items []T, fn func(T) Node) Node {
|
func Each[T any](items []T, fn func(T) Node) Node {
|
||||||
return EachSeq(slices.Values(items), fn)
|
return EachSeq(slices.Values(items), fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EachSeq iterates an iter.Seq and renders each via fn.
|
// node.go: 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) })
|
// Example: EachSeq(slices.Values(items), renderItem).
|
||||||
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
||||||
return &eachNode[T]{items: items, fn: fn}
|
return &eachNode[T]{items: items, fn: fn}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *eachNode[T]) Render(ctx *Context) string {
|
func (n *eachNode[T]) Render(ctx *Context) string {
|
||||||
return n.renderWithLayoutPath(ctx, "")
|
if n == nil || n.items == nil || n.fn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for item := range n.items {
|
||||||
|
b.WriteString(renderNodeWithPath(n.fn(item), ctx, ""))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *eachNode[T]) applyAttr(key, value string) {
|
func (n *eachNode[T]) renderWithPath(ctx *Context, path string) string {
|
||||||
|
if n == nil || n.items == nil || n.fn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for item := range n.items {
|
||||||
|
b.WriteString(renderNodeWithPath(n.fn(item), ctx, path))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *eachNode[T]) setAttr(key, value string) {
|
||||||
if n == nil || n.fn == nil {
|
if n == nil || n.fn == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prev := n.fn
|
prev := n.fn
|
||||||
n.fn = func(item T) Node {
|
n.fn = func(item T) Node {
|
||||||
return Attr(prev(item), key, value)
|
return Attr(cloneNode(prev(item)), key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
func (n *eachNode[T]) cloneNode() Node {
|
||||||
if n == nil || n.fn == nil || n.items == nil {
|
if n == nil {
|
||||||
return ""
|
return (*eachNode[T])(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := newTextBuilder()
|
clone := *n
|
||||||
for item := range n.items {
|
return &clone
|
||||||
child := n.fn(item)
|
|
||||||
if child == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1086
node_test.go
1086
node_test.go
File diff suppressed because it is too large
Load diff
73
path.go
73
path.go
|
|
@ -2,27 +2,68 @@ package html
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
// path.go: ParseBlockID extracts the HLCRF slot sequence from a data-block ID.
|
||||||
// Usage example: slots := ParseBlockID("L-0-C-0")
|
// Example: ParseBlockID("L-0-C-0") returns []byte{'L', 'C'}.
|
||||||
// "L-0-C-0" → ['L', 'C']
|
|
||||||
func ParseBlockID(id string) []byte {
|
func ParseBlockID(id string) []byte {
|
||||||
|
segments := ParseBlockPath(id)
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := make([]byte, len(segments))
|
||||||
|
for i := range segments {
|
||||||
|
slots[i] = segments[i].Slot
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockPathSegment describes one slot occurrence in a data-block path.
|
||||||
|
// Example: BlockPathSegment{Slot: 'C', Index: 0} represents "C-0".
|
||||||
|
type BlockPathSegment struct {
|
||||||
|
Slot byte
|
||||||
|
Index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBlockPath extracts the slot/index sequence from a data-block ID.
|
||||||
|
// Example: ParseBlockPath("L-0-C-1") returns []BlockPathSegment{{'L', 0}, {'C', 1}}.
|
||||||
|
func ParseBlockPath(id string) []BlockPathSegment {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid IDs are exact sequences of "{slot}-0" segments, e.g.
|
// Split on "-" and require the exact structural pattern:
|
||||||
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID.
|
// slot, numeric index, slot, numeric index, ...
|
||||||
parts := strings.Split(id, "-")
|
parts := strings.SplitSeq(id, "-")
|
||||||
if len(parts)%2 != 0 {
|
segments := make([]BlockPathSegment, 0, 4)
|
||||||
|
i := 0
|
||||||
|
for part := range parts {
|
||||||
|
if i%2 == 0 {
|
||||||
|
if len(part) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch part[0] {
|
||||||
|
case 'H', 'L', 'C', 'R', 'F':
|
||||||
|
segments = append(segments, BlockPathSegment{Slot: part[0]})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if part == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := 0
|
||||||
|
for j := range len(part) {
|
||||||
|
if part[j] < '0' || part[j] > '9' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index = index*10 + int(part[j]-'0')
|
||||||
|
}
|
||||||
|
segments[len(segments)-1].Index = index
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 || i%2 != 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return segments
|
||||||
slots := make([]byte, 0, len(parts)/2)
|
|
||||||
for i := 0; i < len(parts); i += 2 {
|
|
||||||
if len(parts[i]) != 1 || parts[i+1] != "0" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
slots = append(slots, parts[i][0])
|
|
||||||
}
|
|
||||||
return slots
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
179
path_test.go
179
path_test.go
|
|
@ -1,10 +1,13 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNestedLayout_PathChain_Good(t *testing.T) {
|
func TestNestedLayout_PathChain(t *testing.T) {
|
||||||
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
|
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
|
||||||
outer := NewLayout("HLCRF").
|
outer := NewLayout("HLCRF").
|
||||||
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||||
|
|
@ -12,62 +15,175 @@ func TestNestedLayout_PathChain_Good(t *testing.T) {
|
||||||
|
|
||||||
// Inner layout paths must be prefixed with parent block ID
|
// Inner layout paths must be prefixed with parent block ID
|
||||||
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
|
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("nested layout missing %q in:\n%s", want, got)
|
t.Errorf("nested layout missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outer layout must still have root-level paths
|
// Outer layout must still have root-level paths
|
||||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("outer layout missing %q in:\n%s", want, got)
|
t.Errorf("outer layout missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
|
func TestNestedLayout_DeepNesting(t *testing.T) {
|
||||||
deepest := NewLayout("C").C(Raw("deep"))
|
deepest := NewLayout("C").C(Raw("deep"))
|
||||||
middle := NewLayout("C").C(deepest)
|
middle := NewLayout("C").C(deepest)
|
||||||
outer := NewLayout("C").C(middle)
|
outer := NewLayout("C").C(middle)
|
||||||
got := outer.Render(NewContext())
|
got := outer.Render(NewContext())
|
||||||
|
|
||||||
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
|
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("deep nesting missing %q in:\n%s", want, got)
|
t.Errorf("deep nesting missing %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlockID_BuildsPath_Good(t *testing.T) {
|
func TestNestedLayout_ThroughConditionalWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("wrapped"))
|
||||||
|
wrapped := If(func(*Context) bool { return true }, inner)
|
||||||
|
got := NewLayout("C").C(wrapped).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("conditional wrapper should preserve nested block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughEntitledWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
ctx.Entitlements = func(feature string) bool { return feature == "feature" }
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("entitled"))
|
||||||
|
wrapped := Entitled("feature", inner)
|
||||||
|
got := NewLayout("C").C(wrapped).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("entitled wrapper should preserve nested block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughSwitchWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("switch"))
|
||||||
|
wrapped := Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||||
|
"match": inner,
|
||||||
|
})
|
||||||
|
got := NewLayout("C").C(wrapped).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("switch wrapper should preserve nested block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughEachWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
items := []int{1, 2}
|
||||||
|
node := Each(items, func(i int) Node {
|
||||||
|
return NewLayout("C").C(Raw(strings.Repeat("x", i)))
|
||||||
|
})
|
||||||
|
|
||||||
|
got := NewLayout("C").C(node).Render(ctx)
|
||||||
|
|
||||||
|
if count := strings.Count(got, `data-block="C-0-C-0"`); count != 2 {
|
||||||
|
t.Fatalf("each wrapper should preserve nested block path twice, got %d in:\n%s", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughEachSeqWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
node := EachSeq(slices.Values([]string{"a", "b"}), func(s string) Node {
|
||||||
|
return NewLayout("C").C(Raw(s))
|
||||||
|
})
|
||||||
|
|
||||||
|
got := NewLayout("C").C(node).Render(ctx)
|
||||||
|
|
||||||
|
if count := strings.Count(got, `data-block="C-0-C-0"`); count != 2 {
|
||||||
|
t.Fatalf("eachseq wrapper should preserve nested block path twice, got %d in:\n%s", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughElementWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("wrapped"))
|
||||||
|
wrapped := El("section", inner)
|
||||||
|
got := NewLayout("C").C(wrapped).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("element wrapper should preserve nested block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_ThroughResponsiveWrapper(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("wrapped"))
|
||||||
|
wrapped := NewResponsive().
|
||||||
|
Variant("desktop", inner)
|
||||||
|
got := NewLayout("C").C(wrapped).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("responsive wrapper should preserve nested block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLayout_NilChild(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
got := NewLayout("C").C(nil, Raw("leaf")).Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, "leaf") {
|
||||||
|
t.Fatalf("layout with nil child should still render leaf content, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "<nil>") {
|
||||||
|
t.Fatalf("layout with nil child should not render placeholder text, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockID(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
slot byte
|
slot byte
|
||||||
want string
|
index int
|
||||||
|
want string
|
||||||
}{
|
}{
|
||||||
{"", 'H', "H-0"},
|
{"", 'H', 0, "H-0"},
|
||||||
{"L-0-", 'C', "L-0-C-0"},
|
{"L-0-", 'C', 0, "L-0-C-0"},
|
||||||
{"C-0-C-0-", 'C', "C-0-C-0-C-0"},
|
{"C-0-C-0-", 'C', 0, "C-0-C-0-C-0"},
|
||||||
{"", 'F', "F-0"},
|
{"", 'F', 2, "F-2"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
l := &Layout{path: tt.path}
|
l := &Layout{path: tt.path}
|
||||||
got := l.blockID(tt.slot)
|
got := l.BlockID(tt.slot, tt.index)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("blockID(%q, %c) = %q, want %q", tt.path, tt.slot, got, tt.want)
|
t.Errorf("BlockID(%q, %c, %d) = %q, want %q", tt.path, tt.slot, tt.index, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
func TestParseBlockID(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
id string
|
id string
|
||||||
want []byte
|
want []byte
|
||||||
}{
|
}{
|
||||||
{"L-0-C-0", []byte{'L', 'C'}},
|
{"L-0-C-0", []byte{'L', 'C'}},
|
||||||
|
{"C-0-C-1", []byte{'C', 'C'}},
|
||||||
{"H-0", []byte{'H'}},
|
{"H-0", []byte{'H'}},
|
||||||
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
|
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
|
||||||
|
{"X-0", nil},
|
||||||
|
{"H-0-X-0", nil},
|
||||||
{"", nil},
|
{"", nil},
|
||||||
|
{"L-1-C-0", []byte{'L', 'C'}},
|
||||||
|
{"L-0-C", nil},
|
||||||
|
{"LL-0", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -84,17 +200,30 @@ func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseBlockID_InvalidInput_Good(t *testing.T) {
|
func TestParseBlockPath(t *testing.T) {
|
||||||
tests := []string{
|
tests := []struct {
|
||||||
"L-1-C-0",
|
id string
|
||||||
"L-0-C",
|
want []BlockPathSegment
|
||||||
"L-0-",
|
}{
|
||||||
"X",
|
{"L-0-C-0", []BlockPathSegment{{Slot: 'L', Index: 0}, {Slot: 'C', Index: 0}}},
|
||||||
|
{"C-12-C-3", []BlockPathSegment{{Slot: 'C', Index: 12}, {Slot: 'C', Index: 3}}},
|
||||||
|
{"H-0", []BlockPathSegment{{Slot: 'H', Index: 0}}},
|
||||||
|
{"", nil},
|
||||||
|
{"X-0", nil},
|
||||||
|
{"L-0-C", nil},
|
||||||
|
{"L-0-C-x", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, id := range tests {
|
for _, tt := range tests {
|
||||||
if got := ParseBlockID(id); got != nil {
|
got := ParseBlockPath(tt.id)
|
||||||
t.Errorf("ParseBlockID(%q) = %v, want nil", id, got)
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("ParseBlockPath(%q) = %v, want %v", tt.id, got, tt.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("ParseBlockPath(%q)[%d] = %#v, want %#v", tt.id, i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
pipeline.go
82
pipeline.go
|
|
@ -3,73 +3,74 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
core "dappco.re/go/core"
|
htmlstd "html"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"dappco.re/go/core/i18n/reversal"
|
"dappco.re/go/core/i18n/reversal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StripTags removes HTML tags from rendered output, returning plain text.
|
// pipeline.go: StripTags removes HTML tags from rendered output, returning plain text.
|
||||||
// Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
// Example: StripTags("<p>Hello</p><p>world</p>") returns "Hello world".
|
||||||
// Tag boundaries are collapsed into single spaces; result is trimmed.
|
// Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||||
// Does not handle script/style element content (go-html does not generate these).
|
// Does not handle script/style element content (go-html does not generate these).
|
||||||
func StripTags(html string) string {
|
func StripTags(html string) string {
|
||||||
b := core.NewBuilder()
|
var b strings.Builder
|
||||||
inTag := false
|
inTag := false
|
||||||
prevSpace := true // starts true to trim leading space
|
pendingSpace := false
|
||||||
|
seenText := false
|
||||||
for _, r := range html {
|
for _, r := range html {
|
||||||
|
if inTag {
|
||||||
|
if r == '>' {
|
||||||
|
inTag = false
|
||||||
|
if seenText {
|
||||||
|
pendingSpace = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if r == '<' {
|
if r == '<' {
|
||||||
inTag = true
|
inTag = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r == '>' {
|
|
||||||
inTag = false
|
if unicode.IsSpace(r) {
|
||||||
if !prevSpace {
|
if seenText {
|
||||||
b.WriteByte(' ')
|
pendingSpace = true
|
||||||
prevSpace = true
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !inTag {
|
|
||||||
if r == ' ' || r == '\t' || r == '\n' {
|
if pendingSpace {
|
||||||
if !prevSpace {
|
b.WriteByte(' ')
|
||||||
b.WriteByte(' ')
|
pendingSpace = false
|
||||||
prevSpace = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.WriteRune(r)
|
|
||||||
prevSpace = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
seenText = true
|
||||||
}
|
}
|
||||||
return core.Trim(b.String())
|
return htmlstd.UnescapeString(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
// pipeline.go: Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||||
// and returns a GrammarImprint — the full render-reverse pipeline.
|
// and returns a GrammarImprint — the full render-reverse pipeline.
|
||||||
// Usage example: imp := Imprint(Text("welcome"), NewContext())
|
// Example: Imprint(NewLayout("C").C(Text("page.body")), NewContext()).
|
||||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||||
if ctx == nil {
|
ctx = normaliseContext(ctx)
|
||||||
ctx = NewContext()
|
rendered := Render(node, ctx)
|
||||||
}
|
|
||||||
rendered := ""
|
|
||||||
if node != nil {
|
|
||||||
rendered = node.Render(ctx)
|
|
||||||
}
|
|
||||||
text := StripTags(rendered)
|
text := StripTags(rendered)
|
||||||
tok := reversal.NewTokeniser()
|
tok := reversal.NewTokeniser()
|
||||||
tokens := tok.Tokenise(text)
|
tokens := tok.Tokenise(text)
|
||||||
return reversal.NewImprint(tokens)
|
return reversal.NewImprint(tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompareVariants runs the imprint pipeline on each responsive variant independently
|
// pipeline.go: CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||||
// and returns pairwise similarity scores. Key format: "name1:name2".
|
// and returns pairwise similarity scores. Key format: "name1:name2".
|
||||||
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
// Example: CompareVariants(NewResponsive().Variant("desktop", NewLayout("C")), NewContext()).
|
||||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||||
if ctx == nil {
|
ctx = normaliseContext(ctx)
|
||||||
ctx = NewContext()
|
|
||||||
}
|
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return make(map[string]float64)
|
return map[string]float64{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type named struct {
|
type named struct {
|
||||||
|
|
@ -79,9 +80,6 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||||
|
|
||||||
var imprints []named
|
var imprints []named
|
||||||
for _, v := range r.variants {
|
for _, v := range r.variants {
|
||||||
if v.layout == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
imp := Imprint(v.layout, ctx)
|
imp := Imprint(v.layout, ctx)
|
||||||
imprints = append(imprints, named{name: v.name, imp: imp})
|
imprints = append(imprints, named{name: v.name, imp: imp})
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +87,11 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||||
scores := make(map[string]float64)
|
scores := make(map[string]float64)
|
||||||
for i := range len(imprints) {
|
for i := range len(imprints) {
|
||||||
for j := i + 1; j < len(imprints); j++ {
|
for j := i + 1; j < len(imprints); j++ {
|
||||||
key := imprints[i].name + ":" + imprints[j].name
|
left, right := imprints[i].name, imprints[j].name
|
||||||
|
if right < left {
|
||||||
|
left, right = right, left
|
||||||
|
}
|
||||||
|
key := left + ":" + right
|
||||||
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
|
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStripTags_Simple_Good(t *testing.T) {
|
func TestStripTags_Simple(t *testing.T) {
|
||||||
got := StripTags(`<div>hello</div>`)
|
got := StripTags(`<div>hello</div>`)
|
||||||
want := "hello"
|
want := "hello"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -16,7 +16,7 @@ func TestStripTags_Simple_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Nested_Good(t *testing.T) {
|
func TestStripTags_Nested(t *testing.T) {
|
||||||
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
||||||
want := "Title"
|
want := "Title"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -24,7 +24,7 @@ func TestStripTags_Nested_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
func TestStripTags_MultipleRegions(t *testing.T) {
|
||||||
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
||||||
want := "Head Body Foot"
|
want := "Head Body Foot"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -32,29 +32,37 @@ func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Empty_Ugly(t *testing.T) {
|
func TestStripTags_BoundaryWhitespace(t *testing.T) {
|
||||||
|
got := StripTags(`<p></p><p>hello</p><p></p>`)
|
||||||
|
want := "hello"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("StripTags(boundary whitespace) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripTags_Empty(t *testing.T) {
|
||||||
got := StripTags("")
|
got := StripTags("")
|
||||||
if got != "" {
|
if got != "" {
|
||||||
t.Errorf("StripTags(\"\") = %q, want empty", got)
|
t.Errorf("StripTags(\"\") = %q, want empty", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_NoTags_Good(t *testing.T) {
|
func TestStripTags_NoTags(t *testing.T) {
|
||||||
got := StripTags("plain text")
|
got := StripTags("plain text")
|
||||||
if got != "plain text" {
|
if got != "plain text" {
|
||||||
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
|
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Entities_Good(t *testing.T) {
|
func TestStripTags_Entities(t *testing.T) {
|
||||||
got := StripTags(`<script>`)
|
got := StripTags(`<p>& <script></p>`)
|
||||||
want := "<script>"
|
want := "& <script>"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("StripTags should preserve entities, got %q, want %q", got, want)
|
t.Errorf("StripTags should unescape entities, got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_FromNode_Good(t *testing.T) {
|
func TestImprint_FromNode(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -74,7 +82,20 @@ func TestImprint_FromNode_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_SimilarPages_Good(t *testing.T) {
|
func TestImprint_NilNode(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
imp := Imprint(nil, NewContext())
|
||||||
|
if imp.TokenCount != 0 {
|
||||||
|
t.Fatalf("Imprint(nil, ctx) TokenCount = %d, want 0", imp.TokenCount)
|
||||||
|
}
|
||||||
|
if imp.UniqueVerbs != 0 {
|
||||||
|
t.Fatalf("Imprint(nil, ctx) UniqueVerbs = %d, want 0", imp.UniqueVerbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImprint_SimilarPages(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -102,7 +123,7 @@ func TestImprint_SimilarPages_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants_SameContent_Good(t *testing.T) {
|
func TestCompareVariants(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -128,3 +149,29 @@ func TestCompareVariants_SameContent_Good(t *testing.T) {
|
||||||
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
|
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareVariants_CanonicalKeyOrder(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
i18n.SetDefault(svc)
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
r := NewResponsive().
|
||||||
|
Variant("mobile", NewLayout("C").C(El("p", Text("Hello")))).
|
||||||
|
Variant("desktop", NewLayout("C").C(El("p", Text("Hello"))))
|
||||||
|
|
||||||
|
scores := CompareVariants(r, ctx)
|
||||||
|
|
||||||
|
if _, ok := scores["desktop:mobile"]; !ok {
|
||||||
|
t.Fatalf("CompareVariants should canonicalise pair keys, got %v", scores)
|
||||||
|
}
|
||||||
|
if _, ok := scores["mobile:desktop"]; ok {
|
||||||
|
t.Fatalf("CompareVariants should not emit reversed duplicate keys, got %v", scores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareVariants_NilResponsive(t *testing.T) {
|
||||||
|
scores := CompareVariants(nil, NewContext())
|
||||||
|
if len(scores) != 0 {
|
||||||
|
t.Fatalf("CompareVariants(nil, ctx) = %v, want empty map", scores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
// Render is a convenience function that renders a node tree to HTML.
|
// render.go: Render is a convenience function that renders a node tree to HTML.
|
||||||
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
// Example: Render(NewLayout("C").C(Raw("body")), NewContext()).
|
||||||
func Render(node Node, ctx *Context) string {
|
func Render(node Node, ctx *Context) string {
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
|
||||||
ctx = NewContext()
|
|
||||||
}
|
|
||||||
return node.Render(ctx)
|
return node.Render(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRender_FullPage_Good(t *testing.T) {
|
func TestRender_FullPage(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -27,14 +28,14 @@ func TestRender_FullPage_Good(t *testing.T) {
|
||||||
|
|
||||||
// Contains semantic elements
|
// Contains semantic elements
|
||||||
for _, want := range []string{"<header", "<main", "<footer"} {
|
for _, want := range []string{"<header", "<main", "<footer"} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content rendered
|
// Content rendered
|
||||||
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
|
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
|
||||||
if !containsText(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("full page missing content %q in:\n%s", want, got)
|
t.Errorf("full page missing content %q in:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -43,13 +44,13 @@ func TestRender_FullPage_Good(t *testing.T) {
|
||||||
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
|
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
|
||||||
open := "<" + tag
|
open := "<" + tag
|
||||||
close := "</" + tag + ">"
|
close := "</" + tag + ">"
|
||||||
if countText(got, open) != countText(got, close) {
|
if strings.Count(got, open) != strings.Count(got, close) {
|
||||||
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_EntitlementGating_Good(t *testing.T) {
|
func TestRender_EntitlementGating(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -66,18 +67,18 @@ func TestRender_EntitlementGating_Good(t *testing.T) {
|
||||||
|
|
||||||
got := page.Render(ctx)
|
got := page.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, "public") {
|
if !strings.Contains(got, "public") {
|
||||||
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, "admin-panel") {
|
if !strings.Contains(got, "admin-panel") {
|
||||||
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if containsText(got, "premium-content") {
|
if strings.Contains(got, "premium-content") {
|
||||||
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_XSSPrevention_Good(t *testing.T) {
|
func TestRender_XSSPrevention(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -87,10 +88,17 @@ func TestRender_XSSPrevention_Good(t *testing.T) {
|
||||||
|
|
||||||
got := page.Render(ctx)
|
got := page.Render(ctx)
|
||||||
|
|
||||||
if containsText(got, "<script>") {
|
if strings.Contains(got, "<script>") {
|
||||||
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, "<script>") {
|
if !strings.Contains(got, "<script>") {
|
||||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRender_NilNode(t *testing.T) {
|
||||||
|
got := Render(nil, NewContext())
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("Render(nil, ctx) = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
249
responsive.go
249
responsive.go
|
|
@ -1,18 +1,17 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maps"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time interface check.
|
// responsive.go: Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||||
var _ Node = (*Responsive)(nil)
|
// Example: NewResponsive().Variant("desktop", NewLayout("C").C(Raw("main"))).
|
||||||
|
|
||||||
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
|
||||||
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
|
||||||
// Each variant is rendered inside a container with data-variant for CSS targeting.
|
// Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||||
type Responsive struct {
|
type Responsive struct {
|
||||||
variants []responsiveVariant
|
variants []responsiveVariant
|
||||||
|
attrs map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type responsiveVariant struct {
|
type responsiveVariant struct {
|
||||||
|
|
@ -20,72 +19,57 @@ type responsiveVariant struct {
|
||||||
layout *Layout
|
layout *Layout
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResponsive creates a new multi-variant responsive compositor.
|
// responsive.go: NewResponsive creates a new multi-variant responsive compositor.
|
||||||
// Usage example: r := NewResponsive()
|
// Example: r := NewResponsive().
|
||||||
func NewResponsive() *Responsive {
|
func NewResponsive() *Responsive {
|
||||||
return &Responsive{}
|
return &Responsive{
|
||||||
|
attrs: make(map[string]string),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
// Clone returns a deep copy of the responsive compositor.
|
||||||
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
// Example: next := responsive.Clone().
|
||||||
// Variants render in insertion order.
|
func (r *Responsive) Clone() *Responsive {
|
||||||
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
|
||||||
if r == nil {
|
if r == nil {
|
||||||
r = NewResponsive()
|
return nil
|
||||||
}
|
}
|
||||||
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
|
||||||
return r
|
clone, ok := r.cloneNode().(*Responsive)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render produces HTML with each variant in a data-variant container.
|
func (r *Responsive) setAttr(key, value string) {
|
||||||
// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
|
||||||
func (r *Responsive) Render(ctx *Context) string {
|
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return ""
|
return
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
if r.attrs == nil {
|
||||||
ctx = NewContext()
|
r.attrs = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
r.attrs[key] = value
|
||||||
b := newTextBuilder()
|
|
||||||
for _, v := range r.variants {
|
|
||||||
if v.layout == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(`<div data-variant="`)
|
|
||||||
b.WriteString(escapeAttr(v.name))
|
|
||||||
b.WriteString(`">`)
|
|
||||||
b.WriteString(v.layout.Render(ctx))
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// VariantSelector returns a CSS attribute selector for a responsive variant.
|
|
||||||
// Usage example: selector := VariantSelector("desktop")
|
|
||||||
func VariantSelector(name string) string {
|
|
||||||
return `[data-variant="` + escapeCSSString(name) + `"]`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeCSSString escapes a string for safe use inside a double-quoted CSS
|
||||||
|
// attribute selector.
|
||||||
func escapeCSSString(s string) string {
|
func escapeCSSString(s string) string {
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
switch r {
|
switch r {
|
||||||
case '\\', '"':
|
case '\\', '"':
|
||||||
b.WriteByte('\\')
|
b.WriteByte('\\')
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
|
case '\n':
|
||||||
|
b.WriteString(`\a `)
|
||||||
|
case '\r':
|
||||||
|
b.WriteString(`\d `)
|
||||||
|
case '\f':
|
||||||
|
b.WriteString(`\c `)
|
||||||
default:
|
default:
|
||||||
if r < 0x20 || r == 0x7f {
|
if r < 0x20 || r == 0x7f {
|
||||||
b.WriteByte('\\')
|
b.WriteByte('\\')
|
||||||
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
|
b.WriteString(strings.ToLower(strconv.FormatInt(int64(r), 16)))
|
||||||
for i := 0; i < len(esc); i++ {
|
|
||||||
b.WriteByte(esc[i])
|
|
||||||
}
|
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -94,3 +78,170 @@ func escapeCSSString(s string) string {
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// responsive.go: VariantSelector returns a CSS attribute selector for a named responsive variant.
|
||||||
|
// Example: VariantSelector("desktop") returns [data-variant="desktop"].
|
||||||
|
func VariantSelector(name string) string {
|
||||||
|
return `[data-variant="` + escapeCSSString(name) + `"]`
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsive.go: ScopeVariant prefixes a selector so it only matches elements inside the
|
||||||
|
// named responsive variant.
|
||||||
|
// Example: ScopeVariant("desktop", ".nav").
|
||||||
|
func ScopeVariant(name, selector string) string {
|
||||||
|
scope := VariantSelector(name)
|
||||||
|
if selector == "" {
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := splitSelectorList(selector)
|
||||||
|
scoped := make([]string, 0, len(parts))
|
||||||
|
for i := range parts {
|
||||||
|
part := strings.TrimSpace(parts[i])
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scoped = append(scoped, scope+" "+part)
|
||||||
|
}
|
||||||
|
if len(scoped) == 0 {
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
return strings.Join(scoped, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSelectorList splits a CSS selector list on top-level commas only.
|
||||||
|
// Commas inside brackets, parentheses, braces, or quoted strings are preserved.
|
||||||
|
func splitSelectorList(selector string) []string {
|
||||||
|
if selector == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 1)
|
||||||
|
var b strings.Builder
|
||||||
|
var quote rune
|
||||||
|
escaped := false
|
||||||
|
depthParen := 0
|
||||||
|
depthBracket := 0
|
||||||
|
depthBrace := 0
|
||||||
|
|
||||||
|
for _, r := range selector {
|
||||||
|
if escaped {
|
||||||
|
b.WriteRune(r)
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == '\\' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case quote != 0:
|
||||||
|
b.WriteRune(r)
|
||||||
|
if r == quote {
|
||||||
|
quote = 0
|
||||||
|
}
|
||||||
|
case r == '"' || r == '\'':
|
||||||
|
quote = r
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '(':
|
||||||
|
depthParen++
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ')':
|
||||||
|
if depthParen > 0 {
|
||||||
|
depthParen--
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '[':
|
||||||
|
depthBracket++
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ']':
|
||||||
|
if depthBracket > 0 {
|
||||||
|
depthBracket--
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '{':
|
||||||
|
depthBrace++
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '}':
|
||||||
|
if depthBrace > 0 {
|
||||||
|
depthBrace--
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ',' && depthParen == 0 && depthBracket == 0 && depthBrace == 0:
|
||||||
|
parts = append(parts, b.String())
|
||||||
|
b.Reset()
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, b.String())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsive.go: Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||||
|
// Example: r.Variant("mobile", NewLayout("C").C(Raw("body"))).
|
||||||
|
// Variants render in insertion order.
|
||||||
|
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if layout != nil {
|
||||||
|
layout = layout.Clone()
|
||||||
|
}
|
||||||
|
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsive.go: Render produces HTML with each variant in a data-variant container.
|
||||||
|
// Example: NewResponsive().Variant("desktop", NewLayout("C")).Render(NewContext()).
|
||||||
|
func (r *Responsive) Render(ctx *Context) string {
|
||||||
|
return r.renderWithPath(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responsive) cloneNode() Node {
|
||||||
|
if r == nil {
|
||||||
|
return (*Responsive)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *r
|
||||||
|
if r.attrs != nil {
|
||||||
|
clone.attrs = maps.Clone(r.attrs)
|
||||||
|
}
|
||||||
|
if r.variants != nil {
|
||||||
|
clone.variants = make([]responsiveVariant, len(r.variants))
|
||||||
|
for i := range r.variants {
|
||||||
|
clone.variants[i] = r.variants[i]
|
||||||
|
if r.variants[i].layout != nil {
|
||||||
|
if layout, ok := cloneNode(r.variants[i].layout).(*Layout); ok {
|
||||||
|
clone.variants[i].layout = layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responsive) renderWithPath(ctx *Context, path string) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx = normaliseContext(ctx)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, v := range r.variants {
|
||||||
|
b.WriteString(`<div`)
|
||||||
|
writeSortedAttrs(&b, r.attrs, func(key string) bool {
|
||||||
|
return key == "data-variant"
|
||||||
|
})
|
||||||
|
b.WriteString(` data-variant="`)
|
||||||
|
b.WriteString(escapeAttr(v.name))
|
||||||
|
b.WriteString(`">`)
|
||||||
|
b.WriteString(renderNodeWithPath(v.layout, ctx, path))
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,26 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResponsive_SingleVariant_Good(t *testing.T) {
|
func TestResponsive_SingleVariant(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("desktop", NewLayout("HLCRF").
|
Variant("desktop", NewLayout("HLCRF").
|
||||||
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
|
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, `data-variant="desktop"`) {
|
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||||
t.Errorf("responsive should contain data-variant, got:\n%s", got)
|
t.Errorf("responsive should contain data-variant, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, `data-block="H-0"`) {
|
if !strings.Contains(got, `data-block="H-0"`) {
|
||||||
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_MultiVariant_Good(t *testing.T) {
|
func TestResponsive_MultiVariant(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||||
|
|
@ -29,13 +30,13 @@ func TestResponsive_MultiVariant_Good(t *testing.T) {
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
for _, v := range []string{"desktop", "tablet", "mobile"} {
|
for _, v := range []string{"desktop", "tablet", "mobile"} {
|
||||||
if !containsText(got, `data-variant="`+v+`"`) {
|
if !strings.Contains(got, `data-variant="`+v+`"`) {
|
||||||
t.Errorf("responsive missing variant %q in:\n%s", v, got)
|
t.Errorf("responsive missing variant %q in:\n%s", v, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_VariantOrder_Good(t *testing.T) {
|
func TestResponsive_VariantOrder(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
|
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
|
||||||
|
|
@ -43,8 +44,8 @@ func TestResponsive_VariantOrder_Good(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
di := indexText(got, `data-variant="desktop"`)
|
di := strings.Index(got, `data-variant="desktop"`)
|
||||||
mi := indexText(got, `data-variant="mobile"`)
|
mi := strings.Index(got, `data-variant="mobile"`)
|
||||||
if di < 0 || mi < 0 {
|
if di < 0 || mi < 0 {
|
||||||
t.Fatalf("missing variants in:\n%s", got)
|
t.Fatalf("missing variants in:\n%s", got)
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +54,53 @@ func TestResponsive_VariantOrder_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_NestedPaths_Good(t *testing.T) {
|
func TestResponsive_CloneReturnsIndependentCopy(t *testing.T) {
|
||||||
|
original := NewResponsive().
|
||||||
|
Variant("desktop", NewLayout("C").C(Raw("desktop"))).
|
||||||
|
Variant("mobile", NewLayout("C").C(Raw("mobile")))
|
||||||
|
|
||||||
|
clone := original.Clone()
|
||||||
|
if clone == nil {
|
||||||
|
t.Fatal("Clone should return a responsive compositor")
|
||||||
|
}
|
||||||
|
if clone == original {
|
||||||
|
t.Fatal("Clone should return a distinct responsive instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.Variant("tablet", NewLayout("C").C(Raw("tablet")))
|
||||||
|
clone.setAttr("class", "cloned-responsive")
|
||||||
|
|
||||||
|
originalGot := original.Render(NewContext())
|
||||||
|
cloneGot := clone.Render(NewContext())
|
||||||
|
|
||||||
|
if strings.Contains(originalGot, "tablet") || strings.Contains(originalGot, "cloned-responsive") {
|
||||||
|
t.Fatalf("Clone should not mutate original responsive compositor, got:\n%s", originalGot)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cloneGot, `class="cloned-responsive"`) {
|
||||||
|
t.Fatalf("Clone should preserve attributes on the copy, got:\n%s", cloneGot)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cloneGot, `data-variant="tablet"`) {
|
||||||
|
t.Fatalf("Clone should preserve new variants on the copy, got:\n%s", cloneGot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsive_VariantClonesLayoutInput(t *testing.T) {
|
||||||
|
layout := NewLayout("C").C(Raw("original"))
|
||||||
|
responsive := NewResponsive().Variant("desktop", layout)
|
||||||
|
|
||||||
|
layout.C(Raw("mutated"))
|
||||||
|
|
||||||
|
got := responsive.Render(NewContext())
|
||||||
|
|
||||||
|
if !strings.Contains(got, "original") {
|
||||||
|
t.Fatalf("Variant should snapshot the layout at insertion time, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "mutated") {
|
||||||
|
t.Fatalf("Variant should not share later layout mutations, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsive_NestedPaths(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
|
|
@ -61,15 +108,15 @@ func TestResponsive_NestedPaths_Good(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
if !containsText(got, `data-block="C-0-H-0"`) {
|
if !strings.Contains(got, `data-block="C-0-H-0"`) {
|
||||||
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
||||||
}
|
}
|
||||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||||
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
func TestResponsive_VariantsIndependent(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
||||||
|
|
@ -77,60 +124,176 @@ func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
count := countText(got, `data-block="C-0"`)
|
count := strings.Count(got, `data-block="C-0"`)
|
||||||
if count != 2 {
|
if count != 2 {
|
||||||
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
|
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
|
func TestResponsive_ImplementsNode(t *testing.T) {
|
||||||
var _ Node = NewResponsive()
|
var _ Node = NewResponsive()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
|
func TestResponsive_RenderNilReceiver(t *testing.T) {
|
||||||
var r *Responsive
|
var r *Responsive
|
||||||
|
got := r.Render(NewContext())
|
||||||
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
|
if got != "" {
|
||||||
if got == nil {
|
t.Fatalf("nil Responsive should render empty string, got %q", got)
|
||||||
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) {
|
func TestResponsive_BuilderNilReceiver(t *testing.T) {
|
||||||
|
var r *Responsive
|
||||||
|
if got := r.Variant("desktop", NewLayout("C")); got != nil {
|
||||||
|
t.Fatalf("nil Responsive.Variant() should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsive_RenderNilContext(t *testing.T) {
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("mobile", NewLayout("C").C(Raw("content")))
|
Variant("desktop", NewLayout("C").C(Raw("main")))
|
||||||
|
|
||||||
got := r.Render(nil)
|
got := r.Render(nil)
|
||||||
want := `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>`
|
|
||||||
if got != want {
|
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||||
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
|
t.Fatalf("Responsive.Render(nil) should still render the variant wrapper, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `data-block="C-0"`) {
|
||||||
|
t.Fatalf("Responsive.Render(nil) should still render the layout block, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVariantSelector_Good(t *testing.T) {
|
func TestResponsive_NilLayoutVariant(t *testing.T) {
|
||||||
got := VariantSelector("desktop")
|
ctx := NewContext()
|
||||||
want := `[data-variant="desktop"]`
|
r := NewResponsive().
|
||||||
if got != want {
|
Variant("desktop", nil).
|
||||||
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
|
Variant("mobile", NewLayout("C").C(Raw("m")))
|
||||||
|
|
||||||
|
got := r.Render(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||||
|
t.Fatalf("nil layout variant should still render its wrapper, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "<nil>") {
|
||||||
|
t.Fatalf("nil layout variant should not render placeholder text, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `data-variant="mobile"`) {
|
||||||
|
t.Fatalf("responsive should still render subsequent variants, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVariantSelector_Escapes_Good(t *testing.T) {
|
func TestResponsive_Attributes(t *testing.T) {
|
||||||
got := VariantSelector("desk\"top\\wide")
|
ctx := NewContext()
|
||||||
want := `[data-variant="desk\"top\\wide"]`
|
r := Attr(NewResponsive().
|
||||||
if got != want {
|
Variant("desktop", NewLayout("C").C(Raw("main"))).
|
||||||
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
|
Variant("mobile", NewLayout("C").C(Raw("main"))),
|
||||||
|
"aria-label", "Responsive content",
|
||||||
|
)
|
||||||
|
r = Attr(r, "class", "responsive-shell")
|
||||||
|
|
||||||
|
got := r.Render(ctx)
|
||||||
|
|
||||||
|
if count := strings.Count(got, `aria-label="Responsive content"`); count != 2 {
|
||||||
|
t.Fatalf("responsive attrs should apply to each wrapper, got %d in:\n%s", count, got)
|
||||||
|
}
|
||||||
|
if count := strings.Count(got, `class="responsive-shell"`); count != 2 {
|
||||||
|
t.Fatalf("responsive class should apply to each wrapper, got %d in:\n%s", count, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `aria-label="Responsive content" class="responsive-shell" data-variant="desktop"`) {
|
||||||
|
t.Fatalf("responsive wrapper attrs should be sorted and preserved, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
|
func TestResponsive_ReservedVariantAttributeIsIgnored(t *testing.T) {
|
||||||
got := VariantSelector("a\tb\nc\u0007")
|
ctx := NewContext()
|
||||||
want := `[data-variant="a\9 b\A c\7 "]`
|
r := Attr(NewResponsive().
|
||||||
if got != want {
|
Variant("desktop", NewLayout("C").C(Raw("main"))),
|
||||||
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
|
"data-variant", "override",
|
||||||
|
)
|
||||||
|
|
||||||
|
got := r.Render(ctx)
|
||||||
|
|
||||||
|
if count := strings.Count(got, `data-variant=`); count != 1 {
|
||||||
|
t.Fatalf("responsive wrapper should emit exactly one data-variant attribute, got %d in:\n%s", count, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||||
|
t.Fatalf("responsive wrapper should preserve its own variant name, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `data-variant="override"`) {
|
||||||
|
t.Fatalf("responsive wrapper should ignore reserved data-variant attrs, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantSelector(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variant string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "plain", variant: "desktop", want: `[data-variant="desktop"]`},
|
||||||
|
{name: "escaped", variant: `desk"top\` + "\n" + `line`, want: `[data-variant="desk\"top\\\a line"]`},
|
||||||
|
{name: "control char", variant: "tab\tname", want: `[data-variant="tab\9 name"]`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := VariantSelector(tt.variant)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("VariantSelector(%q) = %q, want %q", tt.variant, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeVariant(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variant string
|
||||||
|
selector string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "scope", variant: "desktop", selector: ".nav", want: `[data-variant="desktop"] .nav`},
|
||||||
|
{name: "empty selector", variant: "mobile", selector: "", want: `[data-variant="mobile"]`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ScopeVariant(tt.variant, tt.selector)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("ScopeVariant(%q, %q) = %q, want %q", tt.variant, tt.selector, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeVariant_MultipleSelectors(t *testing.T) {
|
||||||
|
got := ScopeVariant("desktop", ".nav, .sidebar")
|
||||||
|
want := `[data-variant="desktop"] .nav, [data-variant="desktop"] .sidebar`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ScopeVariant with selector list = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeVariant_IgnoresEmptySelectorSegments(t *testing.T) {
|
||||||
|
got := ScopeVariant("desktop", ".nav, , .sidebar,")
|
||||||
|
want := `[data-variant="desktop"] .nav, [data-variant="desktop"] .sidebar`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ScopeVariant should skip empty selector segments = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeVariant_PreservesNestedCommas(t *testing.T) {
|
||||||
|
got := ScopeVariant("desktop", `:is(.nav, .sidebar), .footer`)
|
||||||
|
want := `[data-variant="desktop"] :is(.nav, .sidebar), [data-variant="desktop"] .footer`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ScopeVariant should preserve nested commas = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeVariant_PreservesEscapedSelectorCharacters(t *testing.T) {
|
||||||
|
got := ScopeVariant("desktop", `.nav\,primary, [data-state="open\,expanded"]`)
|
||||||
|
want := `[data-variant="desktop"] .nav\,primary, [data-variant="desktop"] [data-state="open\,expanded"]`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ScopeVariant should preserve escaped selector characters = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# main
|
|
||||||
**Import:** `dappco.re/go/core/html/cmd/codegen`
|
|
||||||
**Files:** 1
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# main
|
|
||||||
**Import:** `dappco.re/go/core/html/cmd/wasm`
|
|
||||||
**Files:** 2
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# codegen
|
|
||||||
**Import:** `dappco.re/go/core/html/codegen`
|
|
||||||
**Files:** 2
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### `GenerateBundle`
|
|
||||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
|
||||||
|
|
||||||
GenerateBundle produces all WC class definitions and registrations
|
|
||||||
for a set of HLCRF slot assignments.
|
|
||||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
|
||||||
|
|
||||||
### `GenerateClass`
|
|
||||||
`func GenerateClass(tag, slot string) (string, error)`
|
|
||||||
|
|
||||||
GenerateClass produces a JS class definition for a custom element.
|
|
||||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
|
||||||
|
|
||||||
### `GenerateRegistration`
|
|
||||||
`func GenerateRegistration(tag, className string) string`
|
|
||||||
|
|
||||||
GenerateRegistration produces the customElements.define() call.
|
|
||||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
|
||||||
|
|
||||||
### `TagToClassName`
|
|
||||||
`func TagToClassName(tag string) string`
|
|
||||||
|
|
||||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
|
||||||
Usage example: className := TagToClassName("nav-bar")
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# codegen
|
|
||||||
**Import:** `dappco.re/go/core/html/codegen`
|
|
||||||
**Files:** 2
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### `GenerateBundle`
|
|
||||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
|
||||||
|
|
||||||
GenerateBundle produces all WC class definitions and registrations
|
|
||||||
for a set of HLCRF slot assignments.
|
|
||||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
|
||||||
|
|
||||||
### `GenerateClass`
|
|
||||||
`func GenerateClass(tag, slot string) (string, error)`
|
|
||||||
|
|
||||||
GenerateClass produces a JS class definition for a custom element.
|
|
||||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
|
||||||
|
|
||||||
### `GenerateRegistration`
|
|
||||||
`func GenerateRegistration(tag, className string) string`
|
|
||||||
|
|
||||||
GenerateRegistration produces the customElements.define() call.
|
|
||||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
|
||||||
|
|
||||||
### `TagToClassName`
|
|
||||||
`func TagToClassName(tag string) string`
|
|
||||||
|
|
||||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
|
||||||
Usage example: className := TagToClassName("nav-bar")
|
|
||||||
225
specs/root.md
225
specs/root.md
|
|
@ -1,225 +0,0 @@
|
||||||
# html
|
|
||||||
**Import:** `dappco.re/go/core/html`
|
|
||||||
**Files:** 13
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### `Context`
|
|
||||||
`type Context struct`
|
|
||||||
|
|
||||||
Context carries rendering state through the node tree.
|
|
||||||
Usage example: ctx := NewContext()
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Identity string`
|
|
||||||
- `Locale string`
|
|
||||||
- `Entitlements func(feature string) bool`
|
|
||||||
- `Data map[string]any`
|
|
||||||
- Unexported fields are present.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
None.
|
|
||||||
|
|
||||||
### `Layout`
|
|
||||||
`type Layout struct`
|
|
||||||
|
|
||||||
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
|
||||||
with deterministic path-based IDs.
|
|
||||||
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- No exported fields.
|
|
||||||
- Unexported fields are present.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `func (l *Layout) C(nodes ...Node) *Layout`
|
|
||||||
C appends nodes to the Content (main) slot.
|
|
||||||
Usage example: NewLayout("C").C(Text("body"))
|
|
||||||
- `func (l *Layout) F(nodes ...Node) *Layout`
|
|
||||||
F appends nodes to the Footer slot.
|
|
||||||
Usage example: NewLayout("CF").F(Text("footer"))
|
|
||||||
- `func (l *Layout) H(nodes ...Node) *Layout`
|
|
||||||
H appends nodes to the Header slot.
|
|
||||||
Usage example: NewLayout("HCF").H(Text("title"))
|
|
||||||
- `func (l *Layout) L(nodes ...Node) *Layout`
|
|
||||||
L appends nodes to the Left aside slot.
|
|
||||||
Usage example: NewLayout("LC").L(Text("nav"))
|
|
||||||
- `func (l *Layout) R(nodes ...Node) *Layout`
|
|
||||||
R appends nodes to the Right aside slot.
|
|
||||||
Usage example: NewLayout("CR").R(Text("ads"))
|
|
||||||
- `func (l *Layout) Render(ctx *Context) string`
|
|
||||||
Render produces the semantic HTML for this layout.
|
|
||||||
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
|
||||||
Only slots present in the variant string are rendered.
|
|
||||||
|
|
||||||
### `Node`
|
|
||||||
`type Node interface`
|
|
||||||
|
|
||||||
Node is anything renderable.
|
|
||||||
Usage example: var n Node = El("div", Text("welcome"))
|
|
||||||
|
|
||||||
Members:
|
|
||||||
- `Render(ctx *Context) string`
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
None.
|
|
||||||
|
|
||||||
### `Responsive`
|
|
||||||
`type Responsive struct`
|
|
||||||
|
|
||||||
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
|
||||||
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
|
||||||
Each variant is rendered inside a container with data-variant for CSS targeting.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- No exported fields.
|
|
||||||
- Unexported fields are present.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `func (r *Responsive) Render(ctx *Context) string`
|
|
||||||
Render produces HTML with each variant in a data-variant container.
|
|
||||||
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
|
||||||
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
|
|
||||||
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
|
||||||
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
|
||||||
Variants render in insertion order.
|
|
||||||
|
|
||||||
### `Translator`
|
|
||||||
`type Translator interface`
|
|
||||||
|
|
||||||
Translator provides Text() lookups for a rendering context.
|
|
||||||
Usage example: ctx := NewContextWithService(myTranslator)
|
|
||||||
|
|
||||||
The default server build uses go-i18n. Alternate builds, including WASM,
|
|
||||||
can provide any implementation with the same T() method.
|
|
||||||
|
|
||||||
Members:
|
|
||||||
- `T(key string, args ...any) string`
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### `Attr`
|
|
||||||
`func Attr(n Node, key, value string) Node`
|
|
||||||
|
|
||||||
Attr sets an attribute on an El node. Returns the node for chaining.
|
|
||||||
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
|
||||||
It recursively traverses through wrappers like If, Unless, and Entitled.
|
|
||||||
|
|
||||||
### `CompareVariants`
|
|
||||||
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
|
|
||||||
|
|
||||||
CompareVariants runs the imprint pipeline on each responsive variant independently
|
|
||||||
and returns pairwise similarity scores. Key format: "name1:name2".
|
|
||||||
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
|
||||||
|
|
||||||
### `Each`
|
|
||||||
`func Each[T any](items []T, fn func(T) Node) Node`
|
|
||||||
|
|
||||||
Each iterates items and renders each via fn.
|
|
||||||
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
|
||||||
|
|
||||||
### `EachSeq`
|
|
||||||
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
|
|
||||||
|
|
||||||
EachSeq iterates an iter.Seq and renders each via fn.
|
|
||||||
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
|
||||||
|
|
||||||
### `El`
|
|
||||||
`func El(tag string, children ...Node) Node`
|
|
||||||
|
|
||||||
El creates an HTML element node with children.
|
|
||||||
Usage example: El("section", Text("welcome"))
|
|
||||||
|
|
||||||
### `Entitled`
|
|
||||||
`func Entitled(feature string, node Node) Node`
|
|
||||||
|
|
||||||
Entitled renders child only when entitlement is granted. Absent, not hidden.
|
|
||||||
Usage example: Entitled("beta", Text("preview"))
|
|
||||||
If no entitlement function is set on the context, access is denied by default.
|
|
||||||
|
|
||||||
### `If`
|
|
||||||
`func If(cond func(*Context) bool, node Node) Node`
|
|
||||||
|
|
||||||
If renders child only when condition is true.
|
|
||||||
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
|
||||||
|
|
||||||
### `Imprint`
|
|
||||||
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
|
|
||||||
|
|
||||||
Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
|
||||||
and returns a GrammarImprint — the full render-reverse pipeline.
|
|
||||||
Usage example: imp := Imprint(Text("welcome"), NewContext())
|
|
||||||
|
|
||||||
### `NewContext`
|
|
||||||
`func NewContext() *Context`
|
|
||||||
|
|
||||||
NewContext creates a new rendering context with sensible defaults.
|
|
||||||
Usage example: html := Render(Text("welcome"), NewContext())
|
|
||||||
|
|
||||||
### `NewContextWithService`
|
|
||||||
`func NewContextWithService(svc Translator) *Context`
|
|
||||||
|
|
||||||
NewContextWithService creates a rendering context backed by a specific translator.
|
|
||||||
Usage example: ctx := NewContextWithService(myTranslator)
|
|
||||||
|
|
||||||
### `NewLayout`
|
|
||||||
`func NewLayout(variant string) *Layout`
|
|
||||||
|
|
||||||
NewLayout creates a new Layout with the given variant string.
|
|
||||||
Usage example: page := NewLayout("HLCRF")
|
|
||||||
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
|
||||||
|
|
||||||
### `NewResponsive`
|
|
||||||
`func NewResponsive() *Responsive`
|
|
||||||
|
|
||||||
NewResponsive creates a new multi-variant responsive compositor.
|
|
||||||
Usage example: r := NewResponsive()
|
|
||||||
|
|
||||||
### `ParseBlockID`
|
|
||||||
`func ParseBlockID(id string) []byte`
|
|
||||||
|
|
||||||
ParseBlockID extracts the slot sequence from a data-block ID.
|
|
||||||
Usage example: slots := ParseBlockID("L-0-C-0")
|
|
||||||
"L-0-C-0" → ['L', 'C']
|
|
||||||
|
|
||||||
### `Raw`
|
|
||||||
`func Raw(content string) Node`
|
|
||||||
|
|
||||||
Raw creates a node that renders without escaping (escape hatch for trusted content).
|
|
||||||
Usage example: Raw("<strong>trusted</strong>")
|
|
||||||
|
|
||||||
### `Render`
|
|
||||||
`func Render(node Node, ctx *Context) string`
|
|
||||||
|
|
||||||
Render is a convenience function that renders a node tree to HTML.
|
|
||||||
Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
|
||||||
|
|
||||||
### `StripTags`
|
|
||||||
`func StripTags(html string) string`
|
|
||||||
|
|
||||||
StripTags removes HTML tags from rendered output, returning plain text.
|
|
||||||
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
|
||||||
Tag boundaries are collapsed into single spaces; result is trimmed.
|
|
||||||
Does not handle script/style element content (go-html does not generate these).
|
|
||||||
|
|
||||||
### `Switch`
|
|
||||||
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
|
|
||||||
|
|
||||||
Switch renders based on runtime selector value.
|
|
||||||
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
|
||||||
|
|
||||||
### `Text`
|
|
||||||
`func Text(key string, args ...any) Node`
|
|
||||||
|
|
||||||
Text creates a node that renders through the go-i18n grammar pipeline.
|
|
||||||
Usage example: Text("welcome", "Ada")
|
|
||||||
Output is HTML-escaped by default. Safe-by-default path.
|
|
||||||
|
|
||||||
### `Unless`
|
|
||||||
`func Unless(cond func(*Context) bool, node Node) Node`
|
|
||||||
|
|
||||||
Unless renders child only when condition is false.
|
|
||||||
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package html
|
|
||||||
|
|
||||||
import core "dappco.re/go/core"
|
|
||||||
|
|
||||||
func containsText(s, substr string) bool {
|
|
||||||
return core.Contains(s, substr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func countText(s, substr string) int {
|
|
||||||
if substr == "" {
|
|
||||||
return len(s) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for i := 0; i <= len(s)-len(substr); {
|
|
||||||
j := indexText(s[i:], substr)
|
|
||||||
if j < 0 {
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
i += j + len(substr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexText(s, substr string) int {
|
|
||||||
if substr == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if len(substr) > len(s) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i <= len(s)-len(substr); i++ {
|
|
||||||
if s[i:i+len(substr)] == substr {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoaText(v int) string {
|
|
||||||
return core.Sprint(v)
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package html
|
|
||||||
|
|
||||||
import core "dappco.re/go/core"
|
|
||||||
|
|
||||||
type builderOps interface {
|
|
||||||
WriteByte(byte) error
|
|
||||||
WriteRune(rune) (int, error)
|
|
||||||
WriteString(string) (int, error)
|
|
||||||
String() string
|
|
||||||
}
|
|
||||||
|
|
||||||
type textBuilder struct {
|
|
||||||
inner builderOps
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTextBuilder() *textBuilder {
|
|
||||||
return &textBuilder{inner: core.NewBuilder()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteByte(c byte) error {
|
|
||||||
return b.inner.WriteByte(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
|
||||||
return b.inner.WriteRune(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
|
||||||
return b.inner.WriteString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) String() string {
|
|
||||||
return b.inner.String()
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
//go:build js
|
|
||||||
|
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package html
|
|
||||||
|
|
||||||
type textBuilder struct {
|
|
||||||
buf []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTextBuilder() *textBuilder {
|
|
||||||
return &textBuilder{buf: make([]byte, 0, 128)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteByte(c byte) error {
|
|
||||||
b.buf = append(b.buf, c)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
|
||||||
s := string(r)
|
|
||||||
b.buf = append(b.buf, s...)
|
|
||||||
return len(s), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
|
||||||
b.buf = append(b.buf, s...)
|
|
||||||
return len(s), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *textBuilder) String() string {
|
|
||||||
return string(b.buf)
|
|
||||||
}
|
|
||||||
28
translator_clone_default.go
Normal file
28
translator_clone_default.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import i18n "dappco.re/go/core/i18n"
|
||||||
|
|
||||||
|
func cloneTranslator(svc Translator, locale string) Translator {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
|
||||||
|
if clone := cloner.Clone(); clone != nil {
|
||||||
|
applyLocaleToService(clone, locale)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current, ok := svc.(*i18n.Service); ok && current != nil {
|
||||||
|
clone := &i18n.Service{}
|
||||||
|
applyLocaleToService(clone, locale)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
25
translator_clone_js.go
Normal file
25
translator_clone_js.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
func cloneTranslator(svc Translator, locale string) Translator {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
|
||||||
|
if clone := cloner.Clone(); clone != nil {
|
||||||
|
applyLocaleToService(clone, locale)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current, ok := svc.(*defaultTranslator); ok && current != nil {
|
||||||
|
clone := *current
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue