Compare commits

...
Sign in to create a new pull request.

46 commits
main ... dev

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

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

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

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 23:10:48 +00:00
8c7a9de546 Merge pull request '[agent/codex] Update specs/codegen/RFC.md from codegen/*.go. Document ever...' (#13) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 56s
2026-03-27 21:50:48 +00:00
Virgil
33d9e0c516 docs(specs): add codegen RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 21:50:33 +00:00
adc9403883 Merge pull request '[agent/codex] A specs/ folder has been injected into this workspace with R...' (#12) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 44s
2026-03-27 19:56:49 +00:00
Virgil
f21562c555 docs: add generated package specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 19:56:29 +00:00
Claude
adcb98ee2f
chore: bump i18n v0.2.0 → v0.2.1
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 1m35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:26:33 +00:00
44e3478be0 Merge pull request '[agent/codex] Full AX v0.8.0 compliance review. Read CODEX.md and .core/re...' (#11) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 50s
2026-03-27 04:56:29 +00:00
Virgil
11f18a24d2 fix(tests): complete ax naming compliance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 04:55:58 +00:00
1c61fde5fc Merge pull request '[agent/codex] VERIFICATION PASS — report findings only. grep ALL .go fil...' (#10) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 46s
2026-03-27 03:17:33 +00:00
Virgil
df5035c3c4 chore: verification pass
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 03:16:36 +00:00
df19b84051 Merge pull request '[agent/codex] AX v0.8.0 polish pass. Fix ALL violations — banned imports...' (#9) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m31s
2026-03-26 18:12:42 +00:00
Virgil
3616ad3a76 chore: polish ax v0.8.0 conventions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 18:12:06 +00:00
2a5bd5cbba Merge pull request '[agent/codex] Upgrade this package to dappco.re/go/core v0.8.0-alpha.1. Re...' (#8) from agent/ax-review--banned-imports--test-naming into dev
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m31s
2026-03-26 15:24:50 +00:00
Virgil
b8d06460d6 refactor(core): upgrade to v0.8.0-alpha.1
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 15:24:16 +00:00
Virgil
0e976b3a87 fix(wasm): keep server i18n out of js builds
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:31:31 +00:00
Virgil
8a3f28aff3 fix(conventions): isolate banned imports and clarify tests
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:15:24 +00:00
48 changed files with 2163 additions and 314 deletions

View file

@ -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

View file

@ -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()

View file

@ -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)
} }
} }

View file

@ -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
}

View file

@ -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
View file

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

View file

@ -3,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)

View file

@ -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)
} }

View file

@ -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))
} }

View file

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

View file

@ -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')

View file

@ -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
View file

@ -0,0 +1,13 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
// Package codegen generates Web Component bundles for go-html slot maps.
//
// Use it at build time, or through the cmd/codegen CLI:
//
// bundle, err := GenerateBundle(map[string]string{
// "H": "site-header",
// "C": "app-main",
// })
package codegen

61
codegen/typescript.go Normal file
View file

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

View file

@ -1,27 +1,81 @@
package html 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
View file

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

12
doc.go Normal file
View file

@ -0,0 +1,12 @@
// SPDX-Licence-Identifier: EUPL-1.2
// Package html renders semantic HTML from composable node trees.
//
// A typical page combines Layout, El, Text, and Render:
//
// page := NewLayout("HCF").
// H(El("h1", Text("page.title"))).
// C(El("main", Text("page.body"))).
// F(El("small", Text("page.footer")))
// out := Render(page, NewContext())
package html

View file

@ -17,13 +17,18 @@ type Node interface {
} }
``` ```
All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers: All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
| Constructor | Behaviour | | 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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, "&amp;&lt;&gt;&#34;&#39;") { if !containsText(got, "&amp;&lt;&gt;&#34;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got) 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
View file

@ -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
View file

@ -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=

View file

@ -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
View file

@ -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
}

View file

@ -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
View file

@ -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()
} }

View file

@ -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, "&lt;script&gt;") { if !containsText(got, "&lt;script&gt;") {
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 &#34;hello&#34;"`) { if !containsText(got, `alt="he said &#34;hello&#34;"`) {
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
View file

@ -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
} }

View file

@ -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)
}
}
}

View file

@ -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})
} }

View file

@ -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(`&lt;script&gt;`) got := StripTags(`&lt;script&gt;`)
want := "&lt;script&gt;" want := "&lt;script&gt;"
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()

View file

@ -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()
} }

View file

@ -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, "&lt;script&gt;") { if !containsText(got, "&lt;script&gt;") {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got) t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
} }
} }

View file

@ -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()
}

View file

@ -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
View file

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

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

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

34
specs/codegen.md Normal file
View file

@ -0,0 +1,34 @@
# codegen
**Import:** `dappco.re/go/core/html/codegen`
**Files:** 2
## Types
None.
## Functions
### `GenerateBundle`
`func GenerateBundle(slots map[string]string) (string, error)`
GenerateBundle produces all WC class definitions and registrations
for a set of HLCRF slot assignments.
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
### `GenerateClass`
`func GenerateClass(tag, slot string) (string, error)`
GenerateClass produces a JS class definition for a custom element.
Usage example: js, err := GenerateClass("nav-bar", "H")
### `GenerateRegistration`
`func GenerateRegistration(tag, className string) string`
GenerateRegistration produces the customElements.define() call.
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
### `TagToClassName`
`func TagToClassName(tag string) string`
TagToClassName converts a kebab-case tag to PascalCase class name.
Usage example: className := TagToClassName("nav-bar")

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

@ -0,0 +1,34 @@
# codegen
**Import:** `dappco.re/go/core/html/codegen`
**Files:** 2
## Types
None.
## Functions
### `GenerateBundle`
`func GenerateBundle(slots map[string]string) (string, error)`
GenerateBundle produces all WC class definitions and registrations
for a set of HLCRF slot assignments.
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
### `GenerateClass`
`func GenerateClass(tag, slot string) (string, error)`
GenerateClass produces a JS class definition for a custom element.
Usage example: js, err := GenerateClass("nav-bar", "H")
### `GenerateRegistration`
`func GenerateRegistration(tag, className string) string`
GenerateRegistration produces the customElements.define() call.
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
### `TagToClassName`
`func TagToClassName(tag string) string`
TagToClassName converts a kebab-case tag to PascalCase class name.
Usage example: className := TagToClassName("nav-bar")

225
specs/root.md Normal file
View file

@ -0,0 +1,225 @@
# html
**Import:** `dappco.re/go/core/html`
**Files:** 13
## Types
### `Context`
`type Context struct`
Context carries rendering state through the node tree.
Usage example: ctx := NewContext()
Fields:
- `Identity string`
- `Locale string`
- `Entitlements func(feature string) bool`
- `Data map[string]any`
- Unexported fields are present.
Methods:
None.
### `Layout`
`type Layout struct`
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
with deterministic path-based IDs.
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
Fields:
- No exported fields.
- Unexported fields are present.
Methods:
- `func (l *Layout) C(nodes ...Node) *Layout`
C appends nodes to the Content (main) slot.
Usage example: NewLayout("C").C(Text("body"))
- `func (l *Layout) F(nodes ...Node) *Layout`
F appends nodes to the Footer slot.
Usage example: NewLayout("CF").F(Text("footer"))
- `func (l *Layout) H(nodes ...Node) *Layout`
H appends nodes to the Header slot.
Usage example: NewLayout("HCF").H(Text("title"))
- `func (l *Layout) L(nodes ...Node) *Layout`
L appends nodes to the Left aside slot.
Usage example: NewLayout("LC").L(Text("nav"))
- `func (l *Layout) R(nodes ...Node) *Layout`
R appends nodes to the Right aside slot.
Usage example: NewLayout("CR").R(Text("ads"))
- `func (l *Layout) Render(ctx *Context) string`
Render produces the semantic HTML for this layout.
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
Only slots present in the variant string are rendered.
### `Node`
`type Node interface`
Node is anything renderable.
Usage example: var n Node = El("div", Text("welcome"))
Members:
- `Render(ctx *Context) string`
Methods:
None.
### `Responsive`
`type Responsive struct`
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
Each variant is rendered inside a container with data-variant for CSS targeting.
Fields:
- No exported fields.
- Unexported fields are present.
Methods:
- `func (r *Responsive) Render(ctx *Context) string`
Render produces HTML with each variant in a data-variant container.
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
Variants render in insertion order.
### `Translator`
`type Translator interface`
Translator provides Text() lookups for a rendering context.
Usage example: ctx := NewContextWithService(myTranslator)
The default server build uses go-i18n. Alternate builds, including WASM,
can provide any implementation with the same T() method.
Members:
- `T(key string, args ...any) string`
Methods:
None.
## Functions
### `Attr`
`func Attr(n Node, key, value string) Node`
Attr sets an attribute on an El node. Returns the node for chaining.
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
It recursively traverses through wrappers like If, Unless, and Entitled.
### `CompareVariants`
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
CompareVariants runs the imprint pipeline on each responsive variant independently
and returns pairwise similarity scores. Key format: "name1:name2".
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
### `Each`
`func Each[T any](items []T, fn func(T) Node) Node`
Each iterates items and renders each via fn.
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
### `EachSeq`
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
EachSeq iterates an iter.Seq and renders each via fn.
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
### `El`
`func El(tag string, children ...Node) Node`
El creates an HTML element node with children.
Usage example: El("section", Text("welcome"))
### `Entitled`
`func Entitled(feature string, node Node) Node`
Entitled renders child only when entitlement is granted. Absent, not hidden.
Usage example: Entitled("beta", Text("preview"))
If no entitlement function is set on the context, access is denied by default.
### `If`
`func If(cond func(*Context) bool, node Node) Node`
If renders child only when condition is true.
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
### `Imprint`
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
Imprint renders a node tree to HTML, strips tags, tokenises the text,
and returns a GrammarImprint — the full render-reverse pipeline.
Usage example: imp := Imprint(Text("welcome"), NewContext())
### `NewContext`
`func NewContext() *Context`
NewContext creates a new rendering context with sensible defaults.
Usage example: html := Render(Text("welcome"), NewContext())
### `NewContextWithService`
`func NewContextWithService(svc Translator) *Context`
NewContextWithService creates a rendering context backed by a specific translator.
Usage example: ctx := NewContextWithService(myTranslator)
### `NewLayout`
`func NewLayout(variant string) *Layout`
NewLayout creates a new Layout with the given variant string.
Usage example: page := NewLayout("HLCRF")
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
### `NewResponsive`
`func NewResponsive() *Responsive`
NewResponsive creates a new multi-variant responsive compositor.
Usage example: r := NewResponsive()
### `ParseBlockID`
`func ParseBlockID(id string) []byte`
ParseBlockID extracts the slot sequence from a data-block ID.
Usage example: slots := ParseBlockID("L-0-C-0")
"L-0-C-0" → ['L', 'C']
### `Raw`
`func Raw(content string) Node`
Raw creates a node that renders without escaping (escape hatch for trusted content).
Usage example: Raw("<strong>trusted</strong>")
### `Render`
`func Render(node Node, ctx *Context) string`
Render is a convenience function that renders a node tree to HTML.
Usage example: html := Render(El("main", Text("welcome")), NewContext())
### `StripTags`
`func StripTags(html string) string`
StripTags removes HTML tags from rendered output, returning plain text.
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
Tag boundaries are collapsed into single spaces; result is trimmed.
Does not handle script/style element content (go-html does not generate these).
### `Switch`
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
Switch renders based on runtime selector value.
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
### `Text`
`func Text(key string, args ...any) Node`
Text creates a node that renders through the go-i18n grammar pipeline.
Usage example: Text("welcome", "Ada")
Output is HTML-escaped by default. Safe-by-default path.
### `Unless`
`func Unless(cond func(*Context) bool, node Node) Node`
Unless renders child only when condition is false.
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))

48
test_helpers_test.go Normal file
View file

@ -0,0 +1,48 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
func containsText(s, substr string) bool {
return core.Contains(s, substr)
}
func countText(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexText(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexText(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func itoaText(v int) string {
return core.Sprint(v)
}

38
text_builder_default.go Normal file
View file

@ -0,0 +1,38 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import core "dappco.re/go/core"
type builderOps interface {
WriteByte(byte) error
WriteRune(rune) (int, error)
WriteString(string) (int, error)
String() string
}
type textBuilder struct {
inner builderOps
}
func newTextBuilder() *textBuilder {
return &textBuilder{inner: core.NewBuilder()}
}
func (b *textBuilder) WriteByte(c byte) error {
return b.inner.WriteByte(c)
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
return b.inner.WriteRune(r)
}
func (b *textBuilder) WriteString(s string) (int, error) {
return b.inner.WriteString(s)
}
func (b *textBuilder) String() string {
return b.inner.String()
}

33
text_builder_js.go Normal file
View file

@ -0,0 +1,33 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
type textBuilder struct {
buf []byte
}
func newTextBuilder() *textBuilder {
return &textBuilder{buf: make([]byte, 0, 128)}
}
func (b *textBuilder) WriteByte(c byte) error {
b.buf = append(b.buf, c)
return nil
}
func (b *textBuilder) WriteRune(r rune) (int, error) {
s := string(r)
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *textBuilder) String() string {
return string(b.buf)
}

11
text_translate.go Normal file
View file

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

11
text_translate_default.go Normal file
View file

@ -0,0 +1,11 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import i18n "dappco.re/go/core/i18n"
func translateDefault(key string, args ...any) string {
return i18n.T(key, args...)
}

9
text_translate_js.go Normal file
View file

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