Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a924b0be4 | ||
|
|
f543f02cc1 | ||
|
|
8402485489 | ||
|
|
5784b76990 | ||
|
|
70a3096518 | ||
|
|
8abd428227 | ||
|
|
c088e5a5ac | ||
|
|
1d11472136 | ||
|
|
2e2af31c1d | ||
|
|
b9e2630da3 | ||
|
|
c2ff591ec9 | ||
|
|
60d8225a83 | ||
|
|
8e9ca0091c | ||
|
|
cb901dbb71 | ||
|
|
4a3a69e8b7 | ||
|
|
14c16b5385 | ||
|
|
1f98026d04 | ||
|
|
8386c7e57d | ||
|
|
5d13a4028b | ||
|
|
a928d01b9e | ||
|
|
12a7d2497b | ||
|
|
c63f0a2cbe | ||
|
|
c1852f86aa | ||
|
|
4ae93ce36f | ||
|
|
65c0dd3e27 | ||
|
|
f9f0aa197b | ||
|
|
714d7adc90 | ||
|
|
911071d2b0 | ||
|
|
c6fd135239 | ||
|
|
cae46f9c61 | ||
|
|
0318d73a12 | ||
| 8c7a9de546 | |||
|
|
33d9e0c516 | ||
| adc9403883 | |||
|
|
f21562c555 | ||
|
|
adcb98ee2f | ||
| 44e3478be0 | |||
|
|
11f18a24d2 | ||
| 1c61fde5fc | |||
|
|
df5035c3c4 | ||
| df19b84051 | |||
|
|
3616ad3a76 | ||
| 2a5bd5cbba | |||
|
|
b8d06460d6 | ||
|
|
0e976b3a87 | ||
|
|
8a3f28aff3 | ||
| b3f622988d | |||
|
|
c525437ed6 | ||
|
|
666e3a68c6 | ||
|
|
913bbb555a | ||
|
|
63714ec9a1 | ||
|
|
0607c5b517 | ||
| ba26232b27 | |||
|
|
e532c219b9 |
52 changed files with 2302 additions and 335 deletions
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dAppCore/build/actions/build/core@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
run-vet: "true"
|
||||
|
||||
auto-fix:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'changes_requested'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: dAppCore/build/actions/fix@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Merge PR
|
||||
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
dist/
|
||||
.core/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.core/
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Agent instructions for `go-html`. Module path: `forge.lthn.ai/core/go-html`
|
||||
Agent instructions for `go-html`. Module path: `dappco.re/go/core/html`
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ See `docs/architecture.md` for full detail. Summary:
|
|||
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order
|
||||
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only)
|
||||
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
|
||||
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3 MB raw, < 1 MB gzip
|
||||
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
|
||||
|
||||
## Server/Client Split
|
||||
|
||||
|
|
@ -40,8 +40,9 @@ Files guarded with `//go:build !js` are excluded from WASM:
|
|||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
|
||||
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n)
|
||||
- `dappco.re/go/core/i18n` (replace directive → local go-i18n)
|
||||
- `forge.lthn.ai/core/go-inference` (indirect, via go-i18n; not yet migrated)
|
||||
- `forge.lthn.ai/core/go-log` (indirect, via go-i18n; not yet migrated)
|
||||
- Both `go-i18n` and `go-inference` must be cloned alongside this repo for builds
|
||||
- Go 1.26+ required (uses `range` over integers, `iter.Seq`, `maps.Keys`, `slices.Collect`)
|
||||
|
||||
|
|
@ -53,6 +54,8 @@ Files guarded with `//go:build !js` are excluded from WASM:
|
|||
- Licence: EUPL-1.2 — add `// SPDX-Licence-Identifier: EUPL-1.2` to new files
|
||||
- Safe-by-default: HTML escaping via `html.EscapeString()` on Text nodes and attribute values, void element handling, entitlement deny-by-default
|
||||
- Deterministic output: sorted attributes on El nodes, reproducible block ID paths
|
||||
- Errors: use `log.E("scope", "message", err)` from `go-log`, never `fmt.Errorf`
|
||||
- File I/O: use `coreio.Local` from `go-io`, never `os.ReadFile`/`os.WriteFile`
|
||||
- Commits: conventional commits + `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||
|
||||
## Test Conventions
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -1,8 +1,8 @@
|
|||
.PHONY: wasm test clean
|
||||
|
||||
WASM_OUT := dist/go-html.wasm
|
||||
# Raw size limit: 3MB (Go WASM has ~2MB runtime floor)
|
||||
WASM_RAW_LIMIT := 3145728
|
||||
# Raw size limit: 3.5MB (Go 1.26 WASM runtime growth)
|
||||
WASM_RAW_LIMIT := 3670016
|
||||
# Gzip transfer size limit: 1MB (what users actually download)
|
||||
WASM_GZ_LIMIT := 1048576
|
||||
|
||||
|
|
@ -21,9 +21,9 @@ $(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
|
|||
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
|
||||
exit 1; \
|
||||
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
|
||||
echo "WARNING: raw binary exceeds 3MB ($${RAW} bytes) — check imports"; \
|
||||
echo "WARNING: raw binary exceeds 3.5MB ($${RAW} bytes) — check imports"; \
|
||||
else \
|
||||
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3MB)"; \
|
||||
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3.5MB)"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# go-html
|
||||
|
||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
|
||||
|
||||
**Module**: `forge.lthn.ai/core/go-html`
|
||||
**Licence**: EUPL-1.2
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -100,7 +99,7 @@ func BenchmarkImprint_Small(b *testing.B) {
|
|||
func BenchmarkImprint_Large(b *testing.B) {
|
||||
items := make([]string, 20)
|
||||
for i := range items {
|
||||
items[i] = fmt.Sprintf("Item %d was created successfully", i)
|
||||
items[i] = "Item " + itoaText(i) + " was created successfully"
|
||||
}
|
||||
page := NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
|
|
@ -207,7 +206,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
|
|||
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
||||
nodes := make([]Node, 50)
|
||||
for i := range nodes {
|
||||
nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
|
||||
nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
|
||||
}
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).
|
||||
|
|
@ -242,7 +241,7 @@ func benchEach(b *testing.B, n int) {
|
|||
items[i] = i
|
||||
}
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("item-%d", i)))
|
||||
return El("li", Raw("item-"+itoaText(i)))
|
||||
})
|
||||
ctx := NewContext()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,181 @@
|
|||
// Package main provides a build-time CLI for generating Web Component JS bundles.
|
||||
// Reads a JSON slot map from stdin, writes the generated JS to stdout.
|
||||
//go:build !js
|
||||
|
||||
// Package main provides a build-time CLI for generating Web Component bundles.
|
||||
// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
|
||||
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
goio "io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-html/codegen"
|
||||
log "forge.lthn.ai/core/go-log"
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/html/codegen"
|
||||
coreio "dappco.re/go/core/io"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
func run(r 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)
|
||||
if err != nil {
|
||||
return log.E("codegen", "reading stdin", err)
|
||||
}
|
||||
|
||||
var slots map[string]string
|
||||
if err := json.Unmarshal(data, &slots); err != nil {
|
||||
return log.E("codegen", "invalid JSON", err)
|
||||
}
|
||||
|
||||
js, err := codegen.GenerateBundle(slots)
|
||||
out, err := generate(data, emitTypes)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func sameBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range len(a) {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Stdin, os.Stdout); err != nil {
|
||||
log.Error("codegen failed", "err", err)
|
||||
emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
|
||||
inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
|
||||
outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
|
||||
emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
|
||||
pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
|
||||
flag.Parse()
|
||||
|
||||
if *emitWatch {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := coreio.Local.Open("/dev/stdin")
|
||||
if err != nil {
|
||||
log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stdout, err := coreio.Local.Create("/dev/stdout")
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
if err := run(stdin, stdout, *emitTypes); err != nil {
|
||||
log.Error("codegen failed", "scope", "codegen.main", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,179 @@
|
|||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
goio "io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_Good(t *testing.T) {
|
||||
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_WritesBundle_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
js := output.String()
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
}
|
||||
|
||||
func TestRun_Bad_InvalidJSON(t *testing.T) {
|
||||
input := strings.NewReader(`not json`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidJSON_Bad(t *testing.T) {
|
||||
input := core.NewReader(`not json`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid JSON")
|
||||
}
|
||||
|
||||
func TestRun_Bad_InvalidTag(t *testing.T) {
|
||||
input := strings.NewReader(`{"H":"notag"}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidTag_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"notag"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hyphen")
|
||||
}
|
||||
|
||||
func TestRun_Good_Empty(t *testing.T) {
|
||||
input := strings.NewReader(`{}`)
|
||||
var output bytes.Buffer
|
||||
func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, &output)
|
||||
err := run(input, output, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "lowercase hyphenated name")
|
||||
}
|
||||
|
||||
func TestRun_EmptySlots_Good(t *testing.T) {
|
||||
input := core.NewReader(`{}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, output.String())
|
||||
}
|
||||
|
||||
func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
|
||||
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
|
||||
output := core.NewBuilder()
|
||||
|
||||
err := run(input, output, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
dts := output.String()
|
||||
assert.Contains(t, dts, "declare global")
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.Contains(t, dts, `"main-content": MainContent;`)
|
||||
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
|
||||
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
|
||||
}
|
||||
|
||||
func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inputPath := filepath.Join(dir, "slots.json")
|
||||
outputPath := filepath.Join(dir, "bundle.js")
|
||||
|
||||
require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got, err := readTextFile(outputPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
|
||||
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "watch mode requires -input")
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func writeTextFile(path, content string) error {
|
||||
f, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
_, err = goio.WriteString(f, content)
|
||||
return err
|
||||
}
|
||||
|
||||
func readTextFile(path string) (string, error) {
|
||||
f, err := coreio.Local.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
data, err := goio.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package main
|
|||
import (
|
||||
"syscall/js"
|
||||
|
||||
html "forge.lthn.ai/core/go-html"
|
||||
html "dappco.re/go/core/html"
|
||||
)
|
||||
|
||||
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
|
||||
|
|
@ -13,15 +13,19 @@ import (
|
|||
// This is intentional: the WASM module is a rendering engine for trusted content
|
||||
// produced server-side or by the application's own templates.
|
||||
func renderToString(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
if len(args) < 1 || args[0].Type() != js.TypeString {
|
||||
return ""
|
||||
}
|
||||
|
||||
variant := args[0].String()
|
||||
if variant == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx := html.NewContext()
|
||||
|
||||
if len(args) >= 2 {
|
||||
ctx.Locale = args[1].String()
|
||||
if len(args) >= 2 && args[1].Type() == js.TypeString {
|
||||
ctx.SetLocale(args[1].String())
|
||||
}
|
||||
|
||||
layout := html.NewLayout(variant)
|
||||
|
|
|
|||
55
cmd/wasm/main_test.go
Normal file
55
cmd/wasm/main_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
func TestRenderToString_Good(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf("en-GB"),
|
||||
js.ValueOf(map[string]any{"C": "<strong>hello</strong>"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C-0"><strong>hello</strong></main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_VariantTypeGuard(t *testing.T) {
|
||||
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
|
||||
t.Fatalf("non-string variant should be empty, got %q", got)
|
||||
}
|
||||
|
||||
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
|
||||
t.Fatalf("missing variant should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
|
||||
gotAny := renderToString(js.Value{}, []js.Value{
|
||||
js.ValueOf("C"),
|
||||
js.ValueOf(123),
|
||||
js.ValueOf(map[string]any{"C": "x"}),
|
||||
})
|
||||
|
||||
got, ok := gotAny.(string)
|
||||
if !ok {
|
||||
t.Fatalf("renderToString should return string, got %T", gotAny)
|
||||
}
|
||||
|
||||
want := `<main role="main" data-block="C-0">x</main>`
|
||||
if got != want {
|
||||
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"forge.lthn.ai/core/go-html/codegen"
|
||||
log "forge.lthn.ai/core/go-log"
|
||||
"dappco.re/go/core/html/codegen"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
|
||||
|
|
@ -15,7 +15,8 @@ import (
|
|||
// Use cmd/codegen/ CLI instead for build-time generation.
|
||||
func buildComponentJS(slotsJSON string) (string, error) {
|
||||
var slots map[string]string
|
||||
if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
|
||||
if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
return "", log.E("buildComponentJS", "unmarshal JSON", err)
|
||||
}
|
||||
return codegen.GenerateBundle(slots)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildComponentJS_Good(t *testing.T) {
|
||||
func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
|
||||
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
|
||||
js, err := buildComponentJS(slotsJSON)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -18,7 +18,7 @@ func TestBuildComponentJS_Good(t *testing.T) {
|
|||
assert.Contains(t, js, "customElements.define")
|
||||
}
|
||||
|
||||
func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
|
||||
func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
|
||||
_, err := buildComponentJS("not json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
process "dappco.re/go/core/process"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -21,34 +20,44 @@ const (
|
|||
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
|
||||
)
|
||||
|
||||
func TestWASMBinarySize_Good(t *testing.T) {
|
||||
func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping WASM build test in short mode")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "gohtml.wasm")
|
||||
out := core.Path(dir, "gohtml.wasm")
|
||||
|
||||
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
output, err := cmd.CombinedOutput()
|
||||
factory := process.NewService(process.Options{})
|
||||
serviceValue, err := factory(core.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, ok := serviceValue.(*process.Service)
|
||||
require.True(t, ok, "process service factory returned %T", serviceValue)
|
||||
|
||||
output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
|
||||
Command: "go",
|
||||
Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
|
||||
Dir: ".",
|
||||
Env: []string{"GOOS=js", "GOARCH=wasm"},
|
||||
})
|
||||
require.NoError(t, err, "WASM build failed: %s", output)
|
||||
|
||||
rawStr, err := coreio.Local.Read(out)
|
||||
require.NoError(t, err)
|
||||
raw := []byte(rawStr)
|
||||
rawBytes := []byte(rawStr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
buf := core.NewBuilder()
|
||||
gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
|
||||
require.NoError(t, err)
|
||||
_, err = gz.Write(raw)
|
||||
_, err = gz.Write(rawBytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
|
||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len())
|
||||
t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
|
||||
|
||||
assert.Less(t, buf.Len(), wasmGzLimit,
|
||||
"WASM gzip size %d exceeds 1MB limit", buf.Len())
|
||||
assert.Less(t, len(raw), wasmRawLimit,
|
||||
"WASM raw size %d exceeds 3MB limit", len(raw))
|
||||
assert.Less(t, len(rawBytes), wasmRawLimit,
|
||||
"WASM raw size %d exceeds 3MB limit", len(rawBytes))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import "testing"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,40 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sort"
|
||||
"text/template"
|
||||
|
||||
log "forge.lthn.ai/core/go-log"
|
||||
core "dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// isValidCustomElementTag reports whether tag is a safe custom element name.
|
||||
// The generator rejects values that would fail at customElements.define() time.
|
||||
func isValidCustomElementTag(tag string) bool {
|
||||
if tag == "" || !core.Contains(tag, "-") {
|
||||
return false
|
||||
}
|
||||
|
||||
if tag[0] < 'a' || tag[0] > 'z' {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range len(tag) {
|
||||
ch := tag[i]
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
case ch >= '0' && ch <= '9':
|
||||
case ch == '-' || ch == '.' || ch == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// wcTemplate is the Web Component class template.
|
||||
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
|
||||
// DOM API using trusted go-html codegen output (never user input).
|
||||
|
|
@ -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.
|
||||
// Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
func GenerateClass(tag, slot string) (string, error) {
|
||||
if !strings.Contains(tag, "-") {
|
||||
return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil)
|
||||
if !isValidCustomElementTag(tag) {
|
||||
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
||||
}
|
||||
var b strings.Builder
|
||||
err := wcTemplate.Execute(&b, struct {
|
||||
b := core.NewBuilder()
|
||||
err := wcTemplate.Execute(b, struct {
|
||||
ClassName, Tag, Slot string
|
||||
}{
|
||||
ClassName: TagToClassName(tag),
|
||||
|
|
@ -50,16 +78,18 @@ func GenerateClass(tag, slot string) (string, error) {
|
|||
}
|
||||
|
||||
// GenerateRegistration produces the customElements.define() call.
|
||||
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
func GenerateRegistration(tag, className string) string {
|
||||
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
|
||||
return `customElements.define("` + tag + `", ` + className + `);`
|
||||
}
|
||||
|
||||
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
// Usage example: className := TagToClassName("nav-bar")
|
||||
func TagToClassName(tag string) string {
|
||||
var b strings.Builder
|
||||
for p := range strings.SplitSeq(tag, "-") {
|
||||
b := core.NewBuilder()
|
||||
for _, p := range core.Split(tag, "-") {
|
||||
if len(p) > 0 {
|
||||
b.WriteString(strings.ToUpper(p[:1]))
|
||||
b.WriteString(core.Upper(p[:1]))
|
||||
b.WriteString(p[1:])
|
||||
}
|
||||
}
|
||||
|
|
@ -68,11 +98,18 @@ func TagToClassName(tag string) string {
|
|||
|
||||
// GenerateBundle produces all WC class definitions and registrations
|
||||
// for a set of HLCRF slot assignments.
|
||||
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
func GenerateBundle(slots map[string]string) (string, error) {
|
||||
seen := make(map[string]bool)
|
||||
var b strings.Builder
|
||||
b := core.NewBuilder()
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for slot, tag := range slots {
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
|
|
@ -80,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
|
|||
|
||||
cls, err := GenerateClass(tag, slot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
||||
}
|
||||
b.WriteString(cls)
|
||||
b.WriteByte('\n')
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
|
|
@ -8,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateClass_Good(t *testing.T) {
|
||||
func TestGenerateClass_ValidTag_Good(t *testing.T) {
|
||||
js, err := GenerateClass("photo-grid", "C")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
|
||||
|
|
@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) {
|
|||
assert.Contains(t, js, "photo-grid")
|
||||
}
|
||||
|
||||
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
|
||||
func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
|
||||
_, err := GenerateClass("invalid", "C")
|
||||
assert.Error(t, err, "custom element names must contain a hyphen")
|
||||
|
||||
_, err = GenerateClass("Nav-Bar", "C")
|
||||
assert.Error(t, err, "custom element names must be lowercase")
|
||||
|
||||
_, err = GenerateClass("nav bar", "C")
|
||||
assert.Error(t, err, "custom element names must reject spaces")
|
||||
}
|
||||
|
||||
func TestGenerateRegistration_Good(t *testing.T) {
|
||||
func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
|
||||
js := GenerateRegistration("photo-grid", "PhotoGrid")
|
||||
assert.Contains(t, js, "customElements.define")
|
||||
assert.Contains(t, js, `"photo-grid"`)
|
||||
assert.Contains(t, js, "PhotoGrid")
|
||||
}
|
||||
|
||||
func TestTagToClassName_Good(t *testing.T) {
|
||||
func TestTagToClassName_KebabCase_Good(t *testing.T) {
|
||||
tests := []struct{ tag, want string }{
|
||||
{"photo-grid", "PhotoGrid"},
|
||||
{"nav-breadcrumb", "NavBreadcrumb"},
|
||||
|
|
@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateBundle_Good(t *testing.T) {
|
||||
func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
"F": "nav-bar",
|
||||
}
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, js, "NavBar")
|
||||
assert.Contains(t, js, "MainContent")
|
||||
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 2, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "main-content",
|
||||
}
|
||||
|
||||
js, err := GenerateBundle(slots)
|
||||
require.NoError(t, err)
|
||||
|
||||
alpha := strings.Index(js, "class AlphaPanel")
|
||||
main := strings.Index(js, "class MainContent")
|
||||
zed := strings.Index(js, "class ZedPanel")
|
||||
|
||||
assert.NotEqual(t, -1, alpha)
|
||||
assert.NotEqual(t, -1, main)
|
||||
assert.NotEqual(t, -1, zed)
|
||||
assert.Less(t, alpha, main)
|
||||
assert.Less(t, main, zed)
|
||||
assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
|
||||
assert.Equal(t, 3, countSubstr(js, "customElements.define"))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"Z": "zed-panel",
|
||||
"A": "alpha-panel",
|
||||
"M": "alpha-panel",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
|
||||
assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
|
||||
assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
|
||||
assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
|
||||
assert.Contains(t, dts, "export {};")
|
||||
assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "Nav-Bar",
|
||||
"F": "nav bar",
|
||||
}
|
||||
|
||||
dts := GenerateTypeScriptDefinitions(slots)
|
||||
|
||||
assert.Contains(t, dts, `"nav-bar": NavBar;`)
|
||||
assert.NotContains(t, dts, "Nav-Bar")
|
||||
assert.NotContains(t, dts, "nav bar")
|
||||
assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
|
||||
}
|
||||
|
||||
func countSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexSubstr(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexSubstr(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
13
codegen/doc.go
Normal file
13
codegen/doc.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package codegen generates Web Component bundles for go-html slot maps.
|
||||
//
|
||||
// Use it at build time, or through the cmd/codegen CLI:
|
||||
//
|
||||
// bundle, err := GenerateBundle(map[string]string{
|
||||
// "H": "site-header",
|
||||
// "C": "app-main",
|
||||
// })
|
||||
package codegen
|
||||
61
codegen/typescript.go
Normal file
61
codegen/typescript.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
|
||||
// a set of custom elements generated from HLCRF slot assignments.
|
||||
// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
|
||||
func GenerateTypeScriptDefinitions(slots map[string]string) string {
|
||||
seen := make(map[string]bool)
|
||||
declared := make(map[string]bool)
|
||||
b := core.NewBuilder()
|
||||
|
||||
keys := make([]string, 0, len(slots))
|
||||
for slot := range slots {
|
||||
keys = append(keys, slot)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteString("declare global {\n")
|
||||
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !isValidCustomElementTag(tag) || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
b.WriteString(" \"")
|
||||
b.WriteString(tag)
|
||||
b.WriteString("\": ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(";\n")
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
for _, slot := range keys {
|
||||
tag := slots[slot]
|
||||
if !seen[tag] || declared[tag] {
|
||||
continue
|
||||
}
|
||||
declared[tag] = true
|
||||
b.WriteString("export declare class ")
|
||||
b.WriteString(TagToClassName(tag))
|
||||
b.WriteString(" extends HTMLElement {\n")
|
||||
b.WriteString(" connectedCallback(): void;\n")
|
||||
b.WriteString(" render(html: string): void;\n")
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("export {};\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
74
context.go
74
context.go
|
|
@ -1,27 +1,81 @@
|
|||
package html
|
||||
|
||||
import i18n "forge.lthn.ai/core/go-i18n"
|
||||
// Translator provides Text() lookups for a rendering context.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator)
|
||||
//
|
||||
// The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
// can provide any implementation with the same T() method.
|
||||
type Translator interface {
|
||||
T(key string, args ...any) string
|
||||
}
|
||||
|
||||
// Context carries rendering state through the node tree.
|
||||
// Usage example: ctx := NewContext()
|
||||
type Context struct {
|
||||
Identity string
|
||||
Locale string
|
||||
Entitlements func(feature string) bool
|
||||
Data map[string]any
|
||||
service *i18n.Service
|
||||
service Translator
|
||||
}
|
||||
|
||||
func applyLocaleToService(svc Translator, locale string) {
|
||||
if svc == nil || locale == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
|
||||
base := locale
|
||||
for i := 0; i < len(base); i++ {
|
||||
if base[i] == '-' || base[i] == '_' {
|
||||
base = base[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = setter.SetLanguage(base)
|
||||
}
|
||||
}
|
||||
|
||||
// NewContext creates a new rendering context with sensible defaults.
|
||||
func NewContext() *Context {
|
||||
return &Context{
|
||||
// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
|
||||
func NewContext(locale ...string) *Context {
|
||||
ctx := &Context{
|
||||
Data: make(map[string]any),
|
||||
}
|
||||
if len(locale) > 0 {
|
||||
ctx.SetLocale(locale[0])
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// NewContextWithService creates a rendering context backed by a specific i18n service.
|
||||
func NewContextWithService(svc *i18n.Service) *Context {
|
||||
return &Context{
|
||||
Data: make(map[string]any),
|
||||
service: svc,
|
||||
}
|
||||
// NewContextWithService creates a rendering context backed by a specific translator.
|
||||
// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
|
||||
func NewContextWithService(svc Translator, locale ...string) *Context {
|
||||
ctx := NewContext(locale...)
|
||||
ctx.SetService(svc)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetService swaps the translator used by the context.
|
||||
// Usage example: ctx.SetService(myTranslator)
|
||||
func (ctx *Context) SetService(svc Translator) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.service = svc
|
||||
applyLocaleToService(svc, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// SetLocale updates the context locale and reapplies it to the active translator.
|
||||
// Usage example: ctx.SetLocale("en-GB")
|
||||
func (ctx *Context) SetLocale(locale string) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.Locale = locale
|
||||
applyLocaleToService(ctx.service, ctx.Locale)
|
||||
return ctx
|
||||
}
|
||||
|
|
|
|||
90
context_test.go
Normal file
90
context_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestNewContext_OptionalLocale_Good(t *testing.T) {
|
||||
ctx := NewContext("en-GB")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContext returned nil")
|
||||
}
|
||||
if ctx.Locale != "en-GB" {
|
||||
t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
|
||||
}
|
||||
if ctx.Data == nil {
|
||||
t.Fatal("NewContext should initialise Data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("NewContextWithService returned nil")
|
||||
}
|
||||
if ctx.Locale != "fr-FR" {
|
||||
t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
|
||||
}
|
||||
if ctx.service == nil {
|
||||
t.Fatal("NewContextWithService should set translator service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc, "fr-FR")
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContext("fr-FR")
|
||||
|
||||
if got := ctx.SetService(svc); got != ctx {
|
||||
t.Fatal("SetService should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetService locale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetService_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetService(nil); got != nil {
|
||||
t.Fatal("SetService on nil context should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
|
||||
if got := ctx.SetLocale("fr-FR"); got != ctx {
|
||||
t.Fatal("SetLocale should return the same context for chaining")
|
||||
}
|
||||
|
||||
got := Text("prompt.yes").Render(ctx)
|
||||
if got != "o" {
|
||||
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
|
||||
var ctx *Context
|
||||
if got := ctx.SetLocale("en-GB"); got != nil {
|
||||
t.Fatal("SetLocale on nil context should return nil")
|
||||
}
|
||||
}
|
||||
12
doc.go
Normal file
12
doc.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Package html renders semantic HTML from composable node trees.
|
||||
//
|
||||
// A typical page combines Layout, El, Text, and Render:
|
||||
//
|
||||
// page := NewLayout("HCF").
|
||||
// H(El("h1", Text("page.title"))).
|
||||
// C(El("main", Text("page.body"))).
|
||||
// F(El("small", Text("page.footer")))
|
||||
// out := Render(page, NewContext())
|
||||
package html
|
||||
|
|
@ -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 |
|
||||
|-------------|-----------|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -50,16 +55,16 @@ type Context struct {
|
|||
Locale string // BCP 47 locale string
|
||||
Entitlements func(feature string) bool // feature gate callback
|
||||
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:
|
||||
|
||||
- `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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly.
|
||||
|
||||
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values.
|
||||
|
||||
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant.
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ go test ./cmd/codegen/
|
|||
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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
Pass `-types` to emit ambient TypeScript declarations instead of JavaScript:
|
||||
|
||||
```bash
|
||||
echo '{"H":"site-header","C":"app-content"}' \
|
||||
| go run ./cmd/codegen/ -types \
|
||||
> components.d.ts
|
||||
```
|
||||
|
||||
For local development, `-watch` polls an input JSON file and rewrites the
|
||||
output file whenever the slot map changes:
|
||||
|
||||
```bash
|
||||
go run ./cmd/codegen/ \
|
||||
-watch \
|
||||
-input slots.json \
|
||||
-output components.js
|
||||
```
|
||||
|
||||
To test the CLI:
|
||||
|
||||
```bash
|
||||
|
|
@ -278,7 +296,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
|||
### Codegen Tests with Testify
|
||||
|
||||
```go
|
||||
func TestGenerateClass_Good(t *testing.T) {
|
||||
func TestGenerateClass_ValidTag(t *testing.T) {
|
||||
js, err := GenerateClass("photo-grid", "C")
|
||||
require.NoError(t, err)
|
||||
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.
|
||||
- `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.
|
||||
- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.
|
||||
- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
|
|||
|
||||
### Size gate test (`aae5d21`)
|
||||
|
||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||
`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
|
||||
|
||||
- Gzip size < 1,048,576 bytes (1 MB).
|
||||
- Raw size < 3,145,728 bytes (3 MB).
|
||||
|
|
@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
|
|||
|
||||
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
|
||||
|
||||
4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
|
||||
4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
|
||||
|
||||
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
|
||||
|
||||
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
|
||||
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
|
||||
|
||||
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
|
||||
|
||||
|
|
@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for
|
|||
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
|
||||
|
||||
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
|
||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||
- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
|
||||
- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
|
||||
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
|
||||
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
|
|||
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `render.go` | `Render()` convenience function |
|
||||
| `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
|
||||
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
229
edge_test.go
229
edge_test.go
|
|
@ -1,16 +1,15 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
// --- Unicode / RTL edge cases ---
|
||||
|
||||
func TestText_Emoji(t *testing.T) {
|
||||
func TestText_Emoji_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -33,7 +32,7 @@ func TestText_Emoji(t *testing.T) {
|
|||
t.Error("Text with emoji should not produce empty output")
|
||||
}
|
||||
// Emoji should pass through (they are not HTML special chars)
|
||||
if !strings.Contains(got, tt.input) {
|
||||
if !containsText(got, tt.input) {
|
||||
// Some chars may get escaped, but emoji bytes should survive
|
||||
t.Logf("note: emoji text rendered as %q", got)
|
||||
}
|
||||
|
|
@ -41,7 +40,7 @@ func TestText_Emoji(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEl_Emoji(t *testing.T) {
|
||||
func TestEl_Emoji_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("span", Raw("\U0001F680 Launch"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -51,7 +50,7 @@ func TestEl_Emoji(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestText_RTL(t *testing.T) {
|
||||
func TestText_RTL_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -76,19 +75,19 @@ func TestText_RTL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEl_RTL(t *testing.T) {
|
||||
func TestEl_RTL_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `dir="rtl"`) {
|
||||
if !containsText(got, `dir="rtl"`) {
|
||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
if !containsText(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_ZeroWidth(t *testing.T) {
|
||||
func TestText_ZeroWidth_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -114,7 +113,7 @@ func TestText_ZeroWidth(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestText_MixedScripts(t *testing.T) {
|
||||
func TestText_MixedScripts_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -141,7 +140,7 @@ func TestText_MixedScripts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Unicode(t *testing.T) {
|
||||
func TestStripTags_Unicode_Ugly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
|
|
@ -163,19 +162,19 @@ func TestStripTags_Unicode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAttr_UnicodeValue(t *testing.T) {
|
||||
func TestAttr_UnicodeValue_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
||||
got := node.Render(ctx)
|
||||
want := "title=\"\U0001F680 Rocket Launch\""
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deep nesting stress tests ---
|
||||
|
||||
func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||
func TestLayout_DeepNesting10Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Build 10 levels of nested layouts
|
||||
|
|
@ -187,7 +186,7 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
|||
got := current.Render(ctx)
|
||||
|
||||
// Should contain the deepest content
|
||||
if !strings.Contains(got, "deepest") {
|
||||
if !containsText(got, "deepest") {
|
||||
t.Error("10 levels deep: missing leaf content")
|
||||
}
|
||||
|
||||
|
|
@ -196,17 +195,17 @@ func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
|||
for i := 1; i < 10; i++ {
|
||||
expectedBlock += "-C-0"
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
|
||||
if !containsText(got, `data-block="`+expectedBlock+`"`) {
|
||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
||||
}
|
||||
|
||||
// Must have exactly 10 <main> tags
|
||||
if count := strings.Count(got, "<main"); count != 10 {
|
||||
if count := countText(got, "<main"); count != 10 {
|
||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
||||
func TestLayout_DeepNesting20Levels_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
current := NewLayout("C").C(Raw("bottom"))
|
||||
|
|
@ -216,15 +215,15 @@ func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
|||
|
||||
got := current.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "bottom") {
|
||||
if !containsText(got, "bottom") {
|
||||
t.Error("20 levels deep: missing leaf content")
|
||||
}
|
||||
if count := strings.Count(got, "<main"); count != 20 {
|
||||
if count := countText(got, "<main"); count != 20 {
|
||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
||||
func TestLayout_DeepNestingMixedSlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||
|
|
@ -238,12 +237,12 @@ func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
|||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
if !strings.Contains(got, "leaf") {
|
||||
if !containsText(got, "leaf") {
|
||||
t.Error("mixed deep nesting: missing leaf content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_1000(t *testing.T) {
|
||||
func TestEach_LargeIteration1000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 1000)
|
||||
for i := range items {
|
||||
|
|
@ -251,23 +250,23 @@ func TestEach_LargeIteration_1000(t *testing.T) {
|
|||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("%d", i)))
|
||||
return El("li", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<li>"); count != 1000 {
|
||||
if count := countText(got, "<li>"); count != 1000 {
|
||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "<li>0</li>") {
|
||||
if !containsText(got, "<li>0</li>") {
|
||||
t.Error("Each with 1000 items: missing first item")
|
||||
}
|
||||
if !strings.Contains(got, "<li>999</li>") {
|
||||
if !containsText(got, "<li>999</li>") {
|
||||
t.Error("Each with 1000 items: missing last item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_5000(t *testing.T) {
|
||||
func TestEach_LargeIteration5000_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 5000)
|
||||
for i := range items {
|
||||
|
|
@ -275,43 +274,43 @@ func TestEach_LargeIteration_5000(t *testing.T) {
|
|||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("span", Raw(fmt.Sprintf("%d", i)))
|
||||
return El("span", Raw(itoaText(i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<span>"); count != 5000 {
|
||||
if count := countText(got, "<span>"); count != 5000 {
|
||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_NestedEach(t *testing.T) {
|
||||
func TestEach_NestedEach_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
rows := []int{0, 1, 2}
|
||||
cols := []string{"a", "b", "c"}
|
||||
|
||||
node := Each(rows, func(row int) Node {
|
||||
return El("tr", Each(cols, func(col string) Node {
|
||||
return El("td", Raw(fmt.Sprintf("%d-%s", row, col)))
|
||||
return El("td", Raw(itoaText(row)+"-"+col))
|
||||
}))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<tr>"); count != 3 {
|
||||
if count := countText(got, "<tr>"); count != 3 {
|
||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||
}
|
||||
if count := strings.Count(got, "<td>"); count != 9 {
|
||||
if count := countText(got, "<td>"); count != 9 {
|
||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "1-b") {
|
||||
if !containsText(got, "1-b") {
|
||||
t.Error("nested Each: missing cell content '1-b'")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout variant validation ---
|
||||
|
||||
func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
||||
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
|
|
@ -343,7 +342,96 @@ func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
||||
func TestLayout_VariantError_Bad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
wantInvalid bool
|
||||
wantErrString string
|
||||
build func(*Layout)
|
||||
wantRender string
|
||||
}{
|
||||
{
|
||||
name: "valid variant",
|
||||
variant: "HCF",
|
||||
wantInvalid: false,
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main><footer role="contentinfo" data-block="F-0">footer</footer>`,
|
||||
},
|
||||
{
|
||||
name: "mixed invalid variant",
|
||||
variant: "HXC",
|
||||
wantInvalid: true,
|
||||
wantErrString: "html: invalid layout variant HXC",
|
||||
build: func(layout *Layout) {
|
||||
layout.H(Raw("header")).C(Raw("main"))
|
||||
},
|
||||
wantRender: `<header role="banner" data-block="H-0">header</header><main role="main" data-block="C-0">main</main>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
layout := NewLayout(tt.variant)
|
||||
if tt.build != nil {
|
||||
tt.build(layout)
|
||||
}
|
||||
if tt.wantInvalid {
|
||||
if layout.VariantError() == nil {
|
||||
t.Fatalf("VariantError() = nil, want sentinel error for %q", tt.variant)
|
||||
}
|
||||
if !errors.Is(layout.VariantError(), ErrInvalidLayoutVariant) {
|
||||
t.Fatalf("VariantError() = %v, want errors.Is(..., ErrInvalidLayoutVariant)", layout.VariantError())
|
||||
}
|
||||
if got := layout.VariantError().Error(); got != tt.wantErrString {
|
||||
t.Fatalf("VariantError().Error() = %q, want %q", got, tt.wantErrString)
|
||||
}
|
||||
} else if layout.VariantError() != nil {
|
||||
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
|
||||
}
|
||||
|
||||
got := layout.Render(NewContext())
|
||||
if got != tt.wantRender {
|
||||
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLayoutVariant_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", variant: "HCF", wantErr: false},
|
||||
{name: "invalid", variant: "HXC", wantErr: true},
|
||||
{name: "empty", variant: "", wantErr: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLayoutVariant(tt.variant)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = nil, want error", tt.variant)
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidLayoutVariant) {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want ErrInvalidLayoutVariant", tt.variant, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateLayoutVariant(%q) = %v, want nil", tt.variant, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariantMixedValidInvalid_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
||||
|
|
@ -351,32 +439,32 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
|||
H(Raw("header")).C(Raw("main"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "header") {
|
||||
if !containsText(got, "header") {
|
||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "main") {
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
||||
}
|
||||
// Should only have 2 semantic elements
|
||||
if count := strings.Count(got, "data-block="); count != 2 {
|
||||
if count := countText(got, "data-block="); count != 2 {
|
||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DuplicateVariantChars(t *testing.T) {
|
||||
func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "CCC" — C appears three times. Should render C slot content three times.
|
||||
layout := NewLayout("CCC").C(Raw("content"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
count := strings.Count(got, "content")
|
||||
count := countText(got, "content")
|
||||
if count != 3 {
|
||||
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EmptySlots(t *testing.T) {
|
||||
func TestLayout_EmptySlots_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Variant includes all slots but none are populated — should produce empty output.
|
||||
|
|
@ -388,9 +476,38 @@ func TestLayout_EmptySlots(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(If(func(*Context) bool { return true }, inner))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
inner := NewLayout("C").C(Raw("wrapped"))
|
||||
outer := NewLayout("C").C(Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": Raw("ignored"),
|
||||
}))
|
||||
|
||||
got := outer.Render(ctx)
|
||||
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render convenience function edge cases ---
|
||||
|
||||
func TestRender_NilContext(t *testing.T) {
|
||||
func TestRender_NilContext_Ugly(t *testing.T) {
|
||||
node := Raw("test")
|
||||
got := Render(node, nil)
|
||||
if got != "test" {
|
||||
|
|
@ -398,7 +515,7 @@ func TestRender_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_NilContext(t *testing.T) {
|
||||
func TestImprint_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -410,7 +527,7 @@ func TestImprint_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_NilContext(t *testing.T) {
|
||||
func TestCompareVariants_NilContext_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -424,7 +541,7 @@ func TestCompareVariants_NilContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_SingleVariant(t *testing.T) {
|
||||
func TestCompareVariants_SingleVariant_Ugly(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
|
|
@ -439,31 +556,31 @@ func TestCompareVariants_SingleVariant(t *testing.T) {
|
|||
|
||||
// --- escapeHTML / escapeAttr edge cases ---
|
||||
|
||||
func TestEscapeAttr_AllSpecialChars(t *testing.T) {
|
||||
func TestEscapeAttr_AllSpecialChars_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "data-val", `&<>"'`)
|
||||
got := node.Render(ctx)
|
||||
|
||||
if strings.Contains(got, `"&<>"'"`) {
|
||||
if containsText(got, `"&<>"'"`) {
|
||||
t.Error("attribute value with special chars must be fully escaped")
|
||||
}
|
||||
if !strings.Contains(got, "&<>"'") {
|
||||
if !containsText(got, "&<>"'") {
|
||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_EmptyTag(t *testing.T) {
|
||||
func TestElNode_EmptyTag_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
||||
// Empty tag is weird but should not panic
|
||||
if !strings.Contains(got, "content") {
|
||||
if !containsText(got, "content") {
|
||||
t.Errorf("El with empty tag should still render children, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_NoMatch(t *testing.T) {
|
||||
func TestSwitchNode_NoMatch_Ugly(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"a": Raw("alpha"),
|
||||
|
|
@ -476,7 +593,7 @@ func TestSwitchNode_NoMatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitled_NilContext(t *testing.T) {
|
||||
func TestEntitled_NilContext_Ugly(t *testing.T) {
|
||||
node := Entitled("premium", Raw("content"))
|
||||
got := node.Render(nil)
|
||||
if got != "" {
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -1,17 +1,19 @@
|
|||
module forge.lthn.ai/core/go-html
|
||||
module dappco.re/go/core/html
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-i18n v0.1.7
|
||||
forge.lthn.ai/core/go-io v0.1.5
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/i18n v0.2.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core/process v0.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.6 // indirect
|
||||
dappco.re/go/core/inference v0.1.4 // indirect
|
||||
dappco.re/go/core/log v0.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -1,11 +1,15 @@
|
|||
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
|
||||
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
|
||||
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
|
||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
|
||||
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
|
||||
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package html
|
|||
import (
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestIntegration_RenderThenReverse(t *testing.T) {
|
||||
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -26,7 +26,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ResponsiveImprint(t *testing.T) {
|
||||
func TestIntegration_ResponsiveImprint_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
|
|||
184
layout.go
184
layout.go
|
|
@ -1,10 +1,14 @@
|
|||
package html
|
||||
|
||||
import "strings"
|
||||
import "errors"
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Layout)(nil)
|
||||
|
||||
// ErrInvalidLayoutVariant reports that a layout variant string contains at least
|
||||
// one unrecognised slot character.
|
||||
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
|
||||
|
||||
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
|
||||
type slotMeta struct {
|
||||
tag string
|
||||
|
|
@ -22,48 +26,159 @@ var slotRegistry = map[byte]slotMeta{
|
|||
|
||||
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
// with deterministic path-based IDs.
|
||||
// Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||
type Layout struct {
|
||||
variant string // "HLCRF", "HCF", "C", etc.
|
||||
path string // "" for root, "L-0-" for nested
|
||||
slots map[byte][]Node // H, L, C, R, F → children
|
||||
variant string // "HLCRF", "HCF", "C", etc.
|
||||
path string // "" for root, "L-0-" for nested
|
||||
slots map[byte][]Node // H, L, C, R, F → children
|
||||
variantErr error
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
func NewLayout(variant string) *Layout {
|
||||
return &Layout{
|
||||
variant: variant,
|
||||
slots: make(map[byte][]Node),
|
||||
func renderWithLayoutPath(node Node, ctx *Context, path string) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if renderer, ok := node.(layoutPathRenderer); ok {
|
||||
return renderer.renderWithLayoutPath(ctx, path)
|
||||
}
|
||||
|
||||
switch t := node.(type) {
|
||||
case *Layout:
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
clone := *t
|
||||
clone.path = path
|
||||
return clone.Render(ctx)
|
||||
case *ifNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *unlessNode:
|
||||
if t == nil || t.cond == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !t.cond(ctx) {
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
}
|
||||
return ""
|
||||
case *entitledNode:
|
||||
if t == nil || t.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(t.node, ctx, path)
|
||||
case *switchNode:
|
||||
if t == nil || t.selector == nil || t.cases == nil {
|
||||
return ""
|
||||
}
|
||||
key := t.selector(ctx)
|
||||
node, ok := t.cases[key]
|
||||
if !ok || node == nil {
|
||||
return ""
|
||||
}
|
||||
return renderWithLayoutPath(node, ctx, path)
|
||||
default:
|
||||
return node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// NewLayout creates a new Layout with the given variant string.
|
||||
// Usage example: page := NewLayout("HLCRF")
|
||||
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
func NewLayout(variant string) *Layout {
|
||||
l := &Layout{
|
||||
variant: variant,
|
||||
slots: make(map[byte][]Node),
|
||||
}
|
||||
l.variantErr = ValidateLayoutVariant(variant)
|
||||
return l
|
||||
}
|
||||
|
||||
// ValidateLayoutVariant reports whether a layout variant string contains only
|
||||
// recognised slot characters.
|
||||
//
|
||||
// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
|
||||
// layoutVariantError for invalid ones.
|
||||
func ValidateLayoutVariant(variant string) error {
|
||||
var invalid bool
|
||||
for i := range len(variant) {
|
||||
if _, ok := slotRegistry[variant[i]]; ok {
|
||||
continue
|
||||
}
|
||||
invalid = true
|
||||
break
|
||||
}
|
||||
if !invalid {
|
||||
return nil
|
||||
}
|
||||
return &layoutVariantError{variant: variant}
|
||||
}
|
||||
|
||||
func (l *Layout) slotsForSlot(slot byte) []Node {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if l.slots == nil {
|
||||
l.slots = make(map[byte][]Node)
|
||||
}
|
||||
return l.slots[slot]
|
||||
}
|
||||
|
||||
// H appends nodes to the Header slot.
|
||||
// Usage example: NewLayout("HCF").H(Text("title"))
|
||||
func (l *Layout) H(nodes ...Node) *Layout {
|
||||
l.slots['H'] = append(l.slots['H'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['H'] = append(l.slotsForSlot('H'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// L appends nodes to the Left aside slot.
|
||||
// Usage example: NewLayout("LC").L(Text("nav"))
|
||||
func (l *Layout) L(nodes ...Node) *Layout {
|
||||
l.slots['L'] = append(l.slots['L'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['L'] = append(l.slotsForSlot('L'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// C appends nodes to the Content (main) slot.
|
||||
// Usage example: NewLayout("C").C(Text("body"))
|
||||
func (l *Layout) C(nodes ...Node) *Layout {
|
||||
l.slots['C'] = append(l.slots['C'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['C'] = append(l.slotsForSlot('C'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// R appends nodes to the Right aside slot.
|
||||
// Usage example: NewLayout("CR").R(Text("ads"))
|
||||
func (l *Layout) R(nodes ...Node) *Layout {
|
||||
l.slots['R'] = append(l.slots['R'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['R'] = append(l.slotsForSlot('R'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
// F appends nodes to the Footer slot.
|
||||
// Usage example: NewLayout("CF").F(Text("footer"))
|
||||
func (l *Layout) F(nodes ...Node) *Layout {
|
||||
l.slots['F'] = append(l.slots['F'], nodes...)
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.slots['F'] = append(l.slotsForSlot('F'), nodes...)
|
||||
return l
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +187,27 @@ func (l *Layout) blockID(slot byte) string {
|
|||
return l.path + string(slot) + "-0"
|
||||
}
|
||||
|
||||
// VariantError reports whether the layout variant string contained any invalid
|
||||
// slot characters when the layout was constructed.
|
||||
func (l *Layout) VariantError() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return l.variantErr
|
||||
}
|
||||
|
||||
// Render produces the semantic HTML for this layout.
|
||||
// Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||
// Only slots present in the variant string are rendered.
|
||||
func (l *Layout) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
|
||||
for i := range len(l.variant) {
|
||||
slot := l.variant[i]
|
||||
|
|
@ -100,14 +232,10 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
b.WriteString(`">`)
|
||||
|
||||
for _, child := range children {
|
||||
// Clone nested layouts before setting path (thread-safe).
|
||||
if inner, ok := child.(*Layout); ok {
|
||||
clone := *inner
|
||||
clone.path = bid + "-"
|
||||
b.WriteString(clone.Render(ctx))
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(child.Render(ctx))
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
|
||||
}
|
||||
|
||||
b.WriteString("</")
|
||||
|
|
@ -117,3 +245,15 @@ func (l *Layout) Render(ctx *Context) string {
|
|||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type layoutVariantError struct {
|
||||
variant string
|
||||
}
|
||||
|
||||
func (e *layoutVariantError) Error() string {
|
||||
return "html: invalid layout variant " + e.variant
|
||||
}
|
||||
|
||||
func (e *layoutVariantError) Unwrap() error {
|
||||
return ErrInvalidLayoutVariant
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayout_HLCRF(t *testing.T) {
|
||||
func TestLayout_HLCRF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -13,34 +12,34 @@ func TestLayout_HLCRF(t *testing.T) {
|
|||
|
||||
// Must contain semantic elements
|
||||
for _, want := range []string{"<header", "<aside", "<main", "<footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain ARIA roles
|
||||
for _, want := range []string{`role="banner"`, `role="complementary"`, `role="main"`, `role="contentinfo"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing role %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain data-block IDs
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="C-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Must contain content
|
||||
for _, want := range []string{"header", "left", "main", "right", "footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HLCRF layout missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_HCF(t *testing.T) {
|
||||
func TestLayout_HCF_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -48,42 +47,42 @@ func TestLayout_HCF(t *testing.T) {
|
|||
|
||||
// HCF should have header, main, footer
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("HCF layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// HCF must NOT have L or R slots
|
||||
for _, unwanted := range []string{`data-block="L-0"`, `data-block="R-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("HCF layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_ContentOnly(t *testing.T) {
|
||||
func TestLayout_ContentOnly_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
layout := NewLayout("C").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Only C slot should render
|
||||
if !strings.Contains(got, `data-block="C-0"`) {
|
||||
if !containsText(got, `data-block="C-0"`) {
|
||||
t.Errorf("C layout missing data-block=\"C-0\" in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<main") {
|
||||
if !containsText(got, "<main") {
|
||||
t.Errorf("C layout missing <main in:\n%s", got)
|
||||
}
|
||||
|
||||
// No other slots
|
||||
for _, unwanted := range []string{`data-block="H-0"`, `data-block="L-0"`, `data-block="R-0"`, `data-block="F-0"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
if containsText(got, unwanted) {
|
||||
t.Errorf("C layout should NOT contain %q in:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_FluentAPI(t *testing.T) {
|
||||
func TestLayout_FluentAPI_Good(t *testing.T) {
|
||||
layout := NewLayout("HLCRF")
|
||||
|
||||
// Fluent methods should return the same layout for chaining
|
||||
|
|
@ -98,19 +97,53 @@ func TestLayout_FluentAPI(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
|
||||
func TestLayout_IgnoresInvalidSlots_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
// "C" variant: populating L and R should have no effect
|
||||
layout := NewLayout("C").L(Raw("left")).C(Raw("main")).R(Raw("right"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "main") {
|
||||
if !containsText(got, "main") {
|
||||
t.Errorf("C variant should render main content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "left") {
|
||||
if containsText(got, "left") {
|
||||
t.Errorf("C variant should ignore L slot content, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "right") {
|
||||
if containsText(got, "right") {
|
||||
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Methods_NilLayout_Ugly(t *testing.T) {
|
||||
var layout *Layout
|
||||
|
||||
if layout.H(Raw("h")) != nil {
|
||||
t.Fatal("expected nil layout from H on nil receiver")
|
||||
}
|
||||
if layout.L(Raw("l")) != nil {
|
||||
t.Fatal("expected nil layout from L on nil receiver")
|
||||
}
|
||||
if layout.C(Raw("c")) != nil {
|
||||
t.Fatal("expected nil layout from C on nil receiver")
|
||||
}
|
||||
if layout.R(Raw("r")) != nil {
|
||||
t.Fatal("expected nil layout from R on nil receiver")
|
||||
}
|
||||
if layout.F(Raw("f")) != nil {
|
||||
t.Fatal("expected nil layout from F on nil receiver")
|
||||
}
|
||||
|
||||
if got := layout.Render(NewContext()); got != "" {
|
||||
t.Fatalf("nil layout render should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_Render_NilContext_Good(t *testing.T) {
|
||||
layout := NewLayout("C").C(Raw("content"))
|
||||
|
||||
got := layout.Render(nil)
|
||||
want := `<main role="main" data-block="C-0">content</main>`
|
||||
if got != want {
|
||||
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
node.go
131
node.go
|
|
@ -5,12 +5,11 @@ import (
|
|||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Node is anything renderable.
|
||||
// Usage example: var n Node = El("div", Text("welcome"))
|
||||
type Node interface {
|
||||
Render(ctx *Context) string
|
||||
}
|
||||
|
|
@ -27,6 +26,10 @@ var (
|
|||
_ Node = (*eachNode[any])(nil)
|
||||
)
|
||||
|
||||
type layoutPathRenderer interface {
|
||||
renderWithLayoutPath(ctx *Context, path string) string
|
||||
}
|
||||
|
||||
// voidElements is the set of HTML elements that must not have a closing tag.
|
||||
var voidElements = map[string]bool{
|
||||
"area": true,
|
||||
|
|
@ -56,11 +59,15 @@ type rawNode struct {
|
|||
}
|
||||
|
||||
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
// Usage example: Raw("<strong>trusted</strong>")
|
||||
func Raw(content string) Node {
|
||||
return &rawNode{content: content}
|
||||
}
|
||||
|
||||
func (n *rawNode) Render(_ *Context) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return n.content
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +80,7 @@ type elNode struct {
|
|||
}
|
||||
|
||||
// El creates an HTML element node with children.
|
||||
// Usage example: El("section", Text("welcome"))
|
||||
func El(tag string, children ...Node) Node {
|
||||
return &elNode{
|
||||
tag: tag,
|
||||
|
|
@ -82,8 +90,13 @@ func El(tag string, children ...Node) Node {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
if n == nil {
|
||||
return n
|
||||
}
|
||||
|
||||
switch t := n.(type) {
|
||||
case *elNode:
|
||||
t.attrs[key] = value
|
||||
|
|
@ -93,12 +106,52 @@ func Attr(n Node, key, value string) Node {
|
|||
Attr(t.node, key, value)
|
||||
case *entitledNode:
|
||||
Attr(t.node, key, value)
|
||||
case *switchNode:
|
||||
for _, child := range t.cases {
|
||||
Attr(child, key, value)
|
||||
}
|
||||
case attrApplier:
|
||||
t.applyAttr(key, value)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AriaLabel sets an aria-label attribute on an element node.
|
||||
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
||||
func AriaLabel(n Node, label string) Node {
|
||||
return Attr(n, "aria-label", label)
|
||||
}
|
||||
|
||||
// AltText sets an alt attribute on an element node.
|
||||
// Usage example: AltText(El("img"), "Profile photo")
|
||||
func AltText(n Node, text string) Node {
|
||||
return Attr(n, "alt", text)
|
||||
}
|
||||
|
||||
// TabIndex sets a tabindex attribute on an element node.
|
||||
// Usage example: TabIndex(El("button", Text("save")), 0)
|
||||
func TabIndex(n Node, index int) Node {
|
||||
return Attr(n, "tabindex", strconv.Itoa(index))
|
||||
}
|
||||
|
||||
// AutoFocus sets an autofocus attribute on an element node.
|
||||
// Usage example: AutoFocus(El("input"))
|
||||
func AutoFocus(n Node) Node {
|
||||
return Attr(n, "autofocus", "autofocus")
|
||||
}
|
||||
|
||||
// Role sets a role attribute on an element node.
|
||||
// Usage example: Role(El("nav", Text("links")), "navigation")
|
||||
func Role(n Node, role string) Node {
|
||||
return Attr(n, "role", role)
|
||||
}
|
||||
|
||||
func (n *elNode) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
|
||||
b.WriteByte('<')
|
||||
b.WriteString(escapeHTML(n.tag))
|
||||
|
|
@ -121,6 +174,9 @@ func (n *elNode) Render(ctx *Context) string {
|
|||
}
|
||||
|
||||
for i := range len(n.children) {
|
||||
if n.children[i] == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(n.children[i].Render(ctx))
|
||||
}
|
||||
|
||||
|
|
@ -146,19 +202,17 @@ type textNode struct {
|
|||
}
|
||||
|
||||
// Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
// Usage example: Text("welcome", "Ada")
|
||||
// Output is HTML-escaped by default. Safe-by-default path.
|
||||
func Text(key string, args ...any) Node {
|
||||
return &textNode{key: key, args: args}
|
||||
}
|
||||
|
||||
func (n *textNode) Render(ctx *Context) string {
|
||||
var text string
|
||||
if ctx != nil && ctx.service != nil {
|
||||
text = ctx.service.T(n.key, n.args...)
|
||||
} else {
|
||||
text = i18n.T(n.key, n.args...)
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return escapeHTML(text)
|
||||
return escapeHTML(translateText(ctx, n.key, n.args...))
|
||||
}
|
||||
|
||||
// --- ifNode ---
|
||||
|
|
@ -169,11 +223,15 @@ type ifNode struct {
|
|||
}
|
||||
|
||||
// If renders child only when condition is true.
|
||||
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
func If(cond func(*Context) bool, node Node) Node {
|
||||
return &ifNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *ifNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
|
|
@ -188,11 +246,15 @@ type unlessNode struct {
|
|||
}
|
||||
|
||||
// Unless renders child only when condition is false.
|
||||
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
func Unless(cond func(*Context) bool, node Node) Node {
|
||||
return &unlessNode{cond: cond, node: node}
|
||||
}
|
||||
|
||||
func (n *unlessNode) Render(ctx *Context) string {
|
||||
if n == nil || n.cond == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if !n.cond(ctx) {
|
||||
return n.node.Render(ctx)
|
||||
}
|
||||
|
|
@ -207,12 +269,16 @@ type entitledNode struct {
|
|||
}
|
||||
|
||||
// Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
// Usage example: Entitled("beta", Text("preview"))
|
||||
// If no entitlement function is set on the context, access is denied by default.
|
||||
func Entitled(feature string, node Node) Node {
|
||||
return &entitledNode{feature: feature, node: node}
|
||||
}
|
||||
|
||||
func (n *entitledNode) Render(ctx *Context) string {
|
||||
if n == nil || n.node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -227,13 +293,23 @@ type switchNode struct {
|
|||
}
|
||||
|
||||
// Switch renders based on runtime selector value.
|
||||
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
||||
return &switchNode{selector: selector, cases: cases}
|
||||
}
|
||||
|
||||
func (n *switchNode) Render(ctx *Context) string {
|
||||
if n == nil || n.selector == nil {
|
||||
return ""
|
||||
}
|
||||
key := n.selector(ctx)
|
||||
if n.cases == nil {
|
||||
return ""
|
||||
}
|
||||
if node, ok := n.cases[key]; ok {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
return node.Render(ctx)
|
||||
}
|
||||
return ""
|
||||
|
|
@ -246,20 +322,49 @@ type eachNode[T any] struct {
|
|||
fn func(T) Node
|
||||
}
|
||||
|
||||
type attrApplier interface {
|
||||
applyAttr(key, value string)
|
||||
}
|
||||
|
||||
// Each iterates items and renders each via fn.
|
||||
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
func Each[T any](items []T, fn func(T) Node) Node {
|
||||
return EachSeq(slices.Values(items), fn)
|
||||
}
|
||||
|
||||
// EachSeq iterates an iter.Seq and renders each via fn.
|
||||
// Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
||||
return &eachNode[T]{items: items, fn: fn}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
return n.renderWithLayoutPath(ctx, "")
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) applyAttr(key, value string) {
|
||||
if n == nil || n.fn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
prev := n.fn
|
||||
n.fn = func(item T) Node {
|
||||
return Attr(prev(item), key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
|
||||
if n == nil || n.fn == nil || n.items == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
for item := range n.items {
|
||||
b.WriteString(n.fn(item).Render(ctx))
|
||||
child := n.fn(item)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(renderWithLayoutPath(child, ctx, path))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
224
node_test.go
224
node_test.go
|
|
@ -1,11 +1,13 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func TestRawNode_Render(t *testing.T) {
|
||||
func TestRawNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Raw("hello")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -14,7 +16,7 @@ func TestRawNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Render(t *testing.T) {
|
||||
func TestElNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -24,7 +26,7 @@ func TestElNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Nested(t *testing.T) {
|
||||
func TestElNode_Nested_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", El("span", Raw("inner")))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -34,7 +36,7 @@ func TestElNode_Nested(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleChildren(t *testing.T) {
|
||||
func TestElNode_MultipleChildren_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("div", Raw("a"), Raw("b"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -44,7 +46,7 @@ func TestElNode_MultipleChildren(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_VoidElement(t *testing.T) {
|
||||
func TestElNode_VoidElement_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("br")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -54,7 +56,7 @@ func TestElNode_VoidElement(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Render(t *testing.T) {
|
||||
func TestTextNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -63,19 +65,19 @@ func TestTextNode_Render(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTextNode_Escapes(t *testing.T) {
|
||||
func TestTextNode_Escapes_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Text("<script>alert('xss')</script>")
|
||||
got := node.Render(ctx)
|
||||
if strings.Contains(got, "<script>") {
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("Text node must HTML-escape output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("Text node should contain escaped script tag, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNode_True(t *testing.T) {
|
||||
func TestIfNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return true }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -84,7 +86,7 @@ func TestIfNode_True(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIfNode_False(t *testing.T) {
|
||||
func TestIfNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := If(func(*Context) bool { return false }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -93,7 +95,7 @@ func TestIfNode_False(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUnlessNode(t *testing.T) {
|
||||
func TestUnlessNode_False_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return false }, Raw("visible"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -102,7 +104,7 @@ func TestUnlessNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Granted(t *testing.T) {
|
||||
func TestEntitledNode_Granted_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return feature == "premium" }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
|
|
@ -112,7 +114,7 @@ func TestEntitledNode_Granted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_Denied(t *testing.T) {
|
||||
func TestEntitledNode_Denied_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(feature string) bool { return false }
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
|
|
@ -122,7 +124,7 @@ func TestEntitledNode_Denied(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitledNode_NoFunc(t *testing.T) {
|
||||
func TestEntitledNode_NoFunc_Bad(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Entitled("premium", Raw("premium content"))
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -131,7 +133,7 @@ func TestEntitledNode_NoFunc(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEachNode(t *testing.T) {
|
||||
func TestEachNode_Render_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := []string{"a", "b", "c"}
|
||||
node := Each(items, func(item string) Node {
|
||||
|
|
@ -144,7 +146,7 @@ func TestEachNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEachNode_Empty(t *testing.T) {
|
||||
func TestEachNode_Empty_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{}, func(item string) Node {
|
||||
return El("li", Raw(item))
|
||||
|
|
@ -155,7 +157,35 @@ func TestEachNode_Empty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_Attr(t *testing.T) {
|
||||
func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := Each([]Node{inner}, func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("Each nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("C").C(Raw("item"))
|
||||
node := EachSeq(slices.Values([]Node{inner}), func(item Node) Node {
|
||||
return item
|
||||
})
|
||||
|
||||
got := NewLayout("C").C(node).Render(ctx)
|
||||
want := `<main role="main" data-block="C-0"><main role="main" data-block="C-0-C-0">item</main></main>`
|
||||
if got != want {
|
||||
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_Attr_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("content")), "class", "container")
|
||||
got := node.Render(ctx)
|
||||
|
|
@ -165,25 +195,70 @@ func TestElNode_Attr(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElNode_AttrEscaping(t *testing.T) {
|
||||
func TestElNode_AttrEscaping_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("img"), "alt", `he said "hello"`)
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `alt="he said "hello""`) {
|
||||
if !containsText(got, `alt="he said "hello""`) {
|
||||
t.Errorf("Attr should escape attribute values, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleAttrs(t *testing.T) {
|
||||
func TestAriaLabel_Good(t *testing.T) {
|
||||
node := AriaLabel(El("button", Raw("save")), "Save changes")
|
||||
got := node.Render(NewContext())
|
||||
want := `<button aria-label="Save changes">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("AriaLabel() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltText_Good(t *testing.T) {
|
||||
node := AltText(El("img"), "Profile photo")
|
||||
got := node.Render(NewContext())
|
||||
want := `<img alt="Profile photo">`
|
||||
if got != want {
|
||||
t.Errorf("AltText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabIndex_Good(t *testing.T) {
|
||||
node := TabIndex(El("button", Raw("save")), 0)
|
||||
got := node.Render(NewContext())
|
||||
want := `<button tabindex="0">save</button>`
|
||||
if got != want {
|
||||
t.Errorf("TabIndex() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocus_Good(t *testing.T) {
|
||||
node := AutoFocus(El("input"))
|
||||
got := node.Render(NewContext())
|
||||
want := `<input autofocus="autofocus">`
|
||||
if got != want {
|
||||
t.Errorf("AutoFocus() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_Good(t *testing.T) {
|
||||
node := Role(El("nav", Raw("links")), "navigation")
|
||||
got := node.Render(NewContext())
|
||||
want := `<nav role="navigation">links</nav>`
|
||||
if got != want {
|
||||
t.Errorf("Role() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_MultipleAttrs_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
|
||||
if !containsText(got, `class="nav"`) || !containsText(got, `href="/home"`) {
|
||||
t.Errorf("multiple Attr() calls should stack, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_NonElement(t *testing.T) {
|
||||
func TestAttr_NonElement_Ugly(t *testing.T) {
|
||||
node := Attr(Raw("text"), "class", "x")
|
||||
got := node.Render(NewContext())
|
||||
if got != "text" {
|
||||
|
|
@ -191,7 +266,106 @@ func TestAttr_NonElement(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode(t *testing.T) {
|
||||
func TestUnlessNode_True_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Unless(true) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughIfNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := If(func(*Context) bool { return true }, inner)
|
||||
Attr(node, "class", "wrapped")
|
||||
got := node.Render(ctx)
|
||||
want := `<div class="wrapped">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through If = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughUnlessNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Unless(func(*Context) bool { return false }, inner)
|
||||
Attr(node, "id", "test")
|
||||
got := node.Render(ctx)
|
||||
want := `<div id="test">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Unless = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEntitledNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
ctx.Entitlements = func(string) bool { return true }
|
||||
inner := El("div", Raw("content"))
|
||||
node := Entitled("feature", inner)
|
||||
Attr(node, "data-feat", "on")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-feat="on">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Entitled = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := El("div", Raw("content"))
|
||||
node := Switch(func(*Context) string { return "match" }, map[string]Node{
|
||||
"match": inner,
|
||||
"miss": El("span", Raw("unused")),
|
||||
})
|
||||
Attr(node, "data-state", "active")
|
||||
got := node.Render(ctx)
|
||||
want := `<div data-state="active">content</div>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Switch = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Each([]string{"a", "b"}, func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "class", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span class="item">a</span><span class="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through Each = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_ThroughEachSeqNode_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
|
||||
return El("span", Raw(item))
|
||||
})
|
||||
Attr(node, "data-kind", "item")
|
||||
|
||||
got := node.Render(ctx)
|
||||
want := `<span data-kind="item">a</span><span data-kind="item">b</span>`
|
||||
if got != want {
|
||||
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNode_WithService_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
ctx := NewContextWithService(svc)
|
||||
node := Text("hello")
|
||||
got := node.Render(ctx)
|
||||
if got != "hello" {
|
||||
t.Errorf("Text with service context = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_SelectsMatch_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"dark": Raw("dark theme"),
|
||||
|
|
|
|||
21
path.go
21
path.go
|
|
@ -3,21 +3,26 @@ package html
|
|||
import "strings"
|
||||
|
||||
// ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
// Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
// "L-0-C-0" → ['L', 'C']
|
||||
func ParseBlockID(id string) []byte {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split on "-" and take every other element (the slot letters).
|
||||
// Format: "X-0" or "X-0-Y-0-Z-0"
|
||||
var slots []byte
|
||||
i := 0
|
||||
for part := range strings.SplitSeq(id, "-") {
|
||||
if i%2 == 0 && len(part) == 1 {
|
||||
slots = append(slots, part[0])
|
||||
// Valid IDs are exact sequences of "{slot}-0" segments, e.g.
|
||||
// "H-0" or "L-0-C-0". Any malformed segment invalidates the whole ID.
|
||||
parts := strings.Split(id, "-")
|
||||
if len(parts)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make([]byte, 0, len(parts)/2)
|
||||
for i := 0; i < len(parts); i += 2 {
|
||||
if len(parts[i]) != 1 || parts[i+1] != "0" {
|
||||
return nil
|
||||
}
|
||||
i++
|
||||
slots = append(slots, parts[i][0])
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
|
|
|||
30
path_test.go
30
path_test.go
|
|
@ -1,11 +1,10 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedLayout_PathChain(t *testing.T) {
|
||||
func TestNestedLayout_PathChain_Good(t *testing.T) {
|
||||
inner := NewLayout("HCF").H(Raw("inner h")).C(Raw("inner c")).F(Raw("inner f"))
|
||||
outer := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
|
|
@ -13,33 +12,33 @@ func TestNestedLayout_PathChain(t *testing.T) {
|
|||
|
||||
// Inner layout paths must be prefixed with parent block ID
|
||||
for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("nested layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Outer layout must still have root-level paths
|
||||
for _, want := range []string{`data-block="H-0"`, `data-block="C-0"`, `data-block="F-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("outer layout missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedLayout_DeepNesting(t *testing.T) {
|
||||
func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
|
||||
deepest := NewLayout("C").C(Raw("deep"))
|
||||
middle := NewLayout("C").C(deepest)
|
||||
outer := NewLayout("C").C(middle)
|
||||
got := outer.Render(NewContext())
|
||||
|
||||
for _, want := range []string{`data-block="C-0"`, `data-block="C-0-C-0"`, `data-block="C-0-C-0-C-0"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("deep nesting missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockID(t *testing.T) {
|
||||
func TestBlockID_BuildsPath_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
slot byte
|
||||
|
|
@ -60,7 +59,7 @@ func TestBlockID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseBlockID(t *testing.T) {
|
||||
func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
want []byte
|
||||
|
|
@ -84,3 +83,18 @@ func TestParseBlockID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockID_InvalidInput_Good(t *testing.T) {
|
||||
tests := []string{
|
||||
"L-1-C-0",
|
||||
"L-0-C",
|
||||
"L-0-",
|
||||
"X",
|
||||
}
|
||||
|
||||
for _, id := range tests {
|
||||
if got := ParseBlockID(id); got != nil {
|
||||
t.Errorf("ParseBlockID(%q) = %v, want nil", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
pipeline.go
22
pipeline.go
|
|
@ -3,16 +3,17 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n/reversal"
|
||||
"dappco.re/go/core/i18n/reversal"
|
||||
)
|
||||
|
||||
// StripTags removes HTML tags from rendered output, returning plain text.
|
||||
// Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
// Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
// Does not handle script/style element content (go-html does not generate these).
|
||||
func StripTags(html string) string {
|
||||
var b strings.Builder
|
||||
b := core.NewBuilder()
|
||||
inTag := false
|
||||
prevSpace := true // starts true to trim leading space
|
||||
for _, r := range html {
|
||||
|
|
@ -40,16 +41,20 @@ func StripTags(html string) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
return core.Trim(b.String())
|
||||
}
|
||||
|
||||
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
// and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
// Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
rendered := node.Render(ctx)
|
||||
rendered := ""
|
||||
if node != nil {
|
||||
rendered = node.Render(ctx)
|
||||
}
|
||||
text := StripTags(rendered)
|
||||
tok := reversal.NewTokeniser()
|
||||
tokens := tok.Tokenise(text)
|
||||
|
|
@ -58,10 +63,14 @@ func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
|
|||
|
||||
// CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
// and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
// Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
if r == nil {
|
||||
return make(map[string]float64)
|
||||
}
|
||||
|
||||
type named struct {
|
||||
name string
|
||||
|
|
@ -70,6 +79,9 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
|
|||
|
||||
var imprints []named
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
imp := Imprint(v.layout, ctx)
|
||||
imprints = append(imprints, named{name: v.name, imp: imp})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ package html
|
|||
import (
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestStripTags_Simple(t *testing.T) {
|
||||
func TestStripTags_Simple_Good(t *testing.T) {
|
||||
got := StripTags(`<div>hello</div>`)
|
||||
want := "hello"
|
||||
if got != want {
|
||||
|
|
@ -16,7 +16,7 @@ func TestStripTags_Simple(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Nested(t *testing.T) {
|
||||
func TestStripTags_Nested_Good(t *testing.T) {
|
||||
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
|
||||
want := "Title"
|
||||
if got != want {
|
||||
|
|
@ -24,7 +24,7 @@ func TestStripTags_Nested(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_MultipleRegions(t *testing.T) {
|
||||
func TestStripTags_MultipleRegions_Good(t *testing.T) {
|
||||
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
|
||||
want := "Head Body Foot"
|
||||
if got != want {
|
||||
|
|
@ -32,21 +32,21 @@ func TestStripTags_MultipleRegions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Empty(t *testing.T) {
|
||||
func TestStripTags_Empty_Ugly(t *testing.T) {
|
||||
got := StripTags("")
|
||||
if got != "" {
|
||||
t.Errorf("StripTags(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_NoTags(t *testing.T) {
|
||||
func TestStripTags_NoTags_Good(t *testing.T) {
|
||||
got := StripTags("plain text")
|
||||
if got != "plain text" {
|
||||
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Entities(t *testing.T) {
|
||||
func TestStripTags_Entities_Good(t *testing.T) {
|
||||
got := StripTags(`<script>`)
|
||||
want := "<script>"
|
||||
if got != want {
|
||||
|
|
@ -54,7 +54,7 @@ func TestStripTags_Entities(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_FromNode(t *testing.T) {
|
||||
func TestImprint_FromNode_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -74,7 +74,7 @@ func TestImprint_FromNode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImprint_SimilarPages(t *testing.T) {
|
||||
func TestImprint_SimilarPages_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -102,7 +102,7 @@ func TestImprint_SimilarPages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants(t *testing.T) {
|
||||
func TestCompareVariants_SameContent_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package html
|
||||
|
||||
// Render is a convenience function that renders a node tree to HTML.
|
||||
// Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
func Render(node Node, ctx *Context) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
func TestRender_FullPage(t *testing.T) {
|
||||
func TestRender_FullPage_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -28,14 +27,14 @@ func TestRender_FullPage(t *testing.T) {
|
|||
|
||||
// Contains semantic elements
|
||||
for _, want := range []string{"<header", "<main", "<footer"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing semantic element %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Content rendered
|
||||
for _, want := range []string{"Dashboard", "Welcome", "Home"} {
|
||||
if !strings.Contains(got, want) {
|
||||
if !containsText(got, want) {
|
||||
t.Errorf("full page missing content %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,13 +43,13 @@ func TestRender_FullPage(t *testing.T) {
|
|||
for _, tag := range []string{"header", "main", "footer", "h1", "div", "p", "small"} {
|
||||
open := "<" + tag
|
||||
close := "</" + tag + ">"
|
||||
if strings.Count(got, open) != strings.Count(got, close) {
|
||||
if countText(got, open) != countText(got, close) {
|
||||
t.Errorf("unbalanced <%s> tags in:\n%s", tag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_EntitlementGating(t *testing.T) {
|
||||
func TestRender_EntitlementGating_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -67,18 +66,18 @@ func TestRender_EntitlementGating(t *testing.T) {
|
|||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "public") {
|
||||
if !containsText(got, "public") {
|
||||
t.Errorf("entitlement gating should render public content, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "admin-panel") {
|
||||
if !containsText(got, "admin-panel") {
|
||||
t.Errorf("entitlement gating should render admin-panel for admin, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "premium-content") {
|
||||
if containsText(got, "premium-content") {
|
||||
t.Errorf("entitlement gating should NOT render premium-content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XSSPrevention(t *testing.T) {
|
||||
func TestRender_XSSPrevention_Good(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
|
@ -88,10 +87,10 @@ func TestRender_XSSPrevention(t *testing.T) {
|
|||
|
||||
got := page.Render(ctx)
|
||||
|
||||
if strings.Contains(got, "<script>") {
|
||||
if containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention failed: output contains raw <script> tag:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
package html
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ Node = (*Responsive)(nil)
|
||||
|
||||
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
// Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
type Responsive struct {
|
||||
variants []responsiveVariant
|
||||
|
|
@ -14,21 +21,38 @@ type responsiveVariant struct {
|
|||
}
|
||||
|
||||
// NewResponsive creates a new multi-variant responsive compositor.
|
||||
// Usage example: r := NewResponsive()
|
||||
func NewResponsive() *Responsive {
|
||||
return &Responsive{}
|
||||
}
|
||||
|
||||
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
// Variants render in insertion order.
|
||||
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
|
||||
if r == nil {
|
||||
r = NewResponsive()
|
||||
}
|
||||
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
|
||||
return r
|
||||
}
|
||||
|
||||
// Render produces HTML with each variant in a data-variant container.
|
||||
// Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
func (r *Responsive) Render(ctx *Context) string {
|
||||
var b strings.Builder
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = NewContext()
|
||||
}
|
||||
|
||||
b := newTextBuilder()
|
||||
for _, v := range r.variants {
|
||||
if v.layout == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString(`<div data-variant="`)
|
||||
b.WriteString(escapeAttr(v.name))
|
||||
b.WriteString(`">`)
|
||||
|
|
@ -37,3 +61,36 @@ func (r *Responsive) Render(ctx *Context) string {
|
|||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// VariantSelector returns a CSS attribute selector for a responsive variant.
|
||||
// Usage example: selector := VariantSelector("desktop")
|
||||
func VariantSelector(name string) string {
|
||||
return `[data-variant="` + escapeCSSString(name) + `"]`
|
||||
}
|
||||
|
||||
func escapeCSSString(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '"':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if r < 0x20 || r == 0x7f {
|
||||
b.WriteByte('\\')
|
||||
esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
|
||||
for i := 0; i < len(esc); i++ {
|
||||
b.WriteByte(esc[i])
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,25 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponsive_SingleVariant(t *testing.T) {
|
||||
func TestResponsive_SingleVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, `data-variant="desktop"`) {
|
||||
if !containsText(got, `data-variant="desktop"`) {
|
||||
t.Errorf("responsive should contain data-variant, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-block="H-0"`) {
|
||||
if !containsText(got, `data-block="H-0"`) {
|
||||
t.Errorf("responsive should contain layout content, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_MultiVariant(t *testing.T) {
|
||||
func TestResponsive_MultiVariant_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||
|
|
@ -30,13 +29,13 @@ func TestResponsive_MultiVariant(t *testing.T) {
|
|||
got := r.Render(ctx)
|
||||
|
||||
for _, v := range []string{"desktop", "tablet", "mobile"} {
|
||||
if !strings.Contains(got, `data-variant="`+v+`"`) {
|
||||
if !containsText(got, `data-variant="`+v+`"`) {
|
||||
t.Errorf("responsive missing variant %q in:\n%s", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantOrder(t *testing.T) {
|
||||
func TestResponsive_VariantOrder_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
|
||||
|
|
@ -44,8 +43,8 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
di := strings.Index(got, `data-variant="desktop"`)
|
||||
mi := strings.Index(got, `data-variant="mobile"`)
|
||||
di := indexText(got, `data-variant="desktop"`)
|
||||
mi := indexText(got, `data-variant="mobile"`)
|
||||
if di < 0 || mi < 0 {
|
||||
t.Fatalf("missing variants in:\n%s", got)
|
||||
}
|
||||
|
|
@ -54,7 +53,7 @@ func TestResponsive_VariantOrder(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResponsive_NestedPaths(t *testing.T) {
|
||||
func TestResponsive_NestedPaths_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||
r := NewResponsive().
|
||||
|
|
@ -62,15 +61,15 @@ func TestResponsive_NestedPaths(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, `data-block="C-0-H-0"`) {
|
||||
if !containsText(got, `data-block="C-0-H-0"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-block="C-0-C-0"`) {
|
||||
if !containsText(got, `data-block="C-0-C-0"`) {
|
||||
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_VariantsIndependent(t *testing.T) {
|
||||
func TestResponsive_VariantsIndependent_Good(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
r := NewResponsive().
|
||||
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
|
||||
|
|
@ -78,12 +77,60 @@ func TestResponsive_VariantsIndependent(t *testing.T) {
|
|||
|
||||
got := r.Render(ctx)
|
||||
|
||||
count := strings.Count(got, `data-block="C-0"`)
|
||||
count := countText(got, `data-block="C-0"`)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_ImplementsNode(t *testing.T) {
|
||||
func TestResponsive_ImplementsNode_Ugly(t *testing.T) {
|
||||
var _ Node = NewResponsive()
|
||||
}
|
||||
|
||||
func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
|
||||
var r *Responsive
|
||||
|
||||
got := r.Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil responsive from Variant on nil receiver")
|
||||
}
|
||||
|
||||
if output := got.Render(NewContext()); output != `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>` {
|
||||
t.Fatalf("unexpected output from nil receiver Variant path: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsive_Render_NilContext_Good(t *testing.T) {
|
||||
r := NewResponsive().
|
||||
Variant("mobile", NewLayout("C").C(Raw("content")))
|
||||
|
||||
got := r.Render(nil)
|
||||
want := `<div data-variant="mobile"><main role="main" data-block="C-0">content</main></div>`
|
||||
if got != want {
|
||||
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Good(t *testing.T) {
|
||||
got := VariantSelector("desktop")
|
||||
want := `[data-variant="desktop"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_Escapes_Good(t *testing.T) {
|
||||
got := VariantSelector("desk\"top\\wide")
|
||||
want := `[data-variant="desk\"top\\wide"]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSelector_ControlChars_Escape_Good(t *testing.T) {
|
||||
got := VariantSelector("a\tb\nc\u0007")
|
||||
want := `[data-variant="a\9 b\A c\7 "]`
|
||||
if got != want {
|
||||
t.Fatalf("VariantSelector control escapes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
specs/cmd/codegen.md
Normal file
11
specs/cmd/codegen.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/codegen`
|
||||
**Files:** 1
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
11
specs/cmd/wasm.md
Normal file
11
specs/cmd/wasm.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# main
|
||||
**Import:** `dappco.re/go/core/html/cmd/wasm`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
None.
|
||||
34
specs/codegen.md
Normal file
34
specs/codegen.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
34
specs/codegen/RFC.md
Normal file
34
specs/codegen/RFC.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# codegen
|
||||
**Import:** `dappco.re/go/core/html/codegen`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `GenerateBundle`
|
||||
`func GenerateBundle(slots map[string]string) (string, error)`
|
||||
|
||||
GenerateBundle produces all WC class definitions and registrations
|
||||
for a set of HLCRF slot assignments.
|
||||
Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
||||
|
||||
### `GenerateClass`
|
||||
`func GenerateClass(tag, slot string) (string, error)`
|
||||
|
||||
GenerateClass produces a JS class definition for a custom element.
|
||||
Usage example: js, err := GenerateClass("nav-bar", "H")
|
||||
|
||||
### `GenerateRegistration`
|
||||
`func GenerateRegistration(tag, className string) string`
|
||||
|
||||
GenerateRegistration produces the customElements.define() call.
|
||||
Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
||||
|
||||
### `TagToClassName`
|
||||
`func TagToClassName(tag string) string`
|
||||
|
||||
TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
Usage example: className := TagToClassName("nav-bar")
|
||||
225
specs/root.md
Normal file
225
specs/root.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# html
|
||||
**Import:** `dappco.re/go/core/html`
|
||||
**Files:** 13
|
||||
|
||||
## Types
|
||||
|
||||
### `Context`
|
||||
`type Context struct`
|
||||
|
||||
Context carries rendering state through the node tree.
|
||||
Usage example: ctx := NewContext()
|
||||
|
||||
Fields:
|
||||
- `Identity string`
|
||||
- `Locale string`
|
||||
- `Entitlements func(feature string) bool`
|
||||
- `Data map[string]any`
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Layout`
|
||||
`type Layout struct`
|
||||
|
||||
Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
|
||||
with deterministic path-based IDs.
|
||||
Usage example: page := NewLayout("HCF").H(Text("title")).C(Text("body"))
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (l *Layout) C(nodes ...Node) *Layout`
|
||||
C appends nodes to the Content (main) slot.
|
||||
Usage example: NewLayout("C").C(Text("body"))
|
||||
- `func (l *Layout) F(nodes ...Node) *Layout`
|
||||
F appends nodes to the Footer slot.
|
||||
Usage example: NewLayout("CF").F(Text("footer"))
|
||||
- `func (l *Layout) H(nodes ...Node) *Layout`
|
||||
H appends nodes to the Header slot.
|
||||
Usage example: NewLayout("HCF").H(Text("title"))
|
||||
- `func (l *Layout) L(nodes ...Node) *Layout`
|
||||
L appends nodes to the Left aside slot.
|
||||
Usage example: NewLayout("LC").L(Text("nav"))
|
||||
- `func (l *Layout) R(nodes ...Node) *Layout`
|
||||
R appends nodes to the Right aside slot.
|
||||
Usage example: NewLayout("CR").R(Text("ads"))
|
||||
- `func (l *Layout) Render(ctx *Context) string`
|
||||
Render produces the semantic HTML for this layout.
|
||||
Usage example: html := NewLayout("C").C(Text("body")).Render(NewContext())
|
||||
Only slots present in the variant string are rendered.
|
||||
|
||||
### `Node`
|
||||
`type Node interface`
|
||||
|
||||
Node is anything renderable.
|
||||
Usage example: var n Node = El("div", Text("welcome"))
|
||||
|
||||
Members:
|
||||
- `Render(ctx *Context) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
### `Responsive`
|
||||
`type Responsive struct`
|
||||
|
||||
Responsive wraps multiple Layout variants for breakpoint-aware rendering.
|
||||
Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
|
||||
Each variant is rendered inside a container with data-variant for CSS targeting.
|
||||
|
||||
Fields:
|
||||
- No exported fields.
|
||||
- Unexported fields are present.
|
||||
|
||||
Methods:
|
||||
- `func (r *Responsive) Render(ctx *Context) string`
|
||||
Render produces HTML with each variant in a data-variant container.
|
||||
Usage example: html := NewResponsive().Variant("mobile", NewLayout("C")).Render(NewContext())
|
||||
- `func (r *Responsive) Variant(name string, layout *Layout) *Responsive`
|
||||
Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
|
||||
Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
|
||||
Variants render in insertion order.
|
||||
|
||||
### `Translator`
|
||||
`type Translator interface`
|
||||
|
||||
Translator provides Text() lookups for a rendering context.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
The default server build uses go-i18n. Alternate builds, including WASM,
|
||||
can provide any implementation with the same T() method.
|
||||
|
||||
Members:
|
||||
- `T(key string, args ...any) string`
|
||||
|
||||
Methods:
|
||||
None.
|
||||
|
||||
## Functions
|
||||
|
||||
### `Attr`
|
||||
`func Attr(n Node, key, value string) Node`
|
||||
|
||||
Attr sets an attribute on an El node. Returns the node for chaining.
|
||||
Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
||||
It recursively traverses through wrappers like If, Unless, and Entitled.
|
||||
|
||||
### `CompareVariants`
|
||||
`func CompareVariants(r *Responsive, ctx *Context) map[string]float64`
|
||||
|
||||
CompareVariants runs the imprint pipeline on each responsive variant independently
|
||||
and returns pairwise similarity scores. Key format: "name1:name2".
|
||||
Usage example: scores := CompareVariants(NewResponsive(), NewContext())
|
||||
|
||||
### `Each`
|
||||
`func Each[T any](items []T, fn func(T) Node) Node`
|
||||
|
||||
Each iterates items and renders each via fn.
|
||||
Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
||||
|
||||
### `EachSeq`
|
||||
`func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node`
|
||||
|
||||
EachSeq iterates an iter.Seq and renders each via fn.
|
||||
Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
||||
|
||||
### `El`
|
||||
`func El(tag string, children ...Node) Node`
|
||||
|
||||
El creates an HTML element node with children.
|
||||
Usage example: El("section", Text("welcome"))
|
||||
|
||||
### `Entitled`
|
||||
`func Entitled(feature string, node Node) Node`
|
||||
|
||||
Entitled renders child only when entitlement is granted. Absent, not hidden.
|
||||
Usage example: Entitled("beta", Text("preview"))
|
||||
If no entitlement function is set on the context, access is denied by default.
|
||||
|
||||
### `If`
|
||||
`func If(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
If renders child only when condition is true.
|
||||
Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
||||
|
||||
### `Imprint`
|
||||
`func Imprint(node Node, ctx *Context) reversal.GrammarImprint`
|
||||
|
||||
Imprint renders a node tree to HTML, strips tags, tokenises the text,
|
||||
and returns a GrammarImprint — the full render-reverse pipeline.
|
||||
Usage example: imp := Imprint(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContext`
|
||||
`func NewContext() *Context`
|
||||
|
||||
NewContext creates a new rendering context with sensible defaults.
|
||||
Usage example: html := Render(Text("welcome"), NewContext())
|
||||
|
||||
### `NewContextWithService`
|
||||
`func NewContextWithService(svc Translator) *Context`
|
||||
|
||||
NewContextWithService creates a rendering context backed by a specific translator.
|
||||
Usage example: ctx := NewContextWithService(myTranslator)
|
||||
|
||||
### `NewLayout`
|
||||
`func NewLayout(variant string) *Layout`
|
||||
|
||||
NewLayout creates a new Layout with the given variant string.
|
||||
Usage example: page := NewLayout("HLCRF")
|
||||
The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
|
||||
|
||||
### `NewResponsive`
|
||||
`func NewResponsive() *Responsive`
|
||||
|
||||
NewResponsive creates a new multi-variant responsive compositor.
|
||||
Usage example: r := NewResponsive()
|
||||
|
||||
### `ParseBlockID`
|
||||
`func ParseBlockID(id string) []byte`
|
||||
|
||||
ParseBlockID extracts the slot sequence from a data-block ID.
|
||||
Usage example: slots := ParseBlockID("L-0-C-0")
|
||||
"L-0-C-0" → ['L', 'C']
|
||||
|
||||
### `Raw`
|
||||
`func Raw(content string) Node`
|
||||
|
||||
Raw creates a node that renders without escaping (escape hatch for trusted content).
|
||||
Usage example: Raw("<strong>trusted</strong>")
|
||||
|
||||
### `Render`
|
||||
`func Render(node Node, ctx *Context) string`
|
||||
|
||||
Render is a convenience function that renders a node tree to HTML.
|
||||
Usage example: html := Render(El("main", Text("welcome")), NewContext())
|
||||
|
||||
### `StripTags`
|
||||
`func StripTags(html string) string`
|
||||
|
||||
StripTags removes HTML tags from rendered output, returning plain text.
|
||||
Usage example: text := StripTags("<main>Hello <strong>world</strong></main>")
|
||||
Tag boundaries are collapsed into single spaces; result is trimmed.
|
||||
Does not handle script/style element content (go-html does not generate these).
|
||||
|
||||
### `Switch`
|
||||
`func Switch(selector func(*Context) string, cases map[string]Node) Node`
|
||||
|
||||
Switch renders based on runtime selector value.
|
||||
Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
||||
|
||||
### `Text`
|
||||
`func Text(key string, args ...any) Node`
|
||||
|
||||
Text creates a node that renders through the go-i18n grammar pipeline.
|
||||
Usage example: Text("welcome", "Ada")
|
||||
Output is HTML-escaped by default. Safe-by-default path.
|
||||
|
||||
### `Unless`
|
||||
`func Unless(cond func(*Context) bool, node Node) Node`
|
||||
|
||||
Unless renders child only when condition is false.
|
||||
Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
||||
48
test_helpers_test.go
Normal file
48
test_helpers_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
func containsText(s, substr string) bool {
|
||||
return core.Contains(s, substr)
|
||||
}
|
||||
|
||||
func countText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return len(s) + 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i <= len(s)-len(substr); {
|
||||
j := indexText(s[i:], substr)
|
||||
if j < 0 {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
i += j + len(substr)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func indexText(s, substr string) int {
|
||||
if substr == "" {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func itoaText(v int) string {
|
||||
return core.Sprint(v)
|
||||
}
|
||||
38
text_builder_default.go
Normal file
38
text_builder_default.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
type builderOps interface {
|
||||
WriteByte(byte) error
|
||||
WriteRune(rune) (int, error)
|
||||
WriteString(string) (int, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
type textBuilder struct {
|
||||
inner builderOps
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{inner: core.NewBuilder()}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
return b.inner.WriteByte(c)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
return b.inner.WriteRune(r)
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
return b.inner.WriteString(s)
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return b.inner.String()
|
||||
}
|
||||
33
text_builder_js.go
Normal file
33
text_builder_js.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//go:build js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
type textBuilder struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newTextBuilder() *textBuilder {
|
||||
return &textBuilder{buf: make([]byte, 0, 128)}
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteByte(c byte) error {
|
||||
b.buf = append(b.buf, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteRune(r rune) (int, error) {
|
||||
s := string(r)
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) WriteString(s string) (int, error) {
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *textBuilder) String() string {
|
||||
return string(b.buf)
|
||||
}
|
||||
11
text_translate.go
Normal file
11
text_translate.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
func translateText(ctx *Context, key string, args ...any) string {
|
||||
if ctx != nil && ctx.service != nil {
|
||||
return ctx.service.T(key, args...)
|
||||
}
|
||||
|
||||
return translateDefault(key, args...)
|
||||
}
|
||||
11
text_translate_default.go
Normal file
11
text_translate_default.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
import i18n "dappco.re/go/core/i18n"
|
||||
|
||||
func translateDefault(key string, args ...any) string {
|
||||
return i18n.T(key, args...)
|
||||
}
|
||||
9
text_translate_js.go
Normal file
9
text_translate_js.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build js
|
||||
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package html
|
||||
|
||||
func translateDefault(key string, _ ...any) string {
|
||||
return key
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue