[agent/codex] Upgrade this package to dappco.re/go/core v0.8.0-alpha.1. Re... #8

Merged
Virgil merged 3 commits from agent/ax-review--banned-imports--test-naming into dev 2026-03-26 15:24:50 +00:00
33 changed files with 408 additions and 164 deletions

View file

@ -1,7 +1,6 @@
package html
import (
"fmt"
"testing"
i18n "dappco.re/go/core/i18n"
@ -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()

View file

@ -1,3 +1,5 @@
//go:build !js
// 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.
//

View file

@ -1,51 +1,87 @@
//go:build !js
package main
import (
"bytes"
"strings"
"testing"
core "dappco.re/go/core"
"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(t *testing.T) {
input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
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(t *testing.T) {
input := core.NewReader(`not json`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
func TestRun_Bad_InvalidTag(t *testing.T) {
input := strings.NewReader(`{"H":"notag"}`)
var output bytes.Buffer
func TestRun_InvalidTag(t *testing.T) {
input := core.NewReader(`{"H":"notag"}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
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_EmptySlots(t *testing.T) {
input := core.NewReader(`{}`)
output := core.NewBuilder()
err := run(input, &output)
err := run(input, output)
require.NoError(t, err)
assert.Empty(t, output.String())
}
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
}

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestBuildComponentJS_Good(t *testing.T) {
func TestBuildComponentJS_ValidJSON(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(t *testing.T) {
_, err := buildComponentJS("not json")
assert.Error(t, err)
}

View file

@ -4,13 +4,13 @@
package main
import (
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"testing"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -21,7 +21,7 @@ const (
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
)
func TestWASMBinarySize_Good(t *testing.T) {
func TestWASMBinarySize_WithinBudget(t *testing.T) {
if testing.Short() {
t.Skip("skipping WASM build test in short mode")
}
@ -38,8 +38,8 @@ func TestWASMBinarySize_Good(t *testing.T) {
require.NoError(t, err)
raw := []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)
require.NoError(t, err)

View file

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

View file

@ -1,10 +1,11 @@
//go:build !js
package codegen
import (
"fmt"
"strings"
"text/template"
core "dappco.re/go/core"
log "dappco.re/go/core/log"
)
@ -32,11 +33,11 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
// GenerateClass produces a JS class definition for a custom element.
func GenerateClass(tag, slot string) (string, error) {
if !strings.Contains(tag, "-") {
if !core.Contains(tag, "-") {
return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+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),
@ -51,15 +52,15 @@ func GenerateClass(tag, slot string) (string, error) {
// GenerateRegistration produces the customElements.define() call.
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.
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:])
}
}
@ -70,7 +71,7 @@ func TagToClassName(tag string) string {
// for a set of HLCRF slot assignments.
func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool)
var b strings.Builder
b := core.NewBuilder()
for slot, tag := range slots {
if seen[tag] {

View file

@ -1,14 +1,15 @@
//go:build !js
package codegen
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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")
@ -17,19 +18,19 @@ func TestGenerateClass_Good(t *testing.T) {
assert.Contains(t, js, "photo-grid")
}
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
func TestGenerateClass_InvalidTag(t *testing.T) {
_, err := GenerateClass("invalid", "C")
assert.Error(t, err, "custom element names must contain a hyphen")
}
func TestGenerateRegistration_Good(t *testing.T) {
func TestGenerateRegistration_DefinesCustomElement(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(t *testing.T) {
tests := []struct{ tag, want string }{
{"photo-grid", "PhotoGrid"},
{"nav-breadcrumb", "NavBreadcrumb"},
@ -41,14 +42,51 @@ func TestTagToClassName_Good(t *testing.T) {
}
}
func TestGenerateBundle_Good(t *testing.T) {
func TestGenerateBundle_DeduplicatesRegistrations(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 countSubstr(s, substr string) int {
if substr == "" {
return len(s) + 1
}
count := 0
for i := 0; i <= len(s)-len(substr); {
j := indexSubstr(s[i:], substr)
if j < 0 {
return count
}
count++
i += j + len(substr)
}
return count
}
func indexSubstr(s, substr string) int {
if substr == "" {
return 0
}
if len(substr) > len(s) {
return -1
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

13
codegen/doc.go Normal file
View file

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

View file

@ -1,6 +1,12 @@
package html
import i18n "dappco.re/go/core/i18n"
// Translator provides Text() lookups for a rendering context.
//
// 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.
type Context struct {
@ -8,7 +14,7 @@ type Context struct {
Locale string
Entitlements func(feature string) bool
Data map[string]any
service *i18n.Service
service Translator
}
// NewContext creates a new rendering context with sensible defaults.
@ -18,8 +24,8 @@ func NewContext() *Context {
}
}
// NewContextWithService creates a rendering context backed by a specific i18n service.
func NewContextWithService(svc *i18n.Service) *Context {
// NewContextWithService creates a rendering context backed by a specific translator.
func NewContextWithService(svc Translator) *Context {
return &Context{
Data: make(map[string]any),
service: svc,

12
doc.go Normal file
View file

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

View file

@ -23,7 +23,7 @@ All concrete node types are unexported structs with exported constructor functio
|-------------|-----------|
| `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. |
| `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 +50,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

View file

@ -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
@ -278,7 +278,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 +291,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 requires `NewContextWithService()`. There is no way to swap the translator after construction.
- 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.

View file

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

View file

@ -1,8 +1,6 @@
package html
import (
"fmt"
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -33,7 +31,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)
}
@ -80,10 +78,10 @@ func TestEl_RTL(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)
}
}
@ -168,7 +166,7 @@ func TestAttr_UnicodeValue(t *testing.T) {
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)
}
}
@ -187,7 +185,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,12 +194,12 @@ 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)
}
}
@ -216,10 +214,10 @@ 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)
}
}
@ -238,7 +236,7 @@ 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")
}
}
@ -251,18 +249,18 @@ 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")
}
}
@ -275,12 +273,12 @@ 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)
}
}
@ -292,19 +290,19 @@ func TestEach_NestedEach(t *testing.T) {
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'")
}
}
@ -351,14 +349,14 @@ 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)
}
}
@ -370,7 +368,7 @@ func TestLayout_DuplicateVariantChars(t *testing.T) {
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)
}
@ -444,10 +442,10 @@ func TestEscapeAttr_AllSpecialChars(t *testing.T) {
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, "&amp;&lt;&gt;&#34;&#39;") {
if !containsText(got, "&amp;&lt;&gt;&#34;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
}
}
@ -458,7 +456,7 @@ func TestElNode_EmptyTag(t *testing.T) {
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)
}
}

11
go.mod
View file

@ -3,14 +3,14 @@ module dappco.re/go/core/html
go 1.26.0
require (
dappco.re/go/core/i18n v0.1.8
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1
)
require (
dappco.re/go/core v0.5.0 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@ -18,10 +18,3 @@ require (
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
dappco.re/go/core => ../../../../core/go
dappco.re/go/core/i18n => ../../../../core/go-i18n
dappco.re/go/core/io => ../../../../core/go-io
dappco.re/go/core/log => ../../../../core/go-log
)

8
go.sum
View file

@ -1,3 +1,11 @@
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.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/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=
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=

View file

@ -1,7 +1,5 @@
package html
import "strings"
// Compile-time interface check.
var _ Node = (*Layout)(nil)
@ -75,7 +73,7 @@ func (l *Layout) blockID(slot byte) string {
// Render produces the semantic HTML for this layout.
// Only slots present in the variant string are rendered.
func (l *Layout) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for i := range len(l.variant) {
slot := l.variant[i]

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -13,28 +12,28 @@ 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)
}
}
@ -48,14 +47,14 @@ 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)
}
}
@ -68,16 +67,16 @@ func TestLayout_ContentOnly(t *testing.T) {
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)
}
}
@ -104,13 +103,13 @@ func TestLayout_IgnoresInvalidSlots(t *testing.T) {
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)
}
}

15
node.go
View file

@ -5,9 +5,6 @@ import (
"iter"
"maps"
"slices"
"strings"
i18n "dappco.re/go/core/i18n"
)
// Node is anything renderable.
@ -98,7 +95,7 @@ func Attr(n Node, key, value string) Node {
}
func (n *elNode) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
@ -152,13 +149,7 @@ func Text(key string, args ...any) Node {
}
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...)
}
return escapeHTML(text)
return escapeHTML(translateText(ctx, n.key, n.args...))
}
// --- ifNode ---
@ -257,7 +248,7 @@ func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
}
func (n *eachNode[T]) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for item := range n.items {
b.WriteString(n.fn(item).Render(ctx))
}

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -69,10 +68,10 @@ func TestTextNode_Escapes(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, "&lt;script&gt;") {
if !containsText(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got)
}
}
@ -171,7 +170,7 @@ func TestElNode_AttrEscaping(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !strings.Contains(got, `alt="he said &#34;hello&#34;"`) {
if !containsText(got, `alt="he said &#34;hello&#34;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}
@ -180,7 +179,7 @@ func TestElNode_MultipleAttrs(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)
}
}

18
path.go
View file

@ -1,7 +1,5 @@
package html
import "strings"
// ParseBlockID extracts the slot sequence from a data-block ID.
// "L-0-C-0" → ['L', 'C']
func ParseBlockID(id string) []byte {
@ -12,12 +10,18 @@ func ParseBlockID(id string) []byte {
// 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])
part := 0
start := 0
for i := 0; i <= len(id); i++ {
if i < len(id) && id[i] != '-' {
continue
}
i++
if part%2 == 0 && i-start == 1 {
slots = append(slots, id[start])
}
part++
start = i + 1
}
return slots
}

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -13,14 +12,14 @@ 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)
}
}
@ -33,7 +32,7 @@ func TestNestedLayout_DeepNesting(t *testing.T) {
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)
}
}

View file

@ -3,7 +3,7 @@
package html
import (
"strings"
core "dappco.re/go/core"
"dappco.re/go/core/i18n/reversal"
)
@ -12,7 +12,7 @@ import (
// 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,7 +40,7 @@ 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,

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
i18n "dappco.re/go/core/i18n"
@ -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,7 +43,7 @@ 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)
}
}
@ -67,13 +66,13 @@ 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)
}
}
@ -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, "&lt;script&gt;") {
if !containsText(got, "&lt;script&gt;") {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
}
}

View file

@ -1,7 +1,5 @@
package html
import "strings"
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
@ -27,7 +25,7 @@ func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
// Render produces HTML with each variant in a data-variant container.
func (r *Responsive) Render(ctx *Context) string {
var b strings.Builder
b := newTextBuilder()
for _, v := range r.variants {
b.WriteString(`<div data-variant="`)
b.WriteString(escapeAttr(v.name))

View file

@ -1,7 +1,6 @@
package html
import (
"strings"
"testing"
)
@ -12,10 +11,10 @@ func TestResponsive_SingleVariant(t *testing.T) {
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)
}
}
@ -30,7 +29,7 @@ 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)
}
}
@ -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)
}
@ -62,10 +61,10 @@ 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)
}
}
@ -78,7 +77,7 @@ 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)
}

48
test_helpers_test.go Normal file
View file

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

38
text_builder_default.go Normal file
View file

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

33
text_builder_js.go Normal file
View file

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

11
text_translate.go Normal file
View file

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

11
text_translate_default.go Normal file
View file

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

9
text_translate_js.go Normal file
View file

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