Merge pull request '[agent/codex] Upgrade this package to dappco.re/go/core v0.8.0-alpha.1. Re...' (#8) from agent/ax-review--banned-imports--test-naming into dev
This commit is contained in:
commit
2a5bd5cbba
33 changed files with 408 additions and 164 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !js
|
||||
|
||||
package codegen
|
||||
|
||||
import "testing"
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
14
context.go
14
context.go
|
|
@ -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
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
56
edge_test.go
56
edge_test.go
|
|
@ -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, "&<>"'") {
|
||||
if !containsText(got, "&<>"'") {
|
||||
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
11
go.mod
|
|
@ -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
8
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
15
node.go
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
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 "hello""`) {
|
||||
if !containsText(got, `alt="he said "hello""`) {
|
||||
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
18
path.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, "<script>") {
|
||||
if !containsText(got, "<script>") {
|
||||
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
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