Compare commits
46 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a924b0be4 | ||
|
|
f543f02cc1 | ||
|
|
8402485489 | ||
|
|
5784b76990 | ||
|
|
70a3096518 | ||
|
|
8abd428227 | ||
|
|
c088e5a5ac | ||
|
|
1d11472136 | ||
|
|
2e2af31c1d | ||
|
|
b9e2630da3 | ||
|
|
c2ff591ec9 | ||
|
|
60d8225a83 | ||
|
|
8e9ca0091c | ||
|
|
cb901dbb71 | ||
|
|
4a3a69e8b7 | ||
|
|
14c16b5385 | ||
|
|
1f98026d04 | ||
|
|
8386c7e57d | ||
|
|
5d13a4028b | ||
|
|
a928d01b9e | ||
|
|
12a7d2497b | ||
|
|
c63f0a2cbe | ||
|
|
c1852f86aa | ||
|
|
4ae93ce36f | ||
|
|
65c0dd3e27 | ||
|
|
f9f0aa197b | ||
|
|
714d7adc90 | ||
|
|
911071d2b0 | ||
|
|
c6fd135239 | ||
|
|
cae46f9c61 | ||
|
|
0318d73a12 | ||
| 8c7a9de546 | |||
|
|
33d9e0c516 | ||
| adc9403883 | |||
|
|
f21562c555 | ||
|
|
adcb98ee2f | ||
| 44e3478be0 | |||
|
|
11f18a24d2 | ||
| 1c61fde5fc | |||
|
|
df5035c3c4 | ||
| df19b84051 | |||
|
|
3616ad3a76 | ||
| 2a5bd5cbba | |||
|
|
b8d06460d6 | ||
|
|
0e976b3a87 | ||
|
|
8a3f28aff3 |
48 changed files with 2163 additions and 314 deletions
|
|
@ -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), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||||
|
|
||||||
**Module**: `forge.lthn.ai/core/go-html`
|
**Module**: `forge.lthn.ai/core/go-html`
|
||||||
**Licence**: EUPL-1.2
|
**Licence**: EUPL-1.2
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
|
@ -100,7 +99,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] = fmt.Sprintf("Item %d was created successfully", i)
|
items[i] = "Item " + itoaText(i) + " was created successfully"
|
||||||
}
|
}
|
||||||
page := NewLayout("HLCRF").
|
page := NewLayout("HLCRF").
|
||||||
H(El("h1", Text("Building project"))).
|
H(El("h1", Text("Building project"))).
|
||||||
|
|
@ -207,7 +206,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(fmt.Sprintf("paragraph %d", i)))
|
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
|
||||||
}
|
}
|
||||||
layout := NewLayout("HLCRF").
|
layout := NewLayout("HLCRF").
|
||||||
H(Raw("header")).
|
H(Raw("header")).
|
||||||
|
|
@ -242,7 +241,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(fmt.Sprintf("item-%d", i)))
|
return El("li", Raw("item-"+itoaText(i)))
|
||||||
})
|
})
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,181 @@
|
||||||
// Package main provides a build-time CLI for generating Web Component JS bundles.
|
//go:build !js
|
||||||
// Reads a JSON slot map from stdin, writes the generated JS to stdout.
|
|
||||||
|
// Package main provides a build-time CLI for generating Web Component bundles.
|
||||||
|
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
|
||||||
//
|
//
|
||||||
// Usage:
|
// 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/ -watch -input slots.json -output components.js
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
goio "io"
|
goio "io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"dappco.re/go/core/html/codegen"
|
"dappco.re/go/core/html/codegen"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
log "dappco.re/go/core/log"
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func run(r goio.Reader, w goio.Writer) error {
|
func generate(data []byte, emitTypes bool) (string, error) {
|
||||||
|
var slots map[string]string
|
||||||
|
if result := core.JSONUnmarshal(data, &slots); !result.OK {
|
||||||
|
err, _ := result.Value.(error)
|
||||||
|
return "", log.E("codegen", "invalid JSON", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if emitTypes {
|
||||||
|
return codegen.GenerateTypeScriptDefinitions(slots), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := codegen.GenerateBundle(slots)
|
||||||
|
if err != nil {
|
||||||
|
return "", log.E("codegen", "generate bundle", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
|
||||||
data, err := goio.ReadAll(r)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
var slots map[string]string
|
out, err := generate(data, emitTypes)
|
||||||
if err := json.Unmarshal(data, &slots); err != nil {
|
|
||||||
return log.E("codegen", "invalid JSON", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
js, err := codegen.GenerateBundle(slots)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = goio.WriteString(w, js)
|
_, err = goio.WriteString(w, out)
|
||||||
|
if err != nil {
|
||||||
|
return log.E("codegen", "writing output", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
|
||||||
|
if inputPath == "" {
|
||||||
|
return log.E("codegen", "watch mode requires -input", nil)
|
||||||
|
}
|
||||||
|
if outputPath == "" {
|
||||||
|
return log.E("codegen", "watch mode requires -output", nil)
|
||||||
|
}
|
||||||
|
if pollInterval <= 0 {
|
||||||
|
pollInterval = 250 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastInput []byte
|
||||||
|
for {
|
||||||
|
input, err := readLocalFile(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return log.E("codegen", "reading input file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sameBytes(input, lastInput) {
|
||||||
|
out, err := generate(input, emitTypes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeLocalFile(outputPath, out); err != nil {
|
||||||
|
return log.E("codegen", "writing output file", err)
|
||||||
|
}
|
||||||
|
lastInput = append(lastInput[:0], input...)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLocalFile(path string) ([]byte, error) {
|
||||||
|
f, err := coreio.Local.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return goio.ReadAll(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLocalFile(path, content string) error {
|
||||||
|
f, err := coreio.Local.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = goio.WriteString(f, content)
|
||||||
return err
|
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() {
|
||||||
if err := run(os.Stdin, os.Stdout); err != nil {
|
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
|
||||||
log.Error("codegen failed", "err", err)
|
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
|
||||||
|
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
|
||||||
|
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
|
||||||
|
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *emitWatch {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
|
||||||
|
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := coreio.Local.Open("/dev/stdin")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := coreio.Local.Create("/dev/stdout")
|
||||||
|
if err != nil {
|
||||||
|
_ = stdin.Close()
|
||||||
|
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = stdin.Close()
|
||||||
|
_ = stdout.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := run(stdin, stdout, *emitTypes); err != nil {
|
||||||
|
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,179 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
|
goio "io"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"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_Good(t *testing.T) {
|
func TestRun_WritesBundle_Good(t *testing.T) {
|
||||||
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||||
var output bytes.Buffer
|
output := core.NewBuilder()
|
||||||
|
|
||||||
err := run(input, &output)
|
err := run(input, output, false)
|
||||||
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, strings.Count(js, "extends HTMLElement"))
|
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_Bad_InvalidJSON(t *testing.T) {
|
func TestRun_InvalidJSON_Bad(t *testing.T) {
|
||||||
input := strings.NewReader(`not json`)
|
input := core.NewReader(`not json`)
|
||||||
var output bytes.Buffer
|
output := core.NewBuilder()
|
||||||
|
|
||||||
err := run(input, &output)
|
err := run(input, output, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid JSON")
|
assert.Contains(t, err.Error(), "invalid JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_Bad_InvalidTag(t *testing.T) {
|
func TestRun_InvalidTag_Bad(t *testing.T) {
|
||||||
input := strings.NewReader(`{"H":"notag"}`)
|
input := core.NewReader(`{"H":"notag"}`)
|
||||||
var output bytes.Buffer
|
output := core.NewBuilder()
|
||||||
|
|
||||||
err := run(input, &output)
|
err := run(input, output, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "hyphen")
|
assert.Contains(t, err.Error(), "hyphen")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_Good_Empty(t *testing.T) {
|
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
|
||||||
input := strings.NewReader(`{}`)
|
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
|
||||||
var output bytes.Buffer
|
output := core.NewBuilder()
|
||||||
|
|
||||||
err := run(input, &output)
|
err := run(input, output, false)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "lowercase hyphenated name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_EmptySlots_Good(t *testing.T) {
|
||||||
|
input := core.NewReader(`{}`)
|
||||||
|
output := core.NewBuilder()
|
||||||
|
|
||||||
|
err := run(input, output, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, output.String())
|
assert.Empty(t, output.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
|
||||||
|
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||||
|
output := core.NewBuilder()
|
||||||
|
|
||||||
|
err := run(input, output, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dts := output.String()
|
||||||
|
assert.Contains(t, dts, "declare global")
|
||||||
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
|
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||||
|
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
||||||
|
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inputPath := filepath.Join(dir, "slots.json")
|
||||||
|
outputPath := filepath.Join(dir, "bundle.js")
|
||||||
|
|
||||||
|
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
got, err := readTextFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
|
||||||
|
}, time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
|
||||||
|
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "watch mode requires -input")
|
||||||
|
}
|
||||||
|
|
||||||
|
func countSubstr(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return len(s) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := 0; i <= len(s)-len(substr); {
|
||||||
|
j := indexSubstr(s[i:], substr)
|
||||||
|
if j < 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
i += j + len(substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexSubstr(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(substr) > len(s) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTextFile(path, content string) error {
|
||||||
|
f, err := coreio.Local.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = goio.WriteString(f, content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTextFile(path string) (string, error) {
|
||||||
|
f, err := coreio.Local.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,19 @@ 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 {
|
if len(args) < 1 || args[0].Type() != js.TypeString {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
variant := args[0].String()
|
variant := args[0].String()
|
||||||
|
if variant == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
ctx := html.NewContext()
|
ctx := html.NewContext()
|
||||||
|
|
||||||
if len(args) >= 2 {
|
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
||||||
ctx.Locale = args[1].String()
|
ctx.SetLocale(args[1].String())
|
||||||
}
|
}
|
||||||
|
|
||||||
layout := html.NewLayout(variant)
|
layout := html.NewLayout(variant)
|
||||||
|
|
|
||||||
55
cmd/wasm/main_test.go
Normal file
55
cmd/wasm/main_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"syscall/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderToString_Good(t *testing.T) {
|
||||||
|
gotAny := renderToString(js.Value{}, []js.Value{
|
||||||
|
js.ValueOf("C"),
|
||||||
|
js.ValueOf("en-GB"),
|
||||||
|
js.ValueOf(map[string]any{"C": "<strong>hello</strong>"}),
|
||||||
|
})
|
||||||
|
|
||||||
|
got, ok := gotAny.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `<main role="main" data-block="C-0"><strong>hello</strong></main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderToString(...) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderToString_VariantTypeGuard(t *testing.T) {
|
||||||
|
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
|
||||||
|
t.Fatalf("non-string variant should be empty, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
|
||||||
|
t.Fatalf("missing variant should be empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
|
||||||
|
gotAny := renderToString(js.Value{}, []js.Value{
|
||||||
|
js.ValueOf("C"),
|
||||||
|
js.ValueOf(123),
|
||||||
|
js.ValueOf(map[string]any{"C": "x"}),
|
||||||
|
})
|
||||||
|
|
||||||
|
got, ok := gotAny.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `<main role="main" data-block="C-0">x</main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
core "dappco.re/go/core"
|
||||||
|
|
||||||
"dappco.re/go/core/html/codegen"
|
"dappco.re/go/core/html/codegen"
|
||||||
log "dappco.re/go/core/log"
|
log "dappco.re/go/core/log"
|
||||||
|
|
@ -15,7 +15,8 @@ import (
|
||||||
// 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 err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
|
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
|
||||||
|
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_Good(t *testing.T) {
|
func TestBuildComponentJS_ValidJSON_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,7 @@ func TestBuildComponentJS_Good(t *testing.T) {
|
||||||
assert.Contains(t, js, "customElements.define")
|
assert.Contains(t, js, "customElements.define")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
|
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
|
||||||
_, err := buildComponentJS("not json")
|
_, err := buildComponentJS("not json")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"os"
|
"context"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
@ -21,34 +20,44 @@ const (
|
||||||
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWASMBinarySize_Good(t *testing.T) {
|
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
|
||||||
if testing.Short() {
|
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 := filepath.Join(dir, "gohtml.wasm")
|
out := core.Path(dir, "gohtml.wasm")
|
||||||
|
|
||||||
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
|
factory := process.NewService(process.Options{})
|
||||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
serviceValue, err := factory(core.New())
|
||||||
output, err := cmd.CombinedOutput()
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, ok := serviceValue.(*process.Service)
|
||||||
|
require.True(t, ok, "process service factory returned %T", serviceValue)
|
||||||
|
|
||||||
|
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
|
||||||
|
Command: "go",
|
||||||
|
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
|
||||||
|
Dir: ".",
|
||||||
|
Env: []string{"GOOS=js", "GOARCH=wasm"},
|
||||||
|
})
|
||||||
require.NoError(t, err, "WASM build failed: %s", output)
|
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)
|
||||||
raw := []byte(rawStr)
|
rawBytes := []byte(rawStr)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
buf := core.NewBuilder()
|
||||||
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(raw)
|
_, err = gz.Write(rawBytes)
|
||||||
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(raw), buf.Len())
|
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), 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(raw), wasmRawLimit,
|
assert.Less(t, len(rawBytes), wasmRawLimit,
|
||||||
"WASM raw size %d exceeds 3MB limit", len(raw))
|
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,40 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"sort"
|
||||||
"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.
|
||||||
|
// The generator rejects values that would fail at customElements.define() time.
|
||||||
|
func isValidCustomElementTag(tag string) bool {
|
||||||
|
if tag == "" || !core.Contains(tag, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag[0] < 'a' || tag[0] > 'z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range len(tag) {
|
||||||
|
ch := tag[i]
|
||||||
|
switch {
|
||||||
|
case ch >= 'a' && ch <= 'z':
|
||||||
|
case ch >= '0' && ch <= '9':
|
||||||
|
case ch == '-' || ch == '.' || ch == '_':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// wcTemplate is the Web Component class template.
|
// wcTemplate is the Web Component class template.
|
||||||
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
|
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
|
||||||
// DOM API using trusted go-html codegen output (never user input).
|
// DOM API using trusted go-html codegen output (never user input).
|
||||||
|
|
@ -31,12 +58,13 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
|
||||||
}`))
|
}`))
|
||||||
|
|
||||||
// GenerateClass produces a JS class definition for a custom element.
|
// GenerateClass produces a JS class definition for a custom element.
|
||||||
|
// Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||||
func GenerateClass(tag, slot string) (string, error) {
|
func GenerateClass(tag, slot string) (string, error) {
|
||||||
if !strings.Contains(tag, "-") {
|
if !isValidCustomElementTag(tag) {
|
||||||
return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil)
|
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
err := wcTemplate.Execute(&b, struct {
|
err := wcTemplate.Execute(b, struct {
|
||||||
ClassName, Tag, Slot string
|
ClassName, Tag, Slot string
|
||||||
}{
|
}{
|
||||||
ClassName: TagToClassName(tag),
|
ClassName: TagToClassName(tag),
|
||||||
|
|
@ -50,16 +78,18 @@ func GenerateClass(tag, slot string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRegistration produces the customElements.define() call.
|
// GenerateRegistration produces the customElements.define() call.
|
||||||
|
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||||
func GenerateRegistration(tag, className string) string {
|
func GenerateRegistration(tag, className string) string {
|
||||||
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
|
return `customElements.define("` + tag + `", ` + className + `);`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||||
|
// Usage example: className := TagToClassName("nav-bar")
|
||||||
func TagToClassName(tag string) string {
|
func TagToClassName(tag string) string {
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
for p := range strings.SplitSeq(tag, "-") {
|
for _, p := range core.Split(tag, "-") {
|
||||||
if len(p) > 0 {
|
if len(p) > 0 {
|
||||||
b.WriteString(strings.ToUpper(p[:1]))
|
b.WriteString(core.Upper(p[:1]))
|
||||||
b.WriteString(p[1:])
|
b.WriteString(p[1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,11 +98,18 @@ func TagToClassName(tag string) string {
|
||||||
|
|
||||||
// GenerateBundle produces all WC class definitions and registrations
|
// 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"})
|
||||||
func GenerateBundle(slots map[string]string) (string, error) {
|
func GenerateBundle(slots map[string]string) (string, error) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
|
keys := make([]string, 0, len(slots))
|
||||||
|
for slot := range slots {
|
||||||
|
keys = append(keys, slot)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
for slot, tag := range slots {
|
for _, slot := range keys {
|
||||||
|
tag := slots[slot]
|
||||||
if seen[tag] {
|
if seen[tag] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
|
||||||
|
|
||||||
cls, err := GenerateClass(tag, slot)
|
cls, err := GenerateClass(tag, slot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
||||||
}
|
}
|
||||||
b.WriteString(cls)
|
b.WriteString(cls)
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
package codegen
|
package codegen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -8,7 +10,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateClass_Good(t *testing.T) {
|
func TestGenerateClass_ValidTag_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")
|
||||||
|
|
@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) {
|
||||||
assert.Contains(t, js, "photo-grid")
|
assert.Contains(t, js, "photo-grid")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
|
func TestGenerateClass_InvalidTag_Bad(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")
|
||||||
|
|
||||||
|
_, err = GenerateClass("Nav-Bar", "C")
|
||||||
|
assert.Error(t, err, "custom element names must be lowercase")
|
||||||
|
|
||||||
|
_, err = GenerateClass("nav bar", "C")
|
||||||
|
assert.Error(t, err, "custom element names must reject spaces")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRegistration_Good(t *testing.T) {
|
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
|
||||||
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
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_Good(t *testing.T) {
|
func TestTagToClassName_KebabCase_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"},
|
||||||
|
|
@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateBundle_Good(t *testing.T) {
|
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
|
||||||
slots := map[string]string{
|
slots := map[string]string{
|
||||||
"H": "nav-bar",
|
"H": "nav-bar",
|
||||||
"C": "main-content",
|
"C": "main-content",
|
||||||
|
"F": "nav-bar",
|
||||||
}
|
}
|
||||||
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, strings.Count(js, "extends HTMLElement"))
|
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||||
|
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"Z": "zed-panel",
|
||||||
|
"A": "alpha-panel",
|
||||||
|
"M": "main-content",
|
||||||
|
}
|
||||||
|
|
||||||
|
js, err := GenerateBundle(slots)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
alpha := strings.Index(js, "class AlphaPanel")
|
||||||
|
main := strings.Index(js, "class MainContent")
|
||||||
|
zed := strings.Index(js, "class ZedPanel")
|
||||||
|
|
||||||
|
assert.NotEqual(t, -1, alpha)
|
||||||
|
assert.NotEqual(t, -1, main)
|
||||||
|
assert.NotEqual(t, -1, zed)
|
||||||
|
assert.Less(t, alpha, main)
|
||||||
|
assert.Less(t, main, zed)
|
||||||
|
assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
|
||||||
|
assert.Equal(t, 3, countSubstr(js, "customElements.define"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"Z": "zed-panel",
|
||||||
|
"A": "alpha-panel",
|
||||||
|
"M": "alpha-panel",
|
||||||
|
}
|
||||||
|
|
||||||
|
dts := GenerateTypeScriptDefinitions(slots)
|
||||||
|
|
||||||
|
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
|
||||||
|
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
|
||||||
|
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
|
||||||
|
assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
|
||||||
|
assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
|
||||||
|
assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
|
||||||
|
assert.Contains(t, dts, "export {};")
|
||||||
|
assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
|
||||||
|
slots := map[string]string{
|
||||||
|
"H": "nav-bar",
|
||||||
|
"C": "Nav-Bar",
|
||||||
|
"F": "nav bar",
|
||||||
|
}
|
||||||
|
|
||||||
|
dts := GenerateTypeScriptDefinitions(slots)
|
||||||
|
|
||||||
|
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||||
|
assert.NotContains(t, dts, "Nav-Bar")
|
||||||
|
assert.NotContains(t, dts, "nav bar")
|
||||||
|
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func countSubstr(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return len(s) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := 0; i <= len(s)-len(substr); {
|
||||||
|
j := indexSubstr(s[i:], substr)
|
||||||
|
if j < 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
i += j + len(substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexSubstr(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(substr) > len(s) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
codegen/doc.go
Normal file
13
codegen/doc.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
// Package codegen generates Web Component bundles for go-html slot maps.
|
||||||
|
//
|
||||||
|
// Use it at build time, or through the cmd/codegen CLI:
|
||||||
|
//
|
||||||
|
// bundle, err := GenerateBundle(map[string]string{
|
||||||
|
// "H": "site-header",
|
||||||
|
// "C": "app-main",
|
||||||
|
// })
|
||||||
|
package codegen
|
||||||
61
codegen/typescript.go
Normal file
61
codegen/typescript.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package codegen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
|
||||||
|
// a set of custom elements generated from HLCRF slot assignments.
|
||||||
|
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
|
||||||
|
func GenerateTypeScriptDefinitions(slots map[string]string) string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
declared := make(map[string]bool)
|
||||||
|
b := core.NewBuilder()
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(slots))
|
||||||
|
for slot := range slots {
|
||||||
|
keys = append(keys, slot)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
b.WriteString("declare global {\n")
|
||||||
|
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
||||||
|
for _, slot := range keys {
|
||||||
|
tag := slots[slot]
|
||||||
|
if !isValidCustomElementTag(tag) || seen[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[tag] = true
|
||||||
|
b.WriteString(" \"")
|
||||||
|
b.WriteString(tag)
|
||||||
|
b.WriteString("\": ")
|
||||||
|
b.WriteString(TagToClassName(tag))
|
||||||
|
b.WriteString(";\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" }\n")
|
||||||
|
b.WriteString("}\n\n")
|
||||||
|
|
||||||
|
for _, slot := range keys {
|
||||||
|
tag := slots[slot]
|
||||||
|
if !seen[tag] || declared[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declared[tag] = true
|
||||||
|
b.WriteString("export declare class ")
|
||||||
|
b.WriteString(TagToClassName(tag))
|
||||||
|
b.WriteString(" extends HTMLElement {\n")
|
||||||
|
b.WriteString(" connectedCallback(): void;\n")
|
||||||
|
b.WriteString(" render(html: string): void;\n")
|
||||||
|
b.WriteString("}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("export {};\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
74
context.go
74
context.go
|
|
@ -1,27 +1,81 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import i18n "dappco.re/go/core/i18n"
|
// Translator provides Text() lookups for a rendering context.
|
||||||
|
// Usage example: ctx := NewContextWithService(myTranslator)
|
||||||
|
//
|
||||||
|
// The default server build uses go-i18n. Alternate builds, including WASM,
|
||||||
|
// can provide any implementation with the same T() method.
|
||||||
|
type Translator interface {
|
||||||
|
T(key string, args ...any) string
|
||||||
|
}
|
||||||
|
|
||||||
// Context carries rendering state through the node tree.
|
// Context carries rendering state through the node tree.
|
||||||
|
// Usage example: ctx := NewContext()
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Identity string
|
Identity string
|
||||||
Locale string
|
Locale string
|
||||||
Entitlements func(feature string) bool
|
Entitlements func(feature string) bool
|
||||||
Data map[string]any
|
Data map[string]any
|
||||||
service *i18n.Service
|
service Translator
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyLocaleToService(svc Translator, locale string) {
|
||||||
|
if svc == nil || locale == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
|
||||||
|
base := locale
|
||||||
|
for i := 0; i < len(base); i++ {
|
||||||
|
if base[i] == '-' || base[i] == '_' {
|
||||||
|
base = base[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = setter.SetLanguage(base)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContext creates a new rendering context with sensible defaults.
|
// NewContext creates a new rendering context with sensible defaults.
|
||||||
func NewContext() *Context {
|
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
|
||||||
return &Context{
|
func NewContext(locale ...string) *Context {
|
||||||
|
ctx := &Context{
|
||||||
Data: make(map[string]any),
|
Data: make(map[string]any),
|
||||||
}
|
}
|
||||||
|
if len(locale) > 0 {
|
||||||
|
ctx.SetLocale(locale[0])
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContextWithService creates a rendering context backed by a specific i18n service.
|
// NewContextWithService creates a rendering context backed by a specific translator.
|
||||||
func NewContextWithService(svc *i18n.Service) *Context {
|
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
|
||||||
return &Context{
|
func NewContextWithService(svc Translator, locale ...string) *Context {
|
||||||
Data: make(map[string]any),
|
ctx := NewContext(locale...)
|
||||||
service: svc,
|
ctx.SetService(svc)
|
||||||
}
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetService swaps the translator used by the context.
|
||||||
|
// Usage example: ctx.SetService(myTranslator)
|
||||||
|
func (ctx *Context) SetService(svc Translator) *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.service = svc
|
||||||
|
applyLocaleToService(svc, ctx.Locale)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLocale updates the context locale and reapplies it to the active translator.
|
||||||
|
// Usage example: ctx.SetLocale("en-GB")
|
||||||
|
func (ctx *Context) SetLocale(locale string) *Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Locale = locale
|
||||||
|
applyLocaleToService(ctx.service, ctx.Locale)
|
||||||
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
context_test.go
Normal file
90
context_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewContext_OptionalLocale_Good(t *testing.T) {
|
||||||
|
ctx := NewContext("en-GB")
|
||||||
|
|
||||||
|
if ctx == nil {
|
||||||
|
t.Fatal("NewContext returned nil")
|
||||||
|
}
|
||||||
|
if ctx.Locale != "en-GB" {
|
||||||
|
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
|
||||||
|
}
|
||||||
|
if ctx.Data == nil {
|
||||||
|
t.Fatal("NewContext should initialise Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
ctx := NewContextWithService(svc, "fr-FR")
|
||||||
|
|
||||||
|
if ctx == nil {
|
||||||
|
t.Fatal("NewContextWithService returned nil")
|
||||||
|
}
|
||||||
|
if ctx.Locale != "fr-FR" {
|
||||||
|
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
|
||||||
|
}
|
||||||
|
if ctx.service == nil {
|
||||||
|
t.Fatal("NewContextWithService should set translator service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
ctx := NewContextWithService(svc, "fr-FR")
|
||||||
|
|
||||||
|
got := Text("prompt.yes").Render(ctx)
|
||||||
|
if got != "o" {
|
||||||
|
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
ctx := NewContext("fr-FR")
|
||||||
|
|
||||||
|
if got := ctx.SetService(svc); got != ctx {
|
||||||
|
t.Fatal("SetService should return the same context for chaining")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := Text("prompt.yes").Render(ctx)
|
||||||
|
if got != "o" {
|
||||||
|
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
|
||||||
|
var ctx *Context
|
||||||
|
if got := ctx.SetService(nil); got != nil {
|
||||||
|
t.Fatal("SetService on nil context should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
|
||||||
|
svc, _ := i18n.New()
|
||||||
|
ctx := NewContextWithService(svc)
|
||||||
|
|
||||||
|
if got := ctx.SetLocale("fr-FR"); got != ctx {
|
||||||
|
t.Fatal("SetLocale should return the same context for chaining")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := Text("prompt.yes").Render(ctx)
|
||||||
|
if got != "o" {
|
||||||
|
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
|
||||||
|
var ctx *Context
|
||||||
|
if got := ctx.SetLocale("en-GB"); got != nil {
|
||||||
|
t.Fatal("SetLocale on nil context should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
12
doc.go
Normal file
12
doc.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
// Package html renders semantic HTML from composable node trees.
|
||||||
|
//
|
||||||
|
// A typical page combines Layout, El, Text, and Render:
|
||||||
|
//
|
||||||
|
// page := NewLayout("HCF").
|
||||||
|
// H(El("h1", Text("page.title"))).
|
||||||
|
// C(El("main", Text("page.body"))).
|
||||||
|
// F(El("small", Text("page.footer")))
|
||||||
|
// out := Render(page, NewContext())
|
||||||
|
package html
|
||||||
|
|
@ -17,13 +17,18 @@ type Node interface {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers:
|
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
|
||||||
|
|
||||||
| Constructor | Behaviour |
|
| 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`, and `Entitled` wrappers. Returns the node for chaining. |
|
||||||
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
|
| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. |
|
||||||
|
| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. |
|
||||||
|
| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. |
|
||||||
|
| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. |
|
||||||
|
| `Role(Node, role)` | Convenience helper that sets `role` on an element node. |
|
||||||
|
| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. |
|
||||||
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
|
| `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. |
|
||||||
|
|
@ -50,16 +55,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 *i18n.Service // unexported; set via constructor
|
service Translator // 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 a specific `i18n.Service` instance.
|
- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
|
||||||
|
|
||||||
The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction.
|
The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean.
|
||||||
|
|
||||||
## HLCRF Layout
|
## HLCRF Layout
|
||||||
|
|
||||||
|
|
@ -161,6 +166,8 @@ html.NewResponsive().
|
||||||
|
|
||||||
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ go test ./cmd/codegen/
|
||||||
go test ./cmd/wasm/
|
go test ./cmd/wasm/
|
||||||
```
|
```
|
||||||
|
|
||||||
The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
|
The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
|
||||||
|
|
||||||
### Test Dependencies
|
### Test Dependencies
|
||||||
|
|
||||||
|
|
@ -145,6 +145,24 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
|
||||||
|
|
||||||
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
|
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
|
||||||
|
|
||||||
|
Pass `-types` to emit ambient TypeScript declarations instead of JavaScript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"H":"site-header","C":"app-content"}' \
|
||||||
|
| go run ./cmd/codegen/ -types \
|
||||||
|
> components.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, `-watch` polls an input JSON file and rewrites the
|
||||||
|
output file whenever the slot map changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/codegen/ \
|
||||||
|
-watch \
|
||||||
|
-input slots.json \
|
||||||
|
-output components.js
|
||||||
|
```
|
||||||
|
|
||||||
To test the CLI:
|
To test the CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -278,7 +296,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||||
### Codegen Tests with Testify
|
### Codegen Tests with Testify
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestGenerateClass_Good(t *testing.T) {
|
func TestGenerateClass_ValidTag(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")
|
||||||
|
|
@ -291,6 +309,6 @@ func TestGenerateClass_Good(t *testing.T) {
|
||||||
|
|
||||||
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
|
- `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 i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction.
|
- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services.
|
||||||
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
|
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
|
||||||
- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.
|
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.
|
||||||
|
|
|
||||||
|
|
@ -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_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||||
|
|
||||||
- Gzip size < 1,048,576 bytes (1 MB).
|
- Gzip size < 1,048,576 bytes (1 MB).
|
||||||
- Raw size < 3,145,728 bytes (3 MB).
|
- Raw size < 3,145,728 bytes (3 MB).
|
||||||
|
|
@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
|
||||||
|
|
||||||
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
||||||
|
|
||||||
4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
|
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
|
||||||
|
|
||||||
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
|
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
|
||||||
|
|
||||||
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
|
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
|
||||||
|
|
||||||
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
||||||
|
|
||||||
|
|
@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for
|
||||||
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
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.
|
||||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||||
|
- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
|
||||||
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
||||||
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ 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` |
|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, and `Role` helpers |
|
||||||
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
|
||||||
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) |
|
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
|
||||||
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
|
| `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 |
|
||||||
|
|
@ -52,11 +52,11 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
|
||||||
|
|
||||||
## 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, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`).
|
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), and accessibility helpers (`AriaLabel`, `AltText`, `TabIndex`, `AutoFocus`, `Role`).
|
||||||
|
|
||||||
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
|
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
|
||||||
|
|
||||||
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting.
|
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
|
||||||
|
|
||||||
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
227
edge_test.go
227
edge_test.go
|
|
@ -1,8 +1,7 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
|
@ -10,7 +9,7 @@ import (
|
||||||
|
|
||||||
// --- Unicode / RTL edge cases ---
|
// --- Unicode / RTL edge cases ---
|
||||||
|
|
||||||
func TestText_Emoji(t *testing.T) {
|
func TestText_Emoji_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -33,7 +32,7 @@ func TestText_Emoji(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 !strings.Contains(got, tt.input) {
|
if !containsText(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)
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +40,7 @@ func TestText_Emoji(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEl_Emoji(t *testing.T) {
|
func TestEl_Emoji_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("span", Raw("\U0001F680 Launch"))
|
node := El("span", Raw("\U0001F680 Launch"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -51,7 +50,7 @@ func TestEl_Emoji(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_RTL(t *testing.T) {
|
func TestText_RTL_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -76,19 +75,19 @@ func TestText_RTL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEl_RTL(t *testing.T) {
|
func TestEl_RTL_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
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 !strings.Contains(got, `dir="rtl"`) {
|
if !containsText(got, `dir="rtl"`) {
|
||||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
|
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_ZeroWidth(t *testing.T) {
|
func TestText_ZeroWidth_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -114,7 +113,7 @@ func TestText_ZeroWidth(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestText_MixedScripts(t *testing.T) {
|
func TestText_MixedScripts_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -141,7 +140,7 @@ func TestText_MixedScripts(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Unicode(t *testing.T) {
|
func TestStripTags_Unicode_Ugly(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|
@ -163,19 +162,19 @@ func TestStripTags_Unicode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_UnicodeValue(t *testing.T) {
|
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
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 !strings.Contains(got, want) {
|
if !containsText(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_DeepNesting_10Levels(t *testing.T) {
|
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// Build 10 levels of nested layouts
|
// Build 10 levels of nested layouts
|
||||||
|
|
@ -187,7 +186,7 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
|
|
||||||
// Should contain the deepest content
|
// Should contain the deepest content
|
||||||
if !strings.Contains(got, "deepest") {
|
if !containsText(got, "deepest") {
|
||||||
t.Error("10 levels deep: missing leaf content")
|
t.Error("10 levels deep: missing leaf content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,17 +195,17 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||||
for i := 1; i < 10; i++ {
|
for i := 1; i < 10; i++ {
|
||||||
expectedBlock += "-C-0"
|
expectedBlock += "-C-0"
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
|
if !containsText(got, `data-block="`+expectedBlock+`"`) {
|
||||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
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 := strings.Count(got, "<main"); count != 10 {
|
if count := countText(got, "<main"); count != 10 {
|
||||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
current := NewLayout("C").C(Raw("bottom"))
|
current := NewLayout("C").C(Raw("bottom"))
|
||||||
|
|
@ -216,15 +215,15 @@ func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
||||||
|
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
|
|
||||||
if !strings.Contains(got, "bottom") {
|
if !containsText(got, "bottom") {
|
||||||
t.Error("20 levels deep: missing leaf content")
|
t.Error("20 levels deep: missing leaf content")
|
||||||
}
|
}
|
||||||
if count := strings.Count(got, "<main"); count != 20 {
|
if count := countText(got, "<main"); count != 20 {
|
||||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||||
|
|
@ -238,12 +237,12 @@ func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.Render(ctx)
|
got := current.Render(ctx)
|
||||||
if !strings.Contains(got, "leaf") {
|
if !containsText(got, "leaf") {
|
||||||
t.Error("mixed deep nesting: missing leaf content")
|
t.Error("mixed deep nesting: missing leaf content")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_LargeIteration_1000(t *testing.T) {
|
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
items := make([]int, 1000)
|
items := make([]int, 1000)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
|
@ -251,23 +250,23 @@ func TestEach_LargeIteration_1000(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
node := Each(items, func(i int) Node {
|
node := Each(items, func(i int) Node {
|
||||||
return El("li", Raw(fmt.Sprintf("%d", i)))
|
return El("li", Raw(itoaText(i)))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := strings.Count(got, "<li>"); count != 1000 {
|
if count := countText(got, "<li>"); count != 1000 {
|
||||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<li>0</li>") {
|
if !containsText(got, "<li>0</li>") {
|
||||||
t.Error("Each with 1000 items: missing first item")
|
t.Error("Each with 1000 items: missing first item")
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<li>999</li>") {
|
if !containsText(got, "<li>999</li>") {
|
||||||
t.Error("Each with 1000 items: missing last item")
|
t.Error("Each with 1000 items: missing last item")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_LargeIteration_5000(t *testing.T) {
|
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
items := make([]int, 5000)
|
items := make([]int, 5000)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
|
@ -275,43 +274,43 @@ func TestEach_LargeIteration_5000(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
node := Each(items, func(i int) Node {
|
node := Each(items, func(i int) Node {
|
||||||
return El("span", Raw(fmt.Sprintf("%d", i)))
|
return El("span", Raw(itoaText(i)))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := strings.Count(got, "<span>"); count != 5000 {
|
if count := countText(got, "<span>"); count != 5000 {
|
||||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEach_NestedEach(t *testing.T) {
|
func TestEach_NestedEach_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
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(fmt.Sprintf("%d-%s", row, col)))
|
return El("td", Raw(itoaText(row)+"-"+col))
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
||||||
if count := strings.Count(got, "<tr>"); count != 3 {
|
if count := countText(got, "<tr>"); count != 3 {
|
||||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||||
}
|
}
|
||||||
if count := strings.Count(got, "<td>"); count != 9 {
|
if count := countText(got, "<td>"); count != 9 {
|
||||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "1-b") {
|
if !containsText(got, "1-b") {
|
||||||
t.Error("nested Each: missing cell content '1-b'")
|
t.Error("nested Each: missing cell content '1-b'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Layout variant validation ---
|
// --- Layout variant validation ---
|
||||||
|
|
||||||
func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -343,7 +342,96 @@ func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
func TestLayout_VariantError_Bad(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variant string
|
||||||
|
wantInvalid bool
|
||||||
|
wantErrString string
|
||||||
|
build func(*Layout)
|
||||||
|
wantRender string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid variant",
|
||||||
|
variant: "HCF",
|
||||||
|
wantInvalid: false,
|
||||||
|
build: func(layout *Layout) {
|
||||||
|
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||||
|
},
|
||||||
|
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main><footer role="contentinfo" data-block="F-0">footer</footer>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed invalid variant",
|
||||||
|
variant: "HXC",
|
||||||
|
wantInvalid: true,
|
||||||
|
wantErrString: "html: invalid layout variant HXC",
|
||||||
|
build: func(layout *Layout) {
|
||||||
|
layout.H(Raw("header")).C(Raw("main"))
|
||||||
|
},
|
||||||
|
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main>`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
layout := NewLayout(tt.variant)
|
||||||
|
if tt.build != nil {
|
||||||
|
tt.build(layout)
|
||||||
|
}
|
||||||
|
if tt.wantInvalid {
|
||||||
|
if layout.VariantError() == nil {
|
||||||
|
t.Fatalf("VariantError() = nil, want sentinel error for %q", tt.variant)
|
||||||
|
}
|
||||||
|
if !errors.Is(layout.VariantError(), ErrInvalidLayoutVariant) {
|
||||||
|
t.Fatalf("VariantError() = %v, want errors.Is(..., ErrInvalidLayoutVariant)", layout.VariantError())
|
||||||
|
}
|
||||||
|
if got := layout.VariantError().Error(); got != tt.wantErrString {
|
||||||
|
t.Fatalf("VariantError().Error() = %q, want %q", got, tt.wantErrString)
|
||||||
|
}
|
||||||
|
} else if layout.VariantError() != nil {
|
||||||
|
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := layout.Render(NewContext())
|
||||||
|
if got != tt.wantRender {
|
||||||
|
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLayoutVariant_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variant string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "valid", variant: "HCF", wantErr: false},
|
||||||
|
{name: "invalid", variant: "HXC", wantErr: true},
|
||||||
|
{name: "empty", variant: "", wantErr: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateLayoutVariant(tt.variant)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ValidateLayoutVariant(%q) = nil, want error", tt.variant)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrInvalidLayoutVariant) {
|
||||||
|
t.Fatalf("ValidateLayoutVariant(%q) = %v, want ErrInvalidLayoutVariant", tt.variant, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
||||||
ctx := NewContext()
|
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.
|
||||||
|
|
@ -351,32 +439,32 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
||||||
H(Raw("header")).C(Raw("main"))
|
H(Raw("header")).C(Raw("main"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
if !strings.Contains(got, "header") {
|
if !containsText(got, "header") {
|
||||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "main") {
|
if !containsText(got, "main") {
|
||||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
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 := strings.Count(got, "data-block="); count != 2 {
|
if count := countText(got, "data-block="); count != 2 {
|
||||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_DuplicateVariantChars(t *testing.T) {
|
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
||||||
// "CCC" — C appears three times. Should render C slot content three times.
|
// "CCC" — C appears three times. Should render C slot content three times.
|
||||||
layout := NewLayout("CCC").C(Raw("content"))
|
layout := NewLayout("CCC").C(Raw("content"))
|
||||||
got := layout.Render(ctx)
|
got := layout.Render(ctx)
|
||||||
|
|
||||||
count := strings.Count(got, "content")
|
count := countText(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_EmptySlots(t *testing.T) {
|
func TestLayout_EmptySlots_Ugly(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 — should produce empty output.
|
||||||
|
|
@ -388,9 +476,38 @@ func TestLayout_EmptySlots(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("wrapped"))
|
||||||
|
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
|
||||||
|
|
||||||
|
got := outer.Render(ctx)
|
||||||
|
|
||||||
|
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
|
||||||
|
inner := NewLayout("C").C(Raw("wrapped"))
|
||||||
|
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||||
|
"match": inner,
|
||||||
|
"miss": Raw("ignored"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
got := outer.Render(ctx)
|
||||||
|
|
||||||
|
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||||
|
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Render convenience function edge cases ---
|
// --- Render convenience function edge cases ---
|
||||||
|
|
||||||
func TestRender_NilContext(t *testing.T) {
|
func TestRender_NilContext_Ugly(t *testing.T) {
|
||||||
node := Raw("test")
|
node := Raw("test")
|
||||||
got := Render(node, nil)
|
got := Render(node, nil)
|
||||||
if got != "test" {
|
if got != "test" {
|
||||||
|
|
@ -398,7 +515,7 @@ func TestRender_NilContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_NilContext(t *testing.T) {
|
func TestImprint_NilContext_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -410,7 +527,7 @@ func TestImprint_NilContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants_NilContext(t *testing.T) {
|
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -424,7 +541,7 @@ func TestCompareVariants_NilContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants_SingleVariant(t *testing.T) {
|
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
|
|
||||||
|
|
@ -439,31 +556,31 @@ func TestCompareVariants_SingleVariant(t *testing.T) {
|
||||||
|
|
||||||
// --- escapeHTML / escapeAttr edge cases ---
|
// --- escapeHTML / escapeAttr edge cases ---
|
||||||
|
|
||||||
func TestEscapeAttr_AllSpecialChars(t *testing.T) {
|
func TestEscapeAttr_AllSpecialChars_Ugly(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 strings.Contains(got, `"&<>"'"`) {
|
if containsText(got, `"&<>"'"`) {
|
||||||
t.Error("attribute value with special chars must be fully escaped")
|
t.Error("attribute value with special chars must be fully escaped")
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "&<>"'") {
|
if !containsText(got, "&<>"'") {
|
||||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_EmptyTag(t *testing.T) {
|
func TestElNode_EmptyTag_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
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 !strings.Contains(got, "content") {
|
if !containsText(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(t *testing.T) {
|
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
cases := map[string]Node{
|
cases := map[string]Node{
|
||||||
"a": Raw("alpha"),
|
"a": Raw("alpha"),
|
||||||
|
|
@ -476,7 +593,7 @@ func TestSwitchNode_NoMatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEntitled_NilContext(t *testing.T) {
|
func TestEntitled_NilContext_Ugly(t *testing.T) {
|
||||||
node := Entitled("premium", Raw("content"))
|
node := Entitled("premium", Raw("content"))
|
||||||
got := node.Render(nil)
|
got := node.Render(nil)
|
||||||
if got != "" {
|
if got != "" {
|
||||||
|
|
|
||||||
16
go.mod
16
go.mod
|
|
@ -3,25 +3,19 @@ module dappco.re/go/core/html
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core/i18n v0.1.8
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
|
dappco.re/go/core/i18n v0.2.1
|
||||||
dappco.re/go/core/io v0.2.0
|
dappco.re/go/core/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 v0.5.0 // indirect
|
dappco.re/go/core/inference v0.1.4 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
dappco.re/go/core/log v0.0.4 // indirect
|
||||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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
|
golang.org/x/text v0.35.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace (
|
|
||||||
dappco.re/go/core => ../../../../core/go
|
|
||||||
dappco.re/go/core/i18n => ../../../../core/go-i18n
|
|
||||||
dappco.re/go/core/io => ../../../../core/go-io
|
|
||||||
dappco.re/go/core/log => ../../../../core/go-log
|
|
||||||
)
|
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -1,3 +1,13 @@
|
||||||
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
|
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
|
||||||
|
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||||
|
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
|
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||||
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
|
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
|
||||||
|
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
|
||||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
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 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIntegration_RenderThenReverse(t *testing.T) {
|
func TestIntegration_RenderThenReverse_Good(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(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegration_ResponsiveImprint(t *testing.T) {
|
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
|
||||||
178
layout.go
178
layout.go
|
|
@ -1,10 +1,14 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import "strings"
|
import "errors"
|
||||||
|
|
||||||
// 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
|
||||||
|
// one unrecognised slot character.
|
||||||
|
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -22,48 +26,159 @@ var slotRegistry = map[byte]slotMeta{
|
||||||
|
|
||||||
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
// 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"))
|
||||||
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
|
||||||
|
variantErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLayout creates a new Layout with the given variant string.
|
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
|
||||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
if node == nil {
|
||||||
func NewLayout(variant string) *Layout {
|
return ""
|
||||||
return &Layout{
|
}
|
||||||
variant: variant,
|
|
||||||
slots: make(map[byte][]Node),
|
if renderer, ok := node.(layoutPathRenderer); ok {
|
||||||
|
return renderer.renderWithLayoutPath(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := node.(type) {
|
||||||
|
case *Layout:
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
clone := *t
|
||||||
|
clone.path = path
|
||||||
|
return clone.Render(ctx)
|
||||||
|
case *ifNode:
|
||||||
|
if t == nil || t.cond == nil || t.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if t.cond(ctx) {
|
||||||
|
return renderWithLayoutPath(t.node, ctx, path)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case *unlessNode:
|
||||||
|
if t == nil || t.cond == nil || t.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !t.cond(ctx) {
|
||||||
|
return renderWithLayoutPath(t.node, ctx, path)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case *entitledNode:
|
||||||
|
if t == nil || t.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return renderWithLayoutPath(t.node, ctx, path)
|
||||||
|
case *switchNode:
|
||||||
|
if t == nil || t.selector == nil || t.cases == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := t.selector(ctx)
|
||||||
|
node, ok := t.cases[key]
|
||||||
|
if !ok || node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return renderWithLayoutPath(node, ctx, path)
|
||||||
|
default:
|
||||||
|
return node.Render(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewLayout creates a new Layout with the given variant string.
|
||||||
|
// Usage example: page := NewLayout("HLCRF")
|
||||||
|
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||||
|
func NewLayout(variant string) *Layout {
|
||||||
|
l := &Layout{
|
||||||
|
variant: variant,
|
||||||
|
slots: make(map[byte][]Node),
|
||||||
|
}
|
||||||
|
l.variantErr = ValidateLayoutVariant(variant)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateLayoutVariant reports whether a layout variant string contains only
|
||||||
|
// recognised slot characters.
|
||||||
|
//
|
||||||
|
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
|
||||||
|
// layoutVariantError for invalid ones.
|
||||||
|
func ValidateLayoutVariant(variant string) error {
|
||||||
|
var invalid bool
|
||||||
|
for i := range len(variant) {
|
||||||
|
if _, ok := slotRegistry[variant[i]]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invalid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !invalid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &layoutVariantError{variant: variant}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Layout) slotsForSlot(slot byte) []Node {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if l.slots == nil {
|
||||||
|
l.slots = make(map[byte][]Node)
|
||||||
|
}
|
||||||
|
return l.slots[slot]
|
||||||
|
}
|
||||||
|
|
||||||
// H appends nodes to the Header slot.
|
// 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 {
|
||||||
l.slots['H'] = append(l.slots['H'], nodes...)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// L appends nodes to the Left aside slot.
|
// L appends nodes to the Left aside slot.
|
||||||
|
// Usage example: NewLayout("LC").L(Text("nav"))
|
||||||
func (l *Layout) L(nodes ...Node) *Layout {
|
func (l *Layout) L(nodes ...Node) *Layout {
|
||||||
l.slots['L'] = append(l.slots['L'], nodes...)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// C appends nodes to the Content (main) slot.
|
// C appends nodes to the Content (main) slot.
|
||||||
|
// Usage example: NewLayout("C").C(Text("body"))
|
||||||
func (l *Layout) C(nodes ...Node) *Layout {
|
func (l *Layout) C(nodes ...Node) *Layout {
|
||||||
l.slots['C'] = append(l.slots['C'], nodes...)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// R appends nodes to the Right aside slot.
|
// R appends nodes to the Right aside slot.
|
||||||
|
// Usage example: NewLayout("CR").R(Text("ads"))
|
||||||
func (l *Layout) R(nodes ...Node) *Layout {
|
func (l *Layout) R(nodes ...Node) *Layout {
|
||||||
l.slots['R'] = append(l.slots['R'], nodes...)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// F appends nodes to the Footer slot.
|
// F appends nodes to the Footer slot.
|
||||||
|
// Usage example: NewLayout("CF").F(Text("footer"))
|
||||||
func (l *Layout) F(nodes ...Node) *Layout {
|
func (l *Layout) F(nodes ...Node) *Layout {
|
||||||
l.slots['F'] = append(l.slots['F'], nodes...)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,10 +187,27 @@ func (l *Layout) blockID(slot byte) string {
|
||||||
return l.path + string(slot) + "-0"
|
return l.path + string(slot) + "-0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VariantError reports whether the layout variant string contained any invalid
|
||||||
|
// slot characters when the layout was constructed.
|
||||||
|
func (l *Layout) VariantError() error {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.variantErr
|
||||||
|
}
|
||||||
|
|
||||||
// Render produces the semantic HTML for this layout.
|
// 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.
|
// Only slots present in the variant string are rendered.
|
||||||
func (l *Layout) Render(ctx *Context) string {
|
func (l *Layout) Render(ctx *Context) string {
|
||||||
var b strings.Builder
|
if l == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = NewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
b := newTextBuilder()
|
||||||
|
|
||||||
for i := range len(l.variant) {
|
for i := range len(l.variant) {
|
||||||
slot := l.variant[i]
|
slot := l.variant[i]
|
||||||
|
|
@ -100,14 +232,10 @@ func (l *Layout) Render(ctx *Context) string {
|
||||||
b.WriteString(`">`)
|
b.WriteString(`">`)
|
||||||
|
|
||||||
for _, child := range children {
|
for _, child := range children {
|
||||||
// Clone nested layouts before setting path (thread-safe).
|
if child == nil {
|
||||||
if inner, ok := child.(*Layout); ok {
|
|
||||||
clone := *inner
|
|
||||||
clone.path = bid + "-"
|
|
||||||
b.WriteString(clone.Render(ctx))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b.WriteString(child.Render(ctx))
|
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("</")
|
b.WriteString("</")
|
||||||
|
|
@ -117,3 +245,15 @@ func (l *Layout) Render(ctx *Context) string {
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type layoutVariantError struct {
|
||||||
|
variant string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *layoutVariantError) Error() string {
|
||||||
|
return "html: invalid layout variant " + e.variant
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *layoutVariantError) Unwrap() error {
|
||||||
|
return ErrInvalidLayoutVariant
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLayout_HLCRF(t *testing.T) {
|
func TestLayout_HLCRF_Good(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"))
|
||||||
|
|
@ -13,34 +12,34 @@ func TestLayout_HLCRF(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 !strings.Contains(got, want) {
|
if !containsText(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 !strings.Contains(got, want) {
|
if !containsText(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 !strings.Contains(got, want) {
|
if !containsText(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 !strings.Contains(got, want) {
|
if !containsText(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(t *testing.T) {
|
func TestLayout_HCF_Good(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"))
|
||||||
|
|
@ -48,42 +47,42 @@ func TestLayout_HCF(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 !strings.Contains(got, want) {
|
if !containsText(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 strings.Contains(got, unwanted) {
|
if containsText(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(t *testing.T) {
|
func TestLayout_ContentOnly_Good(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 !strings.Contains(got, `data-block="C-0"`) {
|
if !containsText(got, `data-block="C-0"`) {
|
||||||
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<main") {
|
if !containsText(got, "<main") {
|
||||||
t.Errorf("C layout missing <main in:\n%s", got)
|
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 strings.Contains(got, unwanted) {
|
if containsText(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(t *testing.T) {
|
func TestLayout_FluentAPI_Good(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
|
||||||
|
|
@ -98,19 +97,53 @@ func TestLayout_FluentAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
|
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
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 !strings.Contains(got, "main") {
|
if !containsText(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 strings.Contains(got, "left") {
|
if containsText(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 strings.Contains(got, "right") {
|
if containsText(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) {
|
||||||
|
var layout *Layout
|
||||||
|
|
||||||
|
if layout.H(Raw("h")) != nil {
|
||||||
|
t.Fatal("expected nil layout from H on nil receiver")
|
||||||
|
}
|
||||||
|
if layout.L(Raw("l")) != nil {
|
||||||
|
t.Fatal("expected nil layout from L on nil receiver")
|
||||||
|
}
|
||||||
|
if layout.C(Raw("c")) != nil {
|
||||||
|
t.Fatal("expected nil layout from C on nil receiver")
|
||||||
|
}
|
||||||
|
if layout.R(Raw("r")) != nil {
|
||||||
|
t.Fatal("expected nil layout from R on nil receiver")
|
||||||
|
}
|
||||||
|
if layout.F(Raw("f")) != nil {
|
||||||
|
t.Fatal("expected nil layout from F on nil receiver")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := layout.Render(NewContext()); got != "" {
|
||||||
|
t.Fatalf("nil layout render should be empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayout_Render_NilContext_Good(t *testing.T) {
|
||||||
|
layout := NewLayout("C").C(Raw("content"))
|
||||||
|
|
||||||
|
got := layout.Render(nil)
|
||||||
|
want := `<main role="main" data-block="C-0">content</main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
131
node.go
131
node.go
|
|
@ -5,12 +5,11 @@ import (
|
||||||
"iter"
|
"iter"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strconv"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node is anything renderable.
|
// Node is anything renderable.
|
||||||
|
// Usage example: var n Node = El("div", Text("welcome"))
|
||||||
type Node interface {
|
type Node interface {
|
||||||
Render(ctx *Context) string
|
Render(ctx *Context) string
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +26,10 @@ var (
|
||||||
_ Node = (*eachNode[any])(nil)
|
_ Node = (*eachNode[any])(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type layoutPathRenderer interface {
|
||||||
|
renderWithLayoutPath(ctx *Context, path string) string
|
||||||
|
}
|
||||||
|
|
||||||
// voidElements is the set of HTML elements that must not have a closing tag.
|
// voidElements is the set of HTML elements that must not have a closing tag.
|
||||||
var voidElements = map[string]bool{
|
var voidElements = map[string]bool{
|
||||||
"area": true,
|
"area": true,
|
||||||
|
|
@ -56,11 +59,15 @@ type rawNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||||
|
// Usage example: Raw("<strong>trusted</strong>")
|
||||||
func Raw(content string) Node {
|
func Raw(content string) Node {
|
||||||
return &rawNode{content: content}
|
return &rawNode{content: content}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *rawNode) Render(_ *Context) string {
|
func (n *rawNode) Render(_ *Context) string {
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return n.content
|
return n.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +80,7 @@ type elNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// El creates an HTML element node with children.
|
// El creates an HTML element node with children.
|
||||||
|
// Usage example: El("section", Text("welcome"))
|
||||||
func El(tag string, children ...Node) Node {
|
func El(tag string, children ...Node) Node {
|
||||||
return &elNode{
|
return &elNode{
|
||||||
tag: tag,
|
tag: tag,
|
||||||
|
|
@ -82,8 +90,13 @@ func El(tag string, children ...Node) Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attr sets an attribute on an El node. Returns the node for chaining.
|
// Attr sets an attribute on an El node. Returns the node for chaining.
|
||||||
// It recursively traverses through wrappers like If, Unless, and Entitled.
|
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||||
|
// It recursively traverses through wrappers like If, Unless, Entitled, and Each.
|
||||||
func Attr(n Node, key, value string) Node {
|
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
|
||||||
|
|
@ -93,12 +106,52 @@ func Attr(n Node, key, value string) Node {
|
||||||
Attr(t.node, key, value)
|
Attr(t.node, key, value)
|
||||||
case *entitledNode:
|
case *entitledNode:
|
||||||
Attr(t.node, key, value)
|
Attr(t.node, key, value)
|
||||||
|
case *switchNode:
|
||||||
|
for _, child := range t.cases {
|
||||||
|
Attr(child, key, value)
|
||||||
|
}
|
||||||
|
case attrApplier:
|
||||||
|
t.applyAttr(key, value)
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AriaLabel sets an aria-label attribute on an element node.
|
||||||
|
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
||||||
|
func AriaLabel(n Node, label string) Node {
|
||||||
|
return Attr(n, "aria-label", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AltText sets an alt attribute on an element node.
|
||||||
|
// Usage example: AltText(El("img"), "Profile photo")
|
||||||
|
func AltText(n Node, text string) Node {
|
||||||
|
return Attr(n, "alt", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabIndex sets a tabindex attribute on an element node.
|
||||||
|
// Usage example: TabIndex(El("button", Text("save")), 0)
|
||||||
|
func TabIndex(n Node, index int) Node {
|
||||||
|
return Attr(n, "tabindex", strconv.Itoa(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoFocus sets an autofocus attribute on an element node.
|
||||||
|
// Usage example: AutoFocus(El("input"))
|
||||||
|
func AutoFocus(n Node) Node {
|
||||||
|
return Attr(n, "autofocus", "autofocus")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role sets a role attribute on an element node.
|
||||||
|
// Usage example: Role(El("nav", Text("links")), "navigation")
|
||||||
|
func Role(n Node, role string) Node {
|
||||||
|
return Attr(n, "role", role)
|
||||||
|
}
|
||||||
|
|
||||||
func (n *elNode) Render(ctx *Context) string {
|
func (n *elNode) Render(ctx *Context) string {
|
||||||
var b strings.Builder
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b := newTextBuilder()
|
||||||
|
|
||||||
b.WriteByte('<')
|
b.WriteByte('<')
|
||||||
b.WriteString(escapeHTML(n.tag))
|
b.WriteString(escapeHTML(n.tag))
|
||||||
|
|
@ -121,6 +174,9 @@ func (n *elNode) Render(ctx *Context) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range len(n.children) {
|
for i := range len(n.children) {
|
||||||
|
if n.children[i] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
b.WriteString(n.children[i].Render(ctx))
|
b.WriteString(n.children[i].Render(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,19 +202,17 @@ type textNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text creates a node that renders through the go-i18n grammar pipeline.
|
// 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.
|
// 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *textNode) Render(ctx *Context) string {
|
func (n *textNode) Render(ctx *Context) string {
|
||||||
var text string
|
if n == nil {
|
||||||
if ctx != nil && ctx.service != nil {
|
return ""
|
||||||
text = ctx.service.T(n.key, n.args...)
|
|
||||||
} else {
|
|
||||||
text = i18n.T(n.key, n.args...)
|
|
||||||
}
|
}
|
||||||
return escapeHTML(text)
|
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ifNode ---
|
// --- ifNode ---
|
||||||
|
|
@ -169,11 +223,15 @@ type ifNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If renders child only when condition is true.
|
// If renders child only when condition is true.
|
||||||
|
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||||
func If(cond func(*Context) bool, node Node) Node {
|
func If(cond func(*Context) bool, node Node) Node {
|
||||||
return &ifNode{cond: cond, node: node}
|
return &ifNode{cond: cond, node: node}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *ifNode) Render(ctx *Context) string {
|
func (n *ifNode) Render(ctx *Context) string {
|
||||||
|
if n == nil || n.cond == nil || n.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if n.cond(ctx) {
|
if n.cond(ctx) {
|
||||||
return n.node.Render(ctx)
|
return n.node.Render(ctx)
|
||||||
}
|
}
|
||||||
|
|
@ -188,11 +246,15 @@ type unlessNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unless renders child only when condition is false.
|
// Unless renders child only when condition is false.
|
||||||
|
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||||
func Unless(cond func(*Context) bool, node Node) Node {
|
func Unless(cond func(*Context) bool, node Node) Node {
|
||||||
return &unlessNode{cond: cond, node: node}
|
return &unlessNode{cond: cond, node: node}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *unlessNode) Render(ctx *Context) string {
|
func (n *unlessNode) Render(ctx *Context) string {
|
||||||
|
if n == nil || n.cond == nil || n.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if !n.cond(ctx) {
|
if !n.cond(ctx) {
|
||||||
return n.node.Render(ctx)
|
return n.node.Render(ctx)
|
||||||
}
|
}
|
||||||
|
|
@ -207,12 +269,16 @@ type entitledNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entitled renders child only when entitlement is granted. Absent, not hidden.
|
// 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 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *entitledNode) Render(ctx *Context) string {
|
func (n *entitledNode) Render(ctx *Context) string {
|
||||||
|
if n == nil || n.node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -227,13 +293,23 @@ type switchNode struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch renders based on runtime selector value.
|
// Switch renders based on runtime selector value.
|
||||||
|
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||||
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
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 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
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 ""
|
||||||
|
}
|
||||||
return node.Render(ctx)
|
return node.Render(ctx)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -246,20 +322,49 @@ type eachNode[T any] struct {
|
||||||
fn func(T) Node
|
fn func(T) Node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type attrApplier interface {
|
||||||
|
applyAttr(key, value string)
|
||||||
|
}
|
||||||
|
|
||||||
// Each iterates items and renders each via fn.
|
// 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.
|
// EachSeq iterates an iter.Seq and renders each via fn.
|
||||||
|
// Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||||
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
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 {
|
||||||
var b strings.Builder
|
return n.renderWithLayoutPath(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *eachNode[T]) applyAttr(key, value string) {
|
||||||
|
if n == nil || n.fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := n.fn
|
||||||
|
n.fn = func(item T) Node {
|
||||||
|
return Attr(prev(item), key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
||||||
|
if n == nil || n.fn == nil || n.items == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b := newTextBuilder()
|
||||||
for item := range n.items {
|
for item := range n.items {
|
||||||
b.WriteString(n.fn(item).Render(ctx))
|
child := n.fn(item)
|
||||||
|
if child == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
176
node_test.go
176
node_test.go
|
|
@ -1,13 +1,13 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRawNode_Render(t *testing.T) {
|
func TestRawNode_Render_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Raw("hello")
|
node := Raw("hello")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -16,7 +16,7 @@ func TestRawNode_Render(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_Render(t *testing.T) {
|
func TestElNode_Render_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("div", Raw("content"))
|
node := El("div", Raw("content"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -26,7 +26,7 @@ func TestElNode_Render(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_Nested(t *testing.T) {
|
func TestElNode_Nested_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("div", El("span", Raw("inner")))
|
node := El("div", El("span", Raw("inner")))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -36,7 +36,7 @@ func TestElNode_Nested(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_MultipleChildren(t *testing.T) {
|
func TestElNode_MultipleChildren_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("div", Raw("a"), Raw("b"))
|
node := El("div", Raw("a"), Raw("b"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -46,7 +46,7 @@ func TestElNode_MultipleChildren(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_VoidElement(t *testing.T) {
|
func TestElNode_VoidElement_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := El("br")
|
node := El("br")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -56,7 +56,7 @@ func TestElNode_VoidElement(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTextNode_Render(t *testing.T) {
|
func TestTextNode_Render_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Text("hello")
|
node := Text("hello")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -65,19 +65,19 @@ func TestTextNode_Render(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTextNode_Escapes(t *testing.T) {
|
func TestTextNode_Escapes_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Text("<script>alert('xss')</script>")
|
node := Text("<script>alert('xss')</script>")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
if strings.Contains(got, "<script>") {
|
if containsText(got, "<script>") {
|
||||||
t.Errorf("Text node must HTML-escape output, got %q", got)
|
t.Errorf("Text node must HTML-escape output, got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<script>") {
|
if !containsText(got, "<script>") {
|
||||||
t.Errorf("Text node should contain escaped script tag, got %q", got)
|
t.Errorf("Text node should contain escaped script tag, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIfNode_True(t *testing.T) {
|
func TestIfNode_True_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := If(func(*Context) bool { return true }, Raw("visible"))
|
node := If(func(*Context) bool { return true }, Raw("visible"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -86,7 +86,7 @@ func TestIfNode_True(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIfNode_False(t *testing.T) {
|
func TestIfNode_False_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := If(func(*Context) bool { return false }, Raw("hidden"))
|
node := If(func(*Context) bool { return false }, Raw("hidden"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -95,7 +95,7 @@ func TestIfNode_False(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnlessNode(t *testing.T) {
|
func TestUnlessNode_False_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Unless(func(*Context) bool { return false }, Raw("visible"))
|
node := Unless(func(*Context) bool { return false }, Raw("visible"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -104,7 +104,7 @@ func TestUnlessNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEntitledNode_Granted(t *testing.T) {
|
func TestEntitledNode_Granted_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
|
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
|
||||||
node := Entitled("premium", Raw("premium content"))
|
node := Entitled("premium", Raw("premium content"))
|
||||||
|
|
@ -114,7 +114,7 @@ func TestEntitledNode_Granted(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEntitledNode_Denied(t *testing.T) {
|
func TestEntitledNode_Denied_Bad(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
ctx.Entitlements = func(feature string) bool { return false }
|
ctx.Entitlements = func(feature string) bool { return false }
|
||||||
node := Entitled("premium", Raw("premium content"))
|
node := Entitled("premium", Raw("premium content"))
|
||||||
|
|
@ -124,7 +124,7 @@ func TestEntitledNode_Denied(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEntitledNode_NoFunc(t *testing.T) {
|
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Entitled("premium", Raw("premium content"))
|
node := Entitled("premium", Raw("premium content"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -133,7 +133,7 @@ func TestEntitledNode_NoFunc(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachNode(t *testing.T) {
|
func TestEachNode_Render_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
items := []string{"a", "b", "c"}
|
items := []string{"a", "b", "c"}
|
||||||
node := Each(items, func(item string) Node {
|
node := Each(items, func(item string) Node {
|
||||||
|
|
@ -146,7 +146,7 @@ func TestEachNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachNode_Empty(t *testing.T) {
|
func TestEachNode_Empty_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Each([]string{}, func(item string) Node {
|
node := Each([]string{}, func(item string) Node {
|
||||||
return El("li", Raw(item))
|
return El("li", Raw(item))
|
||||||
|
|
@ -157,7 +157,35 @@ func TestEachNode_Empty(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_Attr(t *testing.T) {
|
func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
inner := NewLayout("C").C(Raw("item"))
|
||||||
|
node := Each([]Node{inner}, func(item Node) Node {
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
got := NewLayout("C").C(node).Render(ctx)
|
||||||
|
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Each nested layout render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
inner := NewLayout("C").C(Raw("item"))
|
||||||
|
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
got := NewLayout("C").C(node).Render(ctx)
|
||||||
|
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestElNode_Attr_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(El("div", Raw("content")), "class", "container")
|
node := Attr(El("div", Raw("content")), "class", "container")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -167,25 +195,70 @@ func TestElNode_Attr(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_AttrEscaping(t *testing.T) {
|
func TestElNode_AttrEscaping_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(El("img"), "alt", `he said "hello"`)
|
node := Attr(El("img"), "alt", `he said "hello"`)
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
if !strings.Contains(got, `alt="he said "hello""`) {
|
if !containsText(got, `alt="he said "hello""`) {
|
||||||
t.Errorf("Attr should escape attribute values, got %q", got)
|
t.Errorf("Attr should escape attribute values, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestElNode_MultipleAttrs(t *testing.T) {
|
func TestAriaLabel_Good(t *testing.T) {
|
||||||
|
node := AriaLabel(El("button", Raw("save")), "Save changes")
|
||||||
|
got := node.Render(NewContext())
|
||||||
|
want := `<button aria-label="Save changes">save</button>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("AriaLabel() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAltText_Good(t *testing.T) {
|
||||||
|
node := AltText(El("img"), "Profile photo")
|
||||||
|
got := node.Render(NewContext())
|
||||||
|
want := `<img alt="Profile photo">`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("AltText() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTabIndex_Good(t *testing.T) {
|
||||||
|
node := TabIndex(El("button", Raw("save")), 0)
|
||||||
|
got := node.Render(NewContext())
|
||||||
|
want := `<button tabindex="0">save</button>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("TabIndex() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoFocus_Good(t *testing.T) {
|
||||||
|
node := AutoFocus(El("input"))
|
||||||
|
got := node.Render(NewContext())
|
||||||
|
want := `<input autofocus="autofocus">`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("AutoFocus() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRole_Good(t *testing.T) {
|
||||||
|
node := Role(El("nav", Raw("links")), "navigation")
|
||||||
|
got := node.Render(NewContext())
|
||||||
|
want := `<nav role="navigation">links</nav>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Role() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestElNode_MultipleAttrs_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
|
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
|
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
|
||||||
t.Errorf("multiple Attr() calls should stack, got %q", got)
|
t.Errorf("multiple Attr() calls should stack, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_NonElement(t *testing.T) {
|
func TestAttr_NonElement_Ugly(t *testing.T) {
|
||||||
node := Attr(Raw("text"), "class", "x")
|
node := Attr(Raw("text"), "class", "x")
|
||||||
got := node.Render(NewContext())
|
got := node.Render(NewContext())
|
||||||
if got != "text" {
|
if got != "text" {
|
||||||
|
|
@ -193,7 +266,7 @@ func TestAttr_NonElement(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnlessNode_True(t *testing.T) {
|
func TestUnlessNode_True_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
|
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
|
||||||
got := node.Render(ctx)
|
got := node.Render(ctx)
|
||||||
|
|
@ -202,7 +275,7 @@ func TestUnlessNode_True(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_ThroughIfNode(t *testing.T) {
|
func TestAttr_ThroughIfNode_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
inner := El("div", Raw("content"))
|
inner := El("div", Raw("content"))
|
||||||
node := If(func(*Context) bool { return true }, inner)
|
node := If(func(*Context) bool { return true }, inner)
|
||||||
|
|
@ -214,7 +287,7 @@ func TestAttr_ThroughIfNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_ThroughUnlessNode(t *testing.T) {
|
func TestAttr_ThroughUnlessNode_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
inner := El("div", Raw("content"))
|
inner := El("div", Raw("content"))
|
||||||
node := Unless(func(*Context) bool { return false }, inner)
|
node := Unless(func(*Context) bool { return false }, inner)
|
||||||
|
|
@ -226,7 +299,7 @@ func TestAttr_ThroughUnlessNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttr_ThroughEntitledNode(t *testing.T) {
|
func TestAttr_ThroughEntitledNode_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
ctx.Entitlements = func(string) bool { return true }
|
ctx.Entitlements = func(string) bool { return true }
|
||||||
inner := El("div", Raw("content"))
|
inner := El("div", Raw("content"))
|
||||||
|
|
@ -239,7 +312,50 @@ func TestAttr_ThroughEntitledNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTextNode_WithService(t *testing.T) {
|
func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
inner := El("div", Raw("content"))
|
||||||
|
node := Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||||
|
"match": inner,
|
||||||
|
"miss": El("span", Raw("unused")),
|
||||||
|
})
|
||||||
|
Attr(node, "data-state", "active")
|
||||||
|
got := node.Render(ctx)
|
||||||
|
want := `<div data-state="active">content</div>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Attr through Switch = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttr_ThroughEachNode_Good(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
node := Each([]string{"a", "b"}, func(item string) Node {
|
||||||
|
return El("span", Raw(item))
|
||||||
|
})
|
||||||
|
Attr(node, "class", "item")
|
||||||
|
|
||||||
|
got := node.Render(ctx)
|
||||||
|
want := `<span class="item">a</span><span class="item">b</span>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Attr through Each = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttr_ThroughEachSeqNode_Good(t *testing.T) {
|
||||||
|
ctx := NewContext()
|
||||||
|
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
|
||||||
|
return El("span", Raw(item))
|
||||||
|
})
|
||||||
|
Attr(node, "data-kind", "item")
|
||||||
|
|
||||||
|
got := node.Render(ctx)
|
||||||
|
want := `<span data-kind="item">a</span><span data-kind="item">b</span>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextNode_WithService_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
ctx := NewContextWithService(svc)
|
ctx := NewContextWithService(svc)
|
||||||
node := Text("hello")
|
node := Text("hello")
|
||||||
|
|
@ -249,7 +365,7 @@ func TestTextNode_WithService(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSwitchNode(t *testing.T) {
|
func TestSwitchNode_SelectsMatch_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
cases := map[string]Node{
|
cases := map[string]Node{
|
||||||
"dark": Raw("dark theme"),
|
"dark": Raw("dark theme"),
|
||||||
|
|
|
||||||
21
path.go
21
path.go
|
|
@ -3,21 +3,26 @@ package html
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
// ParseBlockID extracts the slot sequence from a data-block ID.
|
||||||
|
// Usage example: slots := ParseBlockID("L-0-C-0")
|
||||||
// "L-0-C-0" → ['L', 'C']
|
// "L-0-C-0" → ['L', 'C']
|
||||||
func ParseBlockID(id string) []byte {
|
func ParseBlockID(id string) []byte {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split on "-" and take every other element (the slot letters).
|
// Valid IDs are exact sequences of "{slot}-0" segments, e.g.
|
||||||
// Format: "X-0" or "X-0-Y-0-Z-0"
|
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID.
|
||||||
var slots []byte
|
parts := strings.Split(id, "-")
|
||||||
i := 0
|
if len(parts)%2 != 0 {
|
||||||
for part := range strings.SplitSeq(id, "-") {
|
return nil
|
||||||
if i%2 == 0 && len(part) == 1 {
|
|
||||||
slots = append(slots, part[0])
|
|
||||||
}
|
}
|
||||||
i++
|
|
||||||
|
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
|
return slots
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
path_test.go
30
path_test.go
|
|
@ -1,11 +1,10 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNestedLayout_PathChain(t *testing.T) {
|
func TestNestedLayout_PathChain_Good(t *testing.T) {
|
||||||
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
|
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"))
|
||||||
|
|
@ -13,33 +12,33 @@ func TestNestedLayout_PathChain(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 !strings.Contains(got, want) {
|
if !containsText(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 !strings.Contains(got, want) {
|
if !containsText(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(t *testing.T) {
|
func TestNestedLayout_DeepNesting_Ugly(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 !strings.Contains(got, want) {
|
if !containsText(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(t *testing.T) {
|
func TestBlockID_BuildsPath_Good(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
slot byte
|
slot byte
|
||||||
|
|
@ -60,7 +59,7 @@ func TestBlockID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseBlockID(t *testing.T) {
|
func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
id string
|
id string
|
||||||
want []byte
|
want []byte
|
||||||
|
|
@ -84,3 +83,18 @@ func TestParseBlockID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseBlockID_InvalidInput_Good(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"L-1-C-0",
|
||||||
|
"L-0-C",
|
||||||
|
"L-0-",
|
||||||
|
"X",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tests {
|
||||||
|
if got := ParseBlockID(id); got != nil {
|
||||||
|
t.Errorf("ParseBlockID(%q) = %v, want nil", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
20
pipeline.go
20
pipeline.go
|
|
@ -3,16 +3,17 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
core "dappco.re/go/core"
|
||||||
|
|
||||||
"dappco.re/go/core/i18n/reversal"
|
"dappco.re/go/core/i18n/reversal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StripTags removes HTML tags from rendered output, returning plain text.
|
// 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.
|
// 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 {
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
inTag := false
|
inTag := false
|
||||||
prevSpace := true // starts true to trim leading space
|
prevSpace := true // starts true to trim leading space
|
||||||
for _, r := range html {
|
for _, r := range html {
|
||||||
|
|
@ -40,16 +41,20 @@ func StripTags(html string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(b.String())
|
return core.Trim(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
// 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())
|
||||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = NewContext()
|
ctx = NewContext()
|
||||||
}
|
}
|
||||||
rendered := node.Render(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)
|
||||||
|
|
@ -58,10 +63,14 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||||
|
|
||||||
// CompareVariants runs the imprint pipeline on each responsive variant independently
|
// 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())
|
||||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = NewContext()
|
ctx = NewContext()
|
||||||
}
|
}
|
||||||
|
if r == nil {
|
||||||
|
return make(map[string]float64)
|
||||||
|
}
|
||||||
|
|
||||||
type named struct {
|
type named struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -70,6 +79,9 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStripTags_Simple(t *testing.T) {
|
func TestStripTags_Simple_Good(t *testing.T) {
|
||||||
got := StripTags(`<div>hello</div>`)
|
got := StripTags(`<div>hello</div>`)
|
||||||
want := "hello"
|
want := "hello"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -16,7 +16,7 @@ func TestStripTags_Simple(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Nested(t *testing.T) {
|
func TestStripTags_Nested_Good(t *testing.T) {
|
||||||
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
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(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_MultipleRegions(t *testing.T) {
|
func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
||||||
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
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,21 +32,21 @@ func TestStripTags_MultipleRegions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripTags_Empty(t *testing.T) {
|
func TestStripTags_Empty_Ugly(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(t *testing.T) {
|
func TestStripTags_NoTags_Good(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(t *testing.T) {
|
func TestStripTags_Entities_Good(t *testing.T) {
|
||||||
got := StripTags(`<script>`)
|
got := StripTags(`<script>`)
|
||||||
want := "<script>"
|
want := "<script>"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -54,7 +54,7 @@ func TestStripTags_Entities(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_FromNode(t *testing.T) {
|
func TestImprint_FromNode_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -74,7 +74,7 @@ func TestImprint_FromNode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImprint_SimilarPages(t *testing.T) {
|
func TestImprint_SimilarPages_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -102,7 +102,7 @@ func TestImprint_SimilarPages(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareVariants(t *testing.T) {
|
func TestCompareVariants_SameContent_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
// Render is a convenience function that renders a node tree to HTML.
|
// Render is a convenience function that renders a node tree to HTML.
|
||||||
|
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||||
func Render(node Node, ctx *Context) string {
|
func Render(node Node, ctx *Context) string {
|
||||||
|
if node == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = NewContext()
|
ctx = NewContext()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
i18n "dappco.re/go/core/i18n"
|
i18n "dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRender_FullPage(t *testing.T) {
|
func TestRender_FullPage_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -28,14 +27,14 @@ func TestRender_FullPage(t *testing.T) {
|
||||||
|
|
||||||
// Contains semantic elements
|
// Contains semantic elements
|
||||||
for _, want := range []string{"<header", "<main", "<footer"} {
|
for _, want := range []string{"<header", "<main", "<footer"} {
|
||||||
if !strings.Contains(got, want) {
|
if !containsText(got, want) {
|
||||||
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
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 !strings.Contains(got, want) {
|
if !containsText(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -44,13 +43,13 @@ func TestRender_FullPage(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 strings.Count(got, open) != strings.Count(got, close) {
|
if countText(got, open) != countText(got, close) {
|
||||||
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_EntitlementGating(t *testing.T) {
|
func TestRender_EntitlementGating_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -67,18 +66,18 @@ func TestRender_EntitlementGating(t *testing.T) {
|
||||||
|
|
||||||
got := page.Render(ctx)
|
got := page.Render(ctx)
|
||||||
|
|
||||||
if !strings.Contains(got, "public") {
|
if !containsText(got, "public") {
|
||||||
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "admin-panel") {
|
if !containsText(got, "admin-panel") {
|
||||||
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if strings.Contains(got, "premium-content") {
|
if containsText(got, "premium-content") {
|
||||||
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_XSSPrevention(t *testing.T) {
|
func TestRender_XSSPrevention_Good(t *testing.T) {
|
||||||
svc, _ := i18n.New()
|
svc, _ := i18n.New()
|
||||||
i18n.SetDefault(svc)
|
i18n.SetDefault(svc)
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
|
|
@ -88,10 +87,10 @@ func TestRender_XSSPrevention(t *testing.T) {
|
||||||
|
|
||||||
got := page.Render(ctx)
|
got := page.Render(ctx)
|
||||||
|
|
||||||
if strings.Contains(got, "<script>") {
|
if containsText(got, "<script>") {
|
||||||
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<script>") {
|
if !containsText(got, "<script>") {
|
||||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time interface check.
|
||||||
|
var _ Node = (*Responsive)(nil)
|
||||||
|
|
||||||
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
// 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
|
||||||
|
|
@ -14,21 +21,38 @@ type responsiveVariant struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResponsive creates a new multi-variant responsive compositor.
|
// NewResponsive creates a new multi-variant responsive compositor.
|
||||||
|
// Usage example: r := NewResponsive()
|
||||||
func NewResponsive() *Responsive {
|
func NewResponsive() *Responsive {
|
||||||
return &Responsive{}
|
return &Responsive{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||||
|
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||||
// Variants render in insertion order.
|
// Variants render in insertion order.
|
||||||
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
||||||
|
if r == nil {
|
||||||
|
r = NewResponsive()
|
||||||
|
}
|
||||||
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render produces HTML with each variant in a data-variant container.
|
// Render produces HTML with each variant in a data-variant container.
|
||||||
|
// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||||
func (r *Responsive) Render(ctx *Context) string {
|
func (r *Responsive) Render(ctx *Context) string {
|
||||||
var b strings.Builder
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = NewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
b := newTextBuilder()
|
||||||
for _, v := range r.variants {
|
for _, v := range r.variants {
|
||||||
|
if v.layout == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString(`<div data-variant="`)
|
b.WriteString(`<div data-variant="`)
|
||||||
b.WriteString(escapeAttr(v.name))
|
b.WriteString(escapeAttr(v.name))
|
||||||
b.WriteString(`">`)
|
b.WriteString(`">`)
|
||||||
|
|
@ -37,3 +61,36 @@ func (r *Responsive) Render(ctx *Context) string {
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VariantSelector returns a CSS attribute selector for a responsive variant.
|
||||||
|
// Usage example: selector := VariantSelector("desktop")
|
||||||
|
func VariantSelector(name string) string {
|
||||||
|
return `[data-variant="` + escapeCSSString(name) + `"]`
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeCSSString(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '\\', '"':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
|
||||||
|
for i := 0; i < len(esc); i++ {
|
||||||
|
b.WriteByte(esc[i])
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResponsive_SingleVariant(t *testing.T) {
|
func TestResponsive_SingleVariant_Good(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 !strings.Contains(got, `data-variant="desktop"`) {
|
if !containsText(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 !strings.Contains(got, `data-block="H-0"`) {
|
if !containsText(got, `data-block="H-0"`) {
|
||||||
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_MultiVariant(t *testing.T) {
|
func TestResponsive_MultiVariant_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
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"))).
|
||||||
|
|
@ -30,13 +29,13 @@ func TestResponsive_MultiVariant(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 !strings.Contains(got, `data-variant="`+v+`"`) {
|
if !containsText(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(t *testing.T) {
|
func TestResponsive_VariantOrder_Good(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"))).
|
||||||
|
|
@ -44,8 +43,8 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
di := strings.Index(got, `data-variant="desktop"`)
|
di := indexText(got, `data-variant="desktop"`)
|
||||||
mi := strings.Index(got, `data-variant="mobile"`)
|
mi := indexText(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)
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +53,7 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_NestedPaths(t *testing.T) {
|
func TestResponsive_NestedPaths_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
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().
|
||||||
|
|
@ -62,15 +61,15 @@ func TestResponsive_NestedPaths(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
if !strings.Contains(got, `data-block="C-0-H-0"`) {
|
if !containsText(got, `data-block="C-0-H-0"`) {
|
||||||
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||||
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsive_VariantsIndependent(t *testing.T) {
|
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
||||||
ctx := NewContext()
|
ctx := NewContext()
|
||||||
r := NewResponsive().
|
r := NewResponsive().
|
||||||
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
||||||
|
|
@ -78,12 +77,60 @@ func TestResponsive_VariantsIndependent(t *testing.T) {
|
||||||
|
|
||||||
got := r.Render(ctx)
|
got := r.Render(ctx)
|
||||||
|
|
||||||
count := strings.Count(got, `data-block="C-0"`)
|
count := countText(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(t *testing.T) {
|
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
|
||||||
var _ Node = NewResponsive()
|
var _ Node = NewResponsive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
|
||||||
|
var r *Responsive
|
||||||
|
|
||||||
|
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil responsive from Variant on nil receiver")
|
||||||
|
}
|
||||||
|
|
||||||
|
if output := got.Render(NewContext()); output != `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>` {
|
||||||
|
t.Fatalf("unexpected output from nil receiver Variant path: %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsive_Render_NilContext_Good(t *testing.T) {
|
||||||
|
r := NewResponsive().
|
||||||
|
Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||||
|
|
||||||
|
got := r.Render(nil)
|
||||||
|
want := `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantSelector_Good(t *testing.T) {
|
||||||
|
got := VariantSelector("desktop")
|
||||||
|
want := `[data-variant="desktop"]`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantSelector_Escapes_Good(t *testing.T) {
|
||||||
|
got := VariantSelector("desk\"top\\wide")
|
||||||
|
want := `[data-variant="desk\"top\\wide"]`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
|
||||||
|
got := VariantSelector("a\tb\nc\u0007")
|
||||||
|
want := `[data-variant="a\9 b\A c\7 "]`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
11
specs/cmd/codegen.md
Normal file
11
specs/cmd/codegen.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# main
|
||||||
|
**Import:** `dappco.re/go/core/html/cmd/codegen`
|
||||||
|
**Files:** 1
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
None.
|
||||||
11
specs/cmd/wasm.md
Normal file
11
specs/cmd/wasm.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# main
|
||||||
|
**Import:** `dappco.re/go/core/html/cmd/wasm`
|
||||||
|
**Files:** 2
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
None.
|
||||||
34
specs/codegen.md
Normal file
34
specs/codegen.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# codegen
|
||||||
|
**Import:** `dappco.re/go/core/html/codegen`
|
||||||
|
**Files:** 2
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `GenerateBundle`
|
||||||
|
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||||
|
|
||||||
|
GenerateBundle produces all WC class definitions and registrations
|
||||||
|
for a set of HLCRF slot assignments.
|
||||||
|
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||||
|
|
||||||
|
### `GenerateClass`
|
||||||
|
`func GenerateClass(tag, slot string) (string, error)`
|
||||||
|
|
||||||
|
GenerateClass produces a JS class definition for a custom element.
|
||||||
|
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||||
|
|
||||||
|
### `GenerateRegistration`
|
||||||
|
`func GenerateRegistration(tag, className string) string`
|
||||||
|
|
||||||
|
GenerateRegistration produces the customElements.define() call.
|
||||||
|
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||||
|
|
||||||
|
### `TagToClassName`
|
||||||
|
`func TagToClassName(tag string) string`
|
||||||
|
|
||||||
|
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||||
|
Usage example: className := TagToClassName("nav-bar")
|
||||||
34
specs/codegen/RFC.md
Normal file
34
specs/codegen/RFC.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# codegen
|
||||||
|
**Import:** `dappco.re/go/core/html/codegen`
|
||||||
|
**Files:** 2
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `GenerateBundle`
|
||||||
|
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||||
|
|
||||||
|
GenerateBundle produces all WC class definitions and registrations
|
||||||
|
for a set of HLCRF slot assignments.
|
||||||
|
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||||
|
|
||||||
|
### `GenerateClass`
|
||||||
|
`func GenerateClass(tag, slot string) (string, error)`
|
||||||
|
|
||||||
|
GenerateClass produces a JS class definition for a custom element.
|
||||||
|
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||||
|
|
||||||
|
### `GenerateRegistration`
|
||||||
|
`func GenerateRegistration(tag, className string) string`
|
||||||
|
|
||||||
|
GenerateRegistration produces the customElements.define() call.
|
||||||
|
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||||
|
|
||||||
|
### `TagToClassName`
|
||||||
|
`func TagToClassName(tag string) string`
|
||||||
|
|
||||||
|
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||||
|
Usage example: className := TagToClassName("nav-bar")
|
||||||
225
specs/root.md
Normal file
225
specs/root.md
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# html
|
||||||
|
**Import:** `dappco.re/go/core/html`
|
||||||
|
**Files:** 13
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### `Context`
|
||||||
|
`type Context struct`
|
||||||
|
|
||||||
|
Context carries rendering state through the node tree.
|
||||||
|
Usage example: ctx := NewContext()
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Identity string`
|
||||||
|
- `Locale string`
|
||||||
|
- `Entitlements func(feature string) bool`
|
||||||
|
- `Data map[string]any`
|
||||||
|
- Unexported fields are present.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
None.
|
||||||
|
|
||||||
|
### `Layout`
|
||||||
|
`type Layout struct`
|
||||||
|
|
||||||
|
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||||
|
with deterministic path-based IDs.
|
||||||
|
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- No exported fields.
|
||||||
|
- Unexported fields are present.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `func (l *Layout) C(nodes ...Node) *Layout`
|
||||||
|
C appends nodes to the Content (main) slot.
|
||||||
|
Usage example: NewLayout("C").C(Text("body"))
|
||||||
|
- `func (l *Layout) F(nodes ...Node) *Layout`
|
||||||
|
F appends nodes to the Footer slot.
|
||||||
|
Usage example: NewLayout("CF").F(Text("footer"))
|
||||||
|
- `func (l *Layout) H(nodes ...Node) *Layout`
|
||||||
|
H appends nodes to the Header slot.
|
||||||
|
Usage example: NewLayout("HCF").H(Text("title"))
|
||||||
|
- `func (l *Layout) L(nodes ...Node) *Layout`
|
||||||
|
L appends nodes to the Left aside slot.
|
||||||
|
Usage example: NewLayout("LC").L(Text("nav"))
|
||||||
|
- `func (l *Layout) R(nodes ...Node) *Layout`
|
||||||
|
R appends nodes to the Right aside slot.
|
||||||
|
Usage example: NewLayout("CR").R(Text("ads"))
|
||||||
|
- `func (l *Layout) Render(ctx *Context) string`
|
||||||
|
Render produces the semantic HTML for this layout.
|
||||||
|
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||||
|
Only slots present in the variant string are rendered.
|
||||||
|
|
||||||
|
### `Node`
|
||||||
|
`type Node interface`
|
||||||
|
|
||||||
|
Node is anything renderable.
|
||||||
|
Usage example: var n Node = El("div", Text("welcome"))
|
||||||
|
|
||||||
|
Members:
|
||||||
|
- `Render(ctx *Context) string`
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
None.
|
||||||
|
|
||||||
|
### `Responsive`
|
||||||
|
`type Responsive struct`
|
||||||
|
|
||||||
|
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||||
|
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||||
|
Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- No exported fields.
|
||||||
|
- Unexported fields are present.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `func (r *Responsive) Render(ctx *Context) string`
|
||||||
|
Render produces HTML with each variant in a data-variant container.
|
||||||
|
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||||
|
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
|
||||||
|
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||||
|
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||||
|
Variants render in insertion order.
|
||||||
|
|
||||||
|
### `Translator`
|
||||||
|
`type Translator interface`
|
||||||
|
|
||||||
|
Translator provides Text() lookups for a rendering context.
|
||||||
|
Usage example: ctx := NewContextWithService(myTranslator)
|
||||||
|
|
||||||
|
The default server build uses go-i18n. Alternate builds, including WASM,
|
||||||
|
can provide any implementation with the same T() method.
|
||||||
|
|
||||||
|
Members:
|
||||||
|
- `T(key string, args ...any) string`
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `Attr`
|
||||||
|
`func Attr(n Node, key, value string) Node`
|
||||||
|
|
||||||
|
Attr sets an attribute on an El node. Returns the node for chaining.
|
||||||
|
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||||
|
It recursively traverses through wrappers like If, Unless, and Entitled.
|
||||||
|
|
||||||
|
### `CompareVariants`
|
||||||
|
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
|
||||||
|
|
||||||
|
CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||||
|
and returns pairwise similarity scores. Key format: "name1:name2".
|
||||||
|
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||||
|
|
||||||
|
### `Each`
|
||||||
|
`func Each[T any](items []T, fn func(T) Node) Node`
|
||||||
|
|
||||||
|
Each iterates items and renders each via fn.
|
||||||
|
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||||
|
|
||||||
|
### `EachSeq`
|
||||||
|
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
|
||||||
|
|
||||||
|
EachSeq iterates an iter.Seq and renders each via fn.
|
||||||
|
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||||
|
|
||||||
|
### `El`
|
||||||
|
`func El(tag string, children ...Node) Node`
|
||||||
|
|
||||||
|
El creates an HTML element node with children.
|
||||||
|
Usage example: El("section", Text("welcome"))
|
||||||
|
|
||||||
|
### `Entitled`
|
||||||
|
`func Entitled(feature string, node Node) Node`
|
||||||
|
|
||||||
|
Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||||
|
Usage example: Entitled("beta", Text("preview"))
|
||||||
|
If no entitlement function is set on the context, access is denied by default.
|
||||||
|
|
||||||
|
### `If`
|
||||||
|
`func If(cond func(*Context) bool, node Node) Node`
|
||||||
|
|
||||||
|
If renders child only when condition is true.
|
||||||
|
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||||
|
|
||||||
|
### `Imprint`
|
||||||
|
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
|
||||||
|
|
||||||
|
Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||||
|
and returns a GrammarImprint — the full render-reverse pipeline.
|
||||||
|
Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||||
|
|
||||||
|
### `NewContext`
|
||||||
|
`func NewContext() *Context`
|
||||||
|
|
||||||
|
NewContext creates a new rendering context with sensible defaults.
|
||||||
|
Usage example: html := Render(Text("welcome"), NewContext())
|
||||||
|
|
||||||
|
### `NewContextWithService`
|
||||||
|
`func NewContextWithService(svc Translator) *Context`
|
||||||
|
|
||||||
|
NewContextWithService creates a rendering context backed by a specific translator.
|
||||||
|
Usage example: ctx := NewContextWithService(myTranslator)
|
||||||
|
|
||||||
|
### `NewLayout`
|
||||||
|
`func NewLayout(variant string) *Layout`
|
||||||
|
|
||||||
|
NewLayout creates a new Layout with the given variant string.
|
||||||
|
Usage example: page := NewLayout("HLCRF")
|
||||||
|
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||||
|
|
||||||
|
### `NewResponsive`
|
||||||
|
`func NewResponsive() *Responsive`
|
||||||
|
|
||||||
|
NewResponsive creates a new multi-variant responsive compositor.
|
||||||
|
Usage example: r := NewResponsive()
|
||||||
|
|
||||||
|
### `ParseBlockID`
|
||||||
|
`func ParseBlockID(id string) []byte`
|
||||||
|
|
||||||
|
ParseBlockID extracts the slot sequence from a data-block ID.
|
||||||
|
Usage example: slots := ParseBlockID("L-0-C-0")
|
||||||
|
"L-0-C-0" → ['L', 'C']
|
||||||
|
|
||||||
|
### `Raw`
|
||||||
|
`func Raw(content string) Node`
|
||||||
|
|
||||||
|
Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||||
|
Usage example: Raw("<strong>trusted</strong>")
|
||||||
|
|
||||||
|
### `Render`
|
||||||
|
`func Render(node Node, ctx *Context) string`
|
||||||
|
|
||||||
|
Render is a convenience function that renders a node tree to HTML.
|
||||||
|
Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||||
|
|
||||||
|
### `StripTags`
|
||||||
|
`func StripTags(html string) string`
|
||||||
|
|
||||||
|
StripTags removes HTML tags from rendered output, returning plain text.
|
||||||
|
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||||
|
Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||||
|
Does not handle script/style element content (go-html does not generate these).
|
||||||
|
|
||||||
|
### `Switch`
|
||||||
|
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
|
||||||
|
|
||||||
|
Switch renders based on runtime selector value.
|
||||||
|
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||||
|
|
||||||
|
### `Text`
|
||||||
|
`func Text(key string, args ...any) Node`
|
||||||
|
|
||||||
|
Text creates a node that renders through the go-i18n grammar pipeline.
|
||||||
|
Usage example: Text("welcome", "Ada")
|
||||||
|
Output is HTML-escaped by default. Safe-by-default path.
|
||||||
|
|
||||||
|
### `Unless`
|
||||||
|
`func Unless(cond func(*Context) bool, node Node) Node`
|
||||||
|
|
||||||
|
Unless renders child only when condition is false.
|
||||||
|
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||||
48
test_helpers_test.go
Normal file
48
test_helpers_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
|
func containsText(s, substr string) bool {
|
||||||
|
return core.Contains(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countText(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return len(s) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := 0; i <= len(s)-len(substr); {
|
||||||
|
j := indexText(s[i:], substr)
|
||||||
|
if j < 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
i += j + len(substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexText(s, substr string) int {
|
||||||
|
if substr == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(substr) > len(s) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoaText(v int) string {
|
||||||
|
return core.Sprint(v)
|
||||||
|
}
|
||||||
38
text_builder_default.go
Normal file
38
text_builder_default.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
|
type builderOps interface {
|
||||||
|
WriteByte(byte) error
|
||||||
|
WriteRune(rune) (int, error)
|
||||||
|
WriteString(string) (int, error)
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type textBuilder struct {
|
||||||
|
inner builderOps
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTextBuilder() *textBuilder {
|
||||||
|
return &textBuilder{inner: core.NewBuilder()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteByte(c byte) error {
|
||||||
|
return b.inner.WriteByte(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||||
|
return b.inner.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||||
|
return b.inner.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) String() string {
|
||||||
|
return b.inner.String()
|
||||||
|
}
|
||||||
33
text_builder_js.go
Normal file
33
text_builder_js.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
type textBuilder struct {
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTextBuilder() *textBuilder {
|
||||||
|
return &textBuilder{buf: make([]byte, 0, 128)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteByte(c byte) error {
|
||||||
|
b.buf = append(b.buf, c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||||
|
s := string(r)
|
||||||
|
b.buf = append(b.buf, s...)
|
||||||
|
return len(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||||
|
b.buf = append(b.buf, s...)
|
||||||
|
return len(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *textBuilder) String() string {
|
||||||
|
return string(b.buf)
|
||||||
|
}
|
||||||
11
text_translate.go
Normal file
11
text_translate.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
func translateText(ctx *Context, key string, args ...any) string {
|
||||||
|
if ctx != nil && ctx.service != nil {
|
||||||
|
return ctx.service.T(key, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return translateDefault(key, args...)
|
||||||
|
}
|
||||||
11
text_translate_default.go
Normal file
11
text_translate_default.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import i18n "dappco.re/go/core/i18n"
|
||||||
|
|
||||||
|
func translateDefault(key string, args ...any) string {
|
||||||
|
return i18n.T(key, args...)
|
||||||
|
}
|
||||||
9
text_translate_js.go
Normal file
9
text_translate_js.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
func translateDefault(key string, _ ...any) string {
|
||||||
|
return key
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue