` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
+**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `
` container for CSS or JavaScript targeting, and `Responsive.Add(name, layout, media)` can also annotate the container with `data-media`. `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.
@@ -65,14 +65,17 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`
item`
+ if got != want {
+ t.Fatalf("wrapped Each layout render = %q, want %q", got, want)
+ }
+}
+
// --- Layout variant validation ---
func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
@@ -342,33 +373,28 @@ func TestLayout_InvalidVariantChars_Bad(t *testing.T) {
}
}
-func TestLayout_VariantError_Bad(t *testing.T) {
+func TestLayout_VariantError_NoOp_Good(t *testing.T) {
tests := []struct {
- name string
- variant string
- wantInvalid bool
- wantErrString string
- build func(*Layout)
- wantRender string
+ name string
+ variant string
+ build func(*Layout)
+ wantRender string
}{
{
- name: "valid variant",
- variant: "HCF",
- wantInvalid: false,
+ name: "valid variant",
+ variant: "HCF",
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
},
- wantRender: `main`,
+ wantRender: `main`,
},
{
- name: "mixed invalid variant",
- variant: "HXC",
- wantInvalid: true,
- wantErrString: "html: invalid layout variant HXC",
+ name: "mixed invalid variant",
+ variant: "HXC",
build: func(layout *Layout) {
layout.H(Raw("header")).C(Raw("main"))
},
- wantRender: `main`,
+ wantRender: `main`,
},
}
@@ -378,17 +404,7 @@ func TestLayout_VariantError_Bad(t *testing.T) {
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 {
+ if layout.VariantError() != nil {
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
}
@@ -400,30 +416,19 @@ func TestLayout_VariantError_Bad(t *testing.T) {
}
}
-func TestValidateLayoutVariant_Good(t *testing.T) {
+func TestValidateLayoutVariant_NoOp_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},
+ {name: "valid", variant: "HCF"},
+ {name: "invalid", variant: "HXC"},
+ {name: "empty", variant: ""},
}
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)
}
@@ -464,6 +469,19 @@ func TestLayout_DuplicateVariantChars_Ugly(t *testing.T) {
}
}
+func TestLayout_DuplicateVariantChars_UniqueBlockIDs_Good(t *testing.T) {
+ ctx := NewContext()
+
+ layout := NewLayout("CCC").C(Raw("content"))
+ got := layout.Render(ctx)
+
+ for _, want := range []string{`data-block="C"`, `data-block="C.1"`, `data-block="C.2"`} {
+ if !containsText(got, want) {
+ t.Fatalf("CCC variant should assign unique block ID %q, got:\n%s", want, got)
+ }
+ }
+}
+
func TestLayout_EmptySlots_Ugly(t *testing.T) {
ctx := NewContext()
@@ -484,7 +502,7 @@ func TestLayout_NestedThroughIf_Ugly(t *testing.T) {
got := outer.Render(ctx)
- if !containsText(got, `data-block="C-0-C-0"`) {
+ if !containsText(got, `data-block="C.0"`) {
t.Fatalf("nested layout inside If should inherit block path, got:\n%s", got)
}
}
@@ -500,7 +518,7 @@ func TestLayout_NestedThroughSwitch_Ugly(t *testing.T) {
got := outer.Render(ctx)
- if !containsText(got, `data-block="C-0-C-0"`) {
+ if !containsText(got, `data-block="C.0"`) {
t.Fatalf("nested layout inside Switch should inherit block path, got:\n%s", got)
}
}
diff --git a/entitled.go b/entitled.go
new file mode 100644
index 0000000..a864899
--- /dev/null
+++ b/entitled.go
@@ -0,0 +1,79 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+// EntitlementChecker decides whether a feature key is granted for the caller's
+// context. Implementations live downstream.
+type EntitlementChecker interface {
+ Check(feature string) bool
+}
+
+type denyAllChecker struct{}
+
+func (denyAllChecker) Check(string) bool {
+ return false
+}
+
+var denyAll EntitlementChecker = denyAllChecker{}
+
+type emptyNode struct{}
+
+var (
+ _ Node = emptyNode{}
+ _ layoutPathRenderer = emptyNode{}
+)
+
+func (emptyNode) Render(*Context) string {
+ return ""
+}
+
+func (emptyNode) renderWithLayoutPath(*Context, string) string {
+ return ""
+}
+
+func (emptyNode) isNilHTMLNode() bool {
+ return true
+}
+
+func emptySentinel() Node {
+ return emptyNode{}
+}
+
+// Entitled returns node unchanged when checker.Check(feature) is true, or an
+// empty Node sentinel when false. Default: deny.
+//
+// The legacy Entitled(feature, node) form is retained for existing context-based
+// callers and renders through Context.Entitlements.
+func Entitled(args ...any) Node {
+ switch len(args) {
+ case 2:
+ feature, ok := args[0].(string)
+ if !ok || feature == "" {
+ return emptySentinel()
+ }
+ node, ok := args[1].(Node)
+ if !ok || isNilNode(node) {
+ return emptySentinel()
+ }
+ return &entitledNode{feature: feature, node: node}
+ case 3:
+ checker, _ := args[0].(EntitlementChecker)
+ feature, ok := args[1].(string)
+ if !ok || feature == "" {
+ return emptySentinel()
+ }
+ node, ok := args[2].(Node)
+ if !ok || isNilNode(node) {
+ return emptySentinel()
+ }
+ if checker == nil {
+ checker = denyAll
+ }
+ if !checker.Check(feature) {
+ return emptySentinel()
+ }
+ return node
+ default:
+ return emptySentinel()
+ }
+}
diff --git a/entitled_test.go b/entitled_test.go
new file mode 100644
index 0000000..83afe73
--- /dev/null
+++ b/entitled_test.go
@@ -0,0 +1,57 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+import "testing"
+
+type stubEntitlementChecker map[string]bool
+
+func (s stubEntitlementChecker) Check(feature string) bool {
+ return s[feature]
+}
+
+func TestEntitledChecker_GoodBadUgly(t *testing.T) {
+ tests := []struct {
+ name string
+ checker EntitlementChecker
+ feature string
+ node Node
+ want string
+ same bool
+ }{
+ {
+ name: "Good: granted feature returns node unchanged",
+ checker: stubEntitlementChecker{"premium": true},
+ feature: "premium",
+ node: Raw("premium content"),
+ want: "premium content",
+ same: true,
+ },
+ {
+ name: "Bad: denied feature returns empty Node",
+ checker: stubEntitlementChecker{"premium": false},
+ feature: "premium",
+ node: Raw("premium content"),
+ want: "",
+ },
+ {
+ name: "Ugly: nil checker returns empty Node",
+ checker: nil,
+ feature: "premium",
+ node: Raw("premium content"),
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Entitled(tt.checker, tt.feature, tt.node)
+ if tt.same && got != tt.node {
+ t.Fatalf("Entitled() should return the original node for granted feature")
+ }
+ if rendered := got.Render(NewContext()); rendered != tt.want {
+ t.Fatalf("Entitled().Render() = %q, want %q", rendered, tt.want)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index f9d51e8..ed4e490 100644
--- a/go.mod
+++ b/go.mod
@@ -1,21 +1,26 @@
-module dappco.re/go/core/html
+module dappco.re/go/html
go 1.26.0
require (
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
+ dappco.re/go/i18n v0.8.0-alpha.1
+ dappco.re/go/io v0.8.0-alpha.1
+ dappco.re/go/log v0.8.0-alpha.1
+ dappco.re/go/process v0.8.0-alpha.1
+ github.com/gin-gonic/gin v1.12.0
)
require (
- 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
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ dappco.re/go/inference v0.8.0-alpha.1 // indirect
+ golang.org/x/text v0.36.0 // indirect
+)
+
+replace (
+ dappco.re/go/core => ../go
+ dappco.re/go/i18n => ../go-i18n
+ dappco.re/go/inference => ../go-inference
+ dappco.re/go/io => ../go-io
+ dappco.re/go/log => ../go-log
+ dappco.re/go/process => ../go-process
)
diff --git a/go.sum b/go.sum
index 2e70fad..6a755ae 100644
--- a/go.sum
+++ b/go.sum
@@ -14,20 +14,11 @@ 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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..957f5b9
--- /dev/null
+++ b/go.work
@@ -0,0 +1,3 @@
+go 1.26.0
+
+use .
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..141fcc6
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,233 @@
+dappco.re/go/core/ws v0.2.4 h1:aQjFzI7VsZVUYeuArnKsAnrl2Oq7L1VtTcVk9RF6W1o=
+dappco.re/go/core/ws v0.2.4/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4=
+forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8=
+forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
+forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
+forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
+forge.lthn.ai/core/go v0.3.0 h1:mOG97ApMprwx9Ked62FdWVwXTGSF6JO6m0DrVpoH2Q4=
+forge.lthn.ai/core/go v0.3.0/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
+forge.lthn.ai/core/go-crypt v0.1.6 h1:jB7L/28S1NR+91u3GcOYuKfBLzPhhBUY1fRe6WkGVns=
+forge.lthn.ai/core/go-crypt v0.1.6/go.mod h1:4VZAGqxlbadhSB66sJkdj54/HSJ+bSxVgwWK5kMMYDo=
+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=
+github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
+github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
+github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
+github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
+github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
+github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
+github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
+github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
+github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
+github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
+github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
+github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
+github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
+github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
+github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
+github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY=
+github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
+github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
+github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
+github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
+github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
+github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU=
+github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
+github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
+github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
+github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
+github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
+github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
+github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
+github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
+github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
+github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
+github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
+github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
+github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
+github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
+github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
+github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
+github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
+github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
+github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
+github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
+github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
+github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
+github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
+github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
+github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
+github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
+github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
+github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
+github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
+go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
+go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
+go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
+golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
diff --git a/grammar.go b/grammar.go
new file mode 100644
index 0000000..3681c92
--- /dev/null
+++ b/grammar.go
@@ -0,0 +1,435 @@
+package html
+
+import (
+ "hash/fnv"
+ "reflect"
+ "strconv"
+)
+
+const (
+ defaultGrammarImprintMaxDepth = 128
+ defaultGrammarImprintMaxPathLen = 256
+)
+
+// Stamp is a structural fingerprint for a node position in the HLCRF tree.
+type Stamp struct {
+ Path string
+ Hash uint64
+ Tags []string
+}
+
+// GrammarImprint classifies node structure without reading rendered content.
+type GrammarImprint struct {
+ maxDepth int
+ maxPathLen int
+}
+
+// Imprint returns a structural stamp for node without rendering it or reading
+// text/raw content.
+func (g *GrammarImprint) Imprint(node Node, ctx Context) Stamp {
+ if isNilNode(node) {
+ return Stamp{}
+ }
+
+ w := grammarImprintWalker{
+ ctx: &ctx,
+ maxDepth: g.configuredMaxDepth(),
+ maxPathLen: g.configuredMaxPathLen(),
+ }
+ return w.imprint(node, "", 0)
+}
+
+func (g *GrammarImprint) configuredMaxDepth() int {
+ if g != nil && g.maxDepth > 0 {
+ return g.maxDepth
+ }
+ return defaultGrammarImprintMaxDepth
+}
+
+func (g *GrammarImprint) configuredMaxPathLen() int {
+ if g != nil && g.maxPathLen > 0 {
+ return g.maxPathLen
+ }
+ return defaultGrammarImprintMaxPathLen
+}
+
+type grammarImprintWalker struct {
+ ctx *Context
+ maxDepth int
+ maxPathLen int
+}
+
+func (w grammarImprintWalker) imprint(node Node, path string, depth int) Stamp {
+ if isNilNode(node) {
+ return Stamp{}
+ }
+ if depth >= w.maxDepth {
+ return w.stamp(node, path, true)
+ }
+
+ switch n := node.(type) {
+ case *Layout:
+ return w.imprintLayout(n, path, depth)
+ case *Responsive:
+ return w.imprintResponsive(n, path, depth)
+ case *ifNode:
+ if n == nil || n.cond == nil || n.node == nil || !n.cond(w.ctx) {
+ return w.emptyStamp(node, path)
+ }
+ return w.imprint(n.node, path, depth+1)
+ case *unlessNode:
+ if n == nil || n.cond == nil || n.node == nil || n.cond(w.ctx) {
+ return w.emptyStamp(node, path)
+ }
+ return w.imprint(n.node, path, depth+1)
+ case *entitledNode:
+ if n == nil || n.node == nil || w.ctx == nil || w.ctx.Entitlements == nil || !w.ctx.Entitlements(n.feature) {
+ return w.emptyStamp(node, path)
+ }
+ return w.imprint(n.node, path, depth+1)
+ case *switchNode:
+ if n == nil || n.selector == nil || n.cases == nil {
+ return w.emptyStamp(node, path)
+ }
+ child := n.cases[n.selector(w.ctx)]
+ if child == nil {
+ return w.emptyStamp(node, path)
+ }
+ return w.imprint(child, path, depth+1)
+ default:
+ return w.stamp(node, path, false)
+ }
+}
+
+func (w grammarImprintWalker) imprintLayout(l *Layout, path string, depth int) Stamp {
+ if l == nil {
+ return Stamp{}
+ }
+
+ slotCounts := make(map[byte]int)
+ slotOrdinal := 0
+
+ for i := range len(l.variant) {
+ slot := l.variant[i]
+ if _, ok := slotRegistry[slot]; !ok {
+ continue
+ }
+
+ count := slotOrdinal
+ slotOrdinal++
+
+ children := l.slots[slot]
+ if len(children) == 0 {
+ continue
+ }
+
+ if path == "" {
+ count = slotCounts[slot]
+ slotCounts[slot] = count + 1
+ }
+
+ blockPath := w.layoutBlockPath(path, slot, count)
+ for childIndex, child := range children {
+ if isNilNode(child) {
+ continue
+ }
+ childPath := w.joinPath(blockPath, strconv.Itoa(childIndex))
+ return w.imprint(child, childPath, depth+1)
+ }
+
+ return w.slotStamp(blockPath)
+ }
+
+ return w.stamp(l, path, false)
+}
+
+func (w grammarImprintWalker) imprintResponsive(r *Responsive, path string, depth int) Stamp {
+ if r == nil {
+ return Stamp{}
+ }
+ for _, variant := range r.variants {
+ if variant.layout == nil {
+ continue
+ }
+ return w.imprint(variant.layout, path, depth+1)
+ }
+ return w.stamp(r, path, false)
+}
+
+func (w grammarImprintWalker) stamp(node Node, path string, truncated bool) Stamp {
+ path = w.normalizedPath(node, path)
+ childCount := structuralChildCount(node, w.ctx)
+ tags := structuralTags(childCount, truncated, structuralEmpty(node, w.ctx))
+ nodeType := structuralNodeType(node)
+
+ return Stamp{
+ Path: path,
+ Hash: grammarStampHash(path, nodeType, childCount),
+ Tags: tags,
+ }
+}
+
+func (w grammarImprintWalker) emptyStamp(node Node, path string) Stamp {
+ path = w.normalizedPath(node, path)
+ return Stamp{
+ Path: path,
+ Hash: grammarStampHash(path, structuralNodeType(node), 0),
+ Tags: []string{"empty"},
+ }
+}
+
+func (w grammarImprintWalker) slotStamp(path string) Stamp {
+ return Stamp{
+ Path: path,
+ Hash: grammarStampHash(path, "layout-slot", 0),
+ Tags: []string{"empty"},
+ }
+}
+
+func (w grammarImprintWalker) normalizedPath(node Node, path string) string {
+ if path != "" || isCoordinateContainer(node) {
+ return w.clampPath(path)
+ }
+ return "0"
+}
+
+func isCoordinateContainer(node Node) bool {
+ switch node.(type) {
+ case *Layout, *Responsive:
+ return true
+ default:
+ return false
+ }
+}
+
+func (w grammarImprintWalker) layoutBlockPath(base string, slot byte, rendered int) string {
+ if base == "" {
+ if rendered == 0 {
+ return w.clampPath(string(slot))
+ }
+ return w.joinPath(string(slot), strconv.Itoa(rendered))
+ }
+ if rendered == 0 {
+ return w.clampPath(base)
+ }
+ return w.joinPath(base, strconv.Itoa(rendered))
+}
+
+func (w grammarImprintWalker) joinPath(path, coord string) string {
+ if coord == "" {
+ return w.clampPath(path)
+ }
+ if path == "" {
+ return w.clampPath(coord)
+ }
+ if len(path)+1+len(coord) <= w.maxPathLen {
+ return path + "." + coord
+ }
+ if len(path) >= w.maxPathLen {
+ return path[:w.maxPathLen]
+ }
+ remaining := w.maxPathLen - len(path)
+ if remaining <= 1 {
+ return path
+ }
+ return path + "." + coord[:remaining-1]
+}
+
+func (w grammarImprintWalker) clampPath(path string) string {
+ if len(path) <= w.maxPathLen {
+ return path
+ }
+ return path[:w.maxPathLen]
+}
+
+func structuralChildCount(node Node, ctx *Context) int {
+ switch n := node.(type) {
+ case *rawNode, *textNode:
+ return 0
+ case *elNode:
+ if n == nil {
+ return 0
+ }
+ return countNodes(n.children)
+ case *ifNode:
+ if n == nil || n.cond == nil || n.node == nil || !n.cond(ctx) {
+ return 0
+ }
+ return 1
+ case *unlessNode:
+ if n == nil || n.cond == nil || n.node == nil || n.cond(ctx) {
+ return 0
+ }
+ return 1
+ case *entitledNode:
+ if n == nil || n.node == nil || ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
+ return 0
+ }
+ return 1
+ case *switchNode:
+ if n == nil {
+ return 0
+ }
+ return countMapNodes(n.cases)
+ case *Layout:
+ if n == nil {
+ return 0
+ }
+ return countLayoutChildren(n)
+ case *Responsive:
+ if n == nil {
+ return 0
+ }
+ count := 0
+ for _, variant := range n.variants {
+ if variant.layout != nil {
+ count++
+ }
+ }
+ return count
+ default:
+ return structuralEachChildCount(node)
+ }
+}
+
+func structuralEachChildCount(node Node) int {
+ n, ok := node.(interface{ isNilHTMLNode() bool })
+ if !ok || n.isNilHTMLNode() {
+ return 0
+ }
+
+ value := reflect.ValueOf(node)
+ if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
+ return 0
+ }
+ elem := value.Elem()
+ if !elem.IsValid() || elem.Kind() != reflect.Struct {
+ return 0
+ }
+ items := elem.FieldByName("items")
+ if items.IsValid() && items.Kind() == reflect.Slice {
+ return items.Len()
+ }
+ return 0
+}
+
+func countLayoutChildren(l *Layout) int {
+ if l == nil {
+ return 0
+ }
+ count := 0
+ for i := range len(l.variant) {
+ slot := l.variant[i]
+ if _, ok := slotRegistry[slot]; !ok {
+ continue
+ }
+ count += countNodes(l.slots[slot])
+ }
+ return count
+}
+
+func countNodes(nodes []Node) int {
+ count := 0
+ for _, node := range nodes {
+ if !isNilNode(node) {
+ count++
+ }
+ }
+ return count
+}
+
+func countMapNodes(nodes map[string]Node) int {
+ count := 0
+ for _, node := range nodes {
+ if !isNilNode(node) {
+ count++
+ }
+ }
+ return count
+}
+
+func structuralEmpty(node Node, ctx *Context) bool {
+ switch n := node.(type) {
+ case *ifNode:
+ return n == nil || n.cond == nil || n.node == nil || !n.cond(ctx)
+ case *unlessNode:
+ return n == nil || n.cond == nil || n.node == nil || n.cond(ctx)
+ case *entitledNode:
+ return n == nil || n.node == nil || ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature)
+ case *switchNode:
+ if n == nil || n.selector == nil || n.cases == nil {
+ return true
+ }
+ return isNilNode(n.cases[n.selector(ctx)])
+ case *Layout:
+ return countLayoutChildren(n) == 0
+ case *Responsive:
+ return structuralChildCount(n, ctx) == 0
+ default:
+ t := reflect.TypeOf(node)
+ if t == nil || t.Kind() != reflect.Pointer || t.Elem().Kind() != reflect.Struct {
+ return false
+ }
+ if _, ok := node.(interface{ isNilHTMLNode() bool }); !ok {
+ return false
+ }
+ return structuralEachChildCount(node) == 0
+ }
+}
+
+func structuralTags(childCount int, truncated, empty bool) []string {
+ if truncated {
+ return []string{"branch", "truncated"}
+ }
+ if empty {
+ return []string{"empty"}
+ }
+ if childCount == 0 {
+ return []string{"leaf"}
+ }
+ return []string{"branch"}
+}
+
+func structuralNodeType(node Node) string {
+ switch n := node.(type) {
+ case *rawNode:
+ return "raw"
+ case *textNode:
+ return "text"
+ case *elNode:
+ if n == nil {
+ return "element"
+ }
+ return "element:" + n.tag
+ case *ifNode:
+ return "if"
+ case *unlessNode:
+ return "unless"
+ case *entitledNode:
+ return "entitled"
+ case *switchNode:
+ return "switch"
+ case *Layout:
+ return "layout"
+ case *Responsive:
+ return "responsive"
+ default:
+ t := reflect.TypeOf(node)
+ if t == nil {
+ return ""
+ }
+ return t.String()
+ }
+}
+
+func grammarStampHash(path, nodeType string, childCount int) uint64 {
+ // Sanctioned exception: core.Hash64 is unavailable in the current core
+ // module, so RFC §4's deterministic 64-bit structural hash uses stdlib FNV.
+ h := fnv.New64()
+ _, _ = h.Write([]byte(path))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write([]byte(nodeType))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write([]byte(strconv.Itoa(childCount)))
+ return h.Sum64()
+}
diff --git a/grammar_test.go b/grammar_test.go
new file mode 100644
index 0000000..ed3ee01
--- /dev/null
+++ b/grammar_test.go
@@ -0,0 +1,89 @@
+package html
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestGrammarImprint_KnownTreePathDeterministic_Good(t *testing.T) {
+ ctx := Context{}
+ page := NewLayout("HCF").
+ H(El("h1", Raw("title"))).
+ C(El("section", El("p", Text("body")))).
+ F(El("small", Raw("foot")))
+
+ imprinter := &GrammarImprint{}
+ first := imprinter.Imprint(page, ctx)
+ second := imprinter.Imprint(page, ctx)
+
+ if first.Path != "H.0" {
+ t.Fatalf("GrammarImprint path = %q, want %q", first.Path, "H.0")
+ }
+ if first.Hash == 0 {
+ t.Fatal("GrammarImprint hash should be non-zero for a known tree")
+ }
+ if first.Hash != second.Hash {
+ t.Fatalf("GrammarImprint hash should be deterministic, got %d then %d", first.Hash, second.Hash)
+ }
+ if !slices.Equal(first.Tags, []string{"branch"}) {
+ t.Fatalf("GrammarImprint tags = %v, want [branch]", first.Tags)
+ }
+
+ changedContent := NewLayout("HCF").
+ H(El("h1", Raw("different title"))).
+ C(El("section", El("p", Text("different body")))).
+ F(El("small", Raw("different foot")))
+ changed := imprinter.Imprint(changedContent, ctx)
+ if first.Hash != changed.Hash {
+ t.Fatalf("GrammarImprint hash should ignore text/raw content, got %d and %d", first.Hash, changed.Hash)
+ }
+}
+
+func TestGrammarImprint_UnsetNode_Bad(t *testing.T) {
+ var node Node
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+
+ if got.Path != "" || got.Hash != 0 || got.Tags != nil {
+ t.Fatalf("GrammarImprint nil node = %#v, want zero-value Stamp", got)
+ }
+}
+
+func TestGrammarImprint_DoesNotRenderContent_Good(t *testing.T) {
+ got := (&GrammarImprint{}).Imprint(grammarPanicNode{}, Context{})
+
+ if got.Path != "0" {
+ t.Fatalf("GrammarImprint custom node path = %q, want %q", got.Path, "0")
+ }
+ if got.Hash == 0 {
+ t.Fatal("GrammarImprint custom node hash should be non-zero")
+ }
+ if !slices.Equal(got.Tags, []string{"leaf"}) {
+ t.Fatalf("GrammarImprint custom node tags = %v, want [leaf]", got.Tags)
+ }
+}
+
+func TestGrammarImprint_DeepNestedPathBudget_Ugly(t *testing.T) {
+ var node Node = Raw("leaf")
+ for range defaultGrammarImprintMaxDepth * 3 {
+ node = NewLayout("C").C(node)
+ }
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+
+ if len(got.Path) > defaultGrammarImprintMaxPathLen {
+ t.Fatalf("GrammarImprint path length = %d, want <= %d", len(got.Path), defaultGrammarImprintMaxPathLen)
+ }
+ if got.Hash == 0 {
+ t.Fatal("GrammarImprint deep tree hash should be non-zero")
+ }
+ if !slices.Contains(got.Tags, "truncated") {
+ t.Fatalf("GrammarImprint deep tree tags = %v, want truncated marker", got.Tags)
+ }
+}
+
+type grammarPanicNode struct{}
+
+func (grammarPanicNode) Render(*Context) string {
+ panic("GrammarImprint must not render nodes")
+}
diff --git a/integration_test.go b/integration_test.go
index 00e1867..ff841dd 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -3,7 +3,7 @@ package html
import (
"testing"
- i18n "dappco.re/go/core/i18n"
+ i18n "dappco.re/go/i18n"
)
func TestIntegration_RenderThenReverse_Good(t *testing.T) {
diff --git a/layout.go b/layout.go
index 7b98899..fc88692 100644
--- a/layout.go
+++ b/layout.go
@@ -1,13 +1,27 @@
package html
-import "errors"
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
+// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
+// The stdlib strconv primitive is safe for WASM.
+
+import "strconv"
// 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")
+// ErrInvalidLayoutVariant is retained for compatibility.
+//
+// Layout variant strings now silently skip unknown characters instead of
+// surfacing validation errors, so this sentinel is never returned by the
+// current implementation.
+var ErrInvalidLayoutVariant error = layoutInvalidVariantSentinel{}
+
+type layoutInvalidVariantSentinel struct{}
+
+func (layoutInvalidVariantSentinel) Error() string {
+ return "html: invalid layout variant"
+}
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
type slotMeta struct {
@@ -18,7 +32,7 @@ type slotMeta struct {
// slotRegistry maps slot letters to their semantic HTML elements and ARIA roles.
var slotRegistry = map[byte]slotMeta{
'H': {tag: "header", role: "banner"},
- 'L': {tag: "aside", role: "complementary"},
+ 'L': {tag: "nav", role: "navigation"},
'C': {tag: "main", role: "main"},
'R': {tag: "aside", role: "complementary"},
'F': {tag: "footer", role: "contentinfo"},
@@ -29,7 +43,7 @@ var slotRegistry = map[byte]slotMeta{
// 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
+ path string // "" for root, "C.0" for nested
slots map[byte][]Node // H, L, C, R, F → children
variantErr error
}
@@ -43,51 +57,7 @@ func renderWithLayoutPath(node Node, ctx *Context, path string) string {
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)
- }
+ return node.Render(ctx)
}
// NewLayout creates a new Layout with the given variant string.
@@ -98,28 +68,16 @@ func NewLayout(variant string) *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.
+// ValidateLayoutVariant is retained for compatibility.
//
-// It returns nil for valid variants and ErrInvalidLayoutVariant wrapped in a
-// layoutVariantError for invalid ones.
+// Variant strings are permissive now: unknown characters are ignored during
+// rendering, so this helper always returns nil.
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}
+ _ = variant
+ return nil
}
func (l *Layout) slotsForSlot(slot byte) []Node {
@@ -142,7 +100,7 @@ func (l *Layout) H(nodes ...Node) *Layout {
return l
}
-// L appends nodes to the Left aside slot.
+// L appends nodes to the Left navigation slot.
// Usage example: NewLayout("LC").L(Text("nav"))
func (l *Layout) L(nodes ...Node) *Layout {
if l == nil {
@@ -182,18 +140,29 @@ func (l *Layout) F(nodes ...Node) *Layout {
return l
}
-// blockID returns the deterministic data-block attribute value for a slot.
-func (l *Layout) blockID(slot byte) string {
- return l.path + string(slot) + "-0"
+// blockID returns the deterministic data-block coordinate for a rendered slot.
+func (l *Layout) blockID(slot byte, rendered int) string {
+ if l.path == "" {
+ if rendered == 0 {
+ return string(slot)
+ }
+ return string(slot) + "." + strconv.Itoa(rendered)
+ }
+ if rendered == 0 {
+ return l.path
+ }
+ return l.path + "." + strconv.Itoa(rendered)
}
-// VariantError reports whether the layout variant string contained any invalid
-// slot characters when the layout was constructed.
+// VariantError is retained for compatibility.
+//
+// Layouts no longer record variant validation errors, so this always returns
+// nil. Unknown characters are ignored at render time.
func (l *Layout) VariantError() error {
if l == nil {
return nil
}
- return l.variantErr
+ return nil
}
// Render produces the semantic HTML for this layout.
@@ -208,20 +177,29 @@ func (l *Layout) Render(ctx *Context) string {
}
b := newTextBuilder()
+ slotCounts := make(map[byte]int)
+ slotOrdinal := 0
for i := range len(l.variant) {
slot := l.variant[i]
- children := l.slots[slot]
- if len(children) == 0 {
+ meta, ok := slotRegistry[slot]
+ if !ok {
continue
}
- meta, ok := slotRegistry[slot]
- if !ok {
+ count := slotOrdinal
+ slotOrdinal++
+
+ children := l.slots[slot]
+ if len(children) == 0 {
continue
}
- bid := l.blockID(slot)
+ if l.path == "" {
+ count = slotCounts[slot]
+ slotCounts[slot] = count + 1
+ }
+ bid := l.blockID(slot, count)
b.WriteByte('<')
b.WriteString(escapeHTML(meta.tag))
@@ -231,11 +209,11 @@ func (l *Layout) Render(ctx *Context) string {
b.WriteString(escapeAttr(bid))
b.WriteString(`">`)
- for _, child := range children {
+ for i, child := range children {
if child == nil {
continue
}
- b.WriteString(renderWithLayoutPath(child, ctx, bid+"-"))
+ b.WriteString(renderWithLayoutPath(child, ctx, bid+"."+strconv.Itoa(i)))
}
b.WriteString("")
@@ -257,3 +235,13 @@ func (e *layoutVariantError) Error() string {
func (e *layoutVariantError) Unwrap() error {
return ErrInvalidLayoutVariant
}
+
+func (l *Layout) renderWithLayoutPath(ctx *Context, path string) string {
+ if l == nil {
+ return ""
+ }
+
+ clone := *l
+ clone.path = path
+ return clone.Render(ctx)
+}
diff --git a/layout_test.go b/layout_test.go
index 704b52f..1460a1d 100644
--- a/layout_test.go
+++ b/layout_test.go
@@ -11,21 +11,21 @@ func TestLayout_HLCRF_Good(t *testing.T) {
got := layout.Render(ctx)
// Must contain semantic elements
- for _, want := range []string{"content`
+ want := `content`
if got != want {
t.Fatalf("layout.Render(nil) = %q, want %q", got, want)
}
diff --git a/node.go b/node.go
index 6b2f720..0a16e5e 100644
--- a/node.go
+++ b/node.go
@@ -1,10 +1,18 @@
package html
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
+// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
+// The small stdlib imports below preserve escaping and deterministic rendering
+// without pulling in that larger dependency graph.
+
import (
+ // Note: html — needed for text sanitization via html.EscapeString in render.
"html"
"iter"
"maps"
"slices"
+ // Note: strconv — needed for numeric attribute conversion (Atoi/Itoa) in rendering.
"strconv"
)
@@ -71,6 +79,10 @@ func (n *rawNode) Render(_ *Context) string {
return n.content
}
+func (n *rawNode) renderWithLayoutPath(_ *Context, _ string) string {
+ return n.Render(nil)
+}
+
// --- elNode ---
type elNode struct {
@@ -91,31 +103,94 @@ func El(tag string, children ...Node) 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, Entitled, and Each.
+// It recursively traverses through wrappers like If, Unless, Entitled, Each,
+// EachSeq, Switch, Layout, and Responsive when present.
func Attr(n Node, key, value string) Node {
- if n == nil {
- return n
+ if isNilNode(n) {
+ return nil
}
switch t := n.(type) {
case *elNode:
+ if t == nil {
+ return nil
+ }
t.attrs[key] = value
case *ifNode:
+ if t == nil {
+ return nil
+ }
Attr(t.node, key, value)
case *unlessNode:
+ if t == nil {
+ return nil
+ }
Attr(t.node, key, value)
case *entitledNode:
+ if t == nil {
+ return nil
+ }
Attr(t.node, key, value)
case *switchNode:
+ if t == nil {
+ return nil
+ }
for _, child := range t.cases {
Attr(child, key, value)
}
+ case *Layout:
+ if t == nil {
+ return nil
+ }
+ if t.slots != nil {
+ for slot, children := range t.slots {
+ for i := range children {
+ children[i] = Attr(children[i], key, value)
+ }
+ t.slots[slot] = children
+ }
+ }
+ case *Responsive:
+ for i := range t.variants {
+ Attr(t.variants[i].layout, key, value)
+ }
case attrApplier:
t.applyAttr(key, value)
}
return n
}
+func isNilNode(n Node) bool {
+ if n == nil {
+ return true
+ }
+
+ switch t := n.(type) {
+ case *rawNode:
+ return t == nil
+ case *elNode:
+ return t == nil
+ case *textNode:
+ return t == nil
+ case *ifNode:
+ return t == nil
+ case *unlessNode:
+ return t == nil
+ case *entitledNode:
+ return t == nil
+ case *switchNode:
+ return t == nil
+ case *Layout:
+ return t == nil
+ case *Responsive:
+ return t == nil
+ case interface{ isNilHTMLNode() bool }:
+ return t.isNilHTMLNode()
+ default:
+ return false
+ }
+}
+
// 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 {
@@ -147,23 +222,39 @@ func Role(n Node, role string) Node {
}
func (n *elNode) Render(ctx *Context) string {
+ return n.render(ctx, "")
+}
+
+func (n *elNode) renderWithLayoutPath(ctx *Context, path string) string {
+ return n.render(ctx, path)
+}
+
+func (n *elNode) render(ctx *Context, path string) string {
if n == nil {
return ""
}
b := newTextBuilder()
+ attrs := n.attrs
+ if path != "" {
+ attrs = make(map[string]string, len(n.attrs)+1)
+ for key, value := range n.attrs {
+ attrs[key] = value
+ }
+ attrs["data-block"] = path
+ }
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
// Sort attribute keys for deterministic output.
- keys := slices.Collect(maps.Keys(n.attrs))
+ keys := slices.Collect(maps.Keys(attrs))
slices.Sort(keys)
for _, key := range keys {
b.WriteByte(' ')
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
- b.WriteString(escapeAttr(n.attrs[key]))
+ b.WriteString(escapeAttr(attrs[key]))
b.WriteByte('"')
}
@@ -174,10 +265,15 @@ func (n *elNode) Render(ctx *Context) string {
}
for i := range len(n.children) {
- if n.children[i] == nil {
+ child := n.children[i]
+ if child == nil {
+ continue
+ }
+ if path == "" {
+ b.WriteString(child.Render(ctx))
continue
}
- b.WriteString(n.children[i].Render(ctx))
+ b.WriteString(renderWithLayoutPath(child, ctx, path+"."+strconv.Itoa(i)))
}
b.WriteString("")
@@ -215,6 +311,10 @@ func (n *textNode) Render(ctx *Context) string {
return escapeHTML(translateText(ctx, n.key, n.args...))
}
+func (n *textNode) renderWithLayoutPath(ctx *Context, _ string) string {
+ return n.Render(ctx)
+}
+
// --- ifNode ---
type ifNode struct {
@@ -238,6 +338,16 @@ func (n *ifNode) Render(ctx *Context) string {
return ""
}
+func (n *ifNode) renderWithLayoutPath(ctx *Context, path string) string {
+ if n == nil || n.cond == nil || n.node == nil {
+ return ""
+ }
+ if n.cond(ctx) {
+ return renderWithLayoutPath(n.node, ctx, path)
+ }
+ return ""
+}
+
// --- unlessNode ---
type unlessNode struct {
@@ -261,6 +371,16 @@ func (n *unlessNode) Render(ctx *Context) string {
return ""
}
+func (n *unlessNode) renderWithLayoutPath(ctx *Context, path string) string {
+ if n == nil || n.cond == nil || n.node == nil {
+ return ""
+ }
+ if !n.cond(ctx) {
+ return renderWithLayoutPath(n.node, ctx, path)
+ }
+ return ""
+}
+
// --- entitledNode ---
type entitledNode struct {
@@ -268,13 +388,6 @@ type entitledNode struct {
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.
-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 ""
@@ -285,6 +398,16 @@ func (n *entitledNode) Render(ctx *Context) string {
return n.node.Render(ctx)
}
+func (n *entitledNode) renderWithLayoutPath(ctx *Context, path string) string {
+ if n == nil || n.node == nil {
+ return ""
+ }
+ if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
+ return ""
+ }
+ return renderWithLayoutPath(n.node, ctx, path)
+}
+
// --- switchNode ---
type switchNode struct {
@@ -315,10 +438,26 @@ func (n *switchNode) Render(ctx *Context) string {
return ""
}
+func (n *switchNode) renderWithLayoutPath(ctx *Context, path string) string {
+ if n == nil || n.selector == nil {
+ return ""
+ }
+ key := n.selector(ctx)
+ if n.cases == nil {
+ return ""
+ }
+ node, ok := n.cases[key]
+ if !ok || node == nil {
+ return ""
+ }
+ return renderWithLayoutPath(node, ctx, path)
+}
+
// --- eachNode ---
type eachNode[T any] struct {
- items iter.Seq[T]
+ items []T
+ seq iter.Seq[T]
fn func(T) Node
}
@@ -326,16 +465,56 @@ type attrApplier interface {
applyAttr(key, value string)
}
+func (n *eachNode[T]) isNilHTMLNode() bool {
+ return n == nil
+}
+
+func nodePreservesLayoutPath(node Node, ctx *Context) bool {
+ switch n := node.(type) {
+ case *Layout, *Responsive:
+ return true
+ case *ifNode:
+ if n == nil || n.cond == nil || n.node == nil || !n.cond(ctx) {
+ return false
+ }
+ return nodePreservesLayoutPath(n.node, ctx)
+ case *unlessNode:
+ if n == nil || n.cond == nil || n.node == nil || n.cond(ctx) {
+ return false
+ }
+ return nodePreservesLayoutPath(n.node, ctx)
+ case *entitledNode:
+ if n == nil || n.node == nil {
+ return false
+ }
+ if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
+ return false
+ }
+ return nodePreservesLayoutPath(n.node, ctx)
+ case *switchNode:
+ if n == nil || n.selector == nil || n.cases == nil {
+ return false
+ }
+ child, ok := n.cases[n.selector(ctx)]
+ if !ok || child == nil {
+ return false
+ }
+ return nodePreservesLayoutPath(child, ctx)
+ default:
+ return false
+ }
+}
+
// 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)
+ return &eachNode[T]{items: items, fn: 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}
+ return &eachNode[T]{seq: items, fn: fn}
}
func (n *eachNode[T]) Render(ctx *Context) string {
@@ -354,17 +533,42 @@ func (n *eachNode[T]) applyAttr(key, value string) {
}
func (n *eachNode[T]) renderWithLayoutPath(ctx *Context, path string) string {
- if n == nil || n.fn == nil || n.items == nil {
+ if n == nil || n.fn == nil {
+ return ""
+ }
+
+ items := n.materialiseItems()
+ if len(items) == 0 {
return ""
}
b := newTextBuilder()
- for item := range n.items {
+ total := len(items)
+ for idx, item := range items {
child := n.fn(item)
if child == nil {
continue
}
- b.WriteString(renderWithLayoutPath(child, ctx, path))
+ childPath := path
+ if path != "" && (!nodePreservesLayoutPath(child, ctx) || total > 1) {
+ childPath = path + "." + strconv.Itoa(idx)
+ }
+ b.WriteString(renderWithLayoutPath(child, ctx, childPath))
}
return b.String()
}
+
+func (n *eachNode[T]) materialiseItems() []T {
+ if n == nil {
+ return nil
+ }
+ if n.seq == nil {
+ return n.items
+ }
+
+ items := make([]T, 0)
+ for item := range n.seq {
+ items = append(items, item)
+ }
+ return items
+}
diff --git a/node_test.go b/node_test.go
index a26cdcd..2d9e3f9 100644
--- a/node_test.go
+++ b/node_test.go
@@ -3,7 +3,7 @@ package html
import (
"testing"
- i18n "dappco.re/go/core/i18n"
+ i18n "dappco.re/go/i18n"
"slices"
)
@@ -36,6 +36,31 @@ func TestElNode_Nested_Good(t *testing.T) {
}
}
+func TestLayout_DirectElementBlockPath_Good(t *testing.T) {
+ ctx := NewContext()
+ got := NewLayout("C").C(El("div", Raw("content"))).Render(ctx)
+
+ if !containsText(got, `data-block="C.0"`) {
+ t.Fatalf("direct element inside layout should receive a block path, got:\n%s", got)
+ }
+}
+
+func TestLayout_EachElementBlockPaths_Good(t *testing.T) {
+ ctx := NewContext()
+ got := NewLayout("C").C(
+ Each([]string{"a", "b"}, func(item string) Node {
+ return El("span", Raw(item))
+ }),
+ ).Render(ctx)
+
+ if !containsText(got, `data-block="C.0.0"`) {
+ t.Fatalf("first Each item should receive a block path, got:\n%s", got)
+ }
+ if !containsText(got, `data-block="C.0.1"`) {
+ t.Fatalf("second Each item should receive a block path, got:\n%s", got)
+ }
+}
+
func TestElNode_MultipleChildren_Good(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("a"), Raw("b"))
@@ -65,6 +90,45 @@ func TestTextNode_Render_Good(t *testing.T) {
}
}
+func TestTextNode_UsesContextDataForCount_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ i18n.SetDefault(svc)
+
+ tests := []struct {
+ name string
+ key string
+ data map[string]any
+ want string
+ }{
+ {
+ name: "capitalised count",
+ key: "i18n.count.file",
+ data: map[string]any{"Count": 5},
+ want: "5 files",
+ },
+ {
+ name: "lowercase count",
+ key: "i18n.count.file",
+ data: map[string]any{"count": 1},
+ want: "1 file",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := NewContext()
+ for k, v := range tt.data {
+ ctx.Metadata[k] = v
+ }
+
+ got := Text(tt.key).Render(ctx)
+ if got != tt.want {
+ t.Fatalf("Text(%q).Render() = %q, want %q", tt.key, got, tt.want)
+ }
+ })
+ }
+}
+
func TestTextNode_Escapes_Good(t *testing.T) {
ctx := NewContext()
node := Text("")
@@ -165,12 +229,30 @@ func TestEachNode_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
})
got := NewLayout("C").C(node).Render(ctx)
- want := `item`
+ want := `item`
if got != want {
t.Fatalf("Each nested layout render = %q, want %q", got, want)
}
}
+func TestEachNode_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
+ ctx := NewContext()
+ first := NewLayout("C").C(Raw("one"))
+ second := NewLayout("C").C(Raw("two"))
+
+ node := Each([]Node{first, second}, func(item Node) Node {
+ return item
+ })
+
+ got := NewLayout("C").C(node).Render(ctx)
+ if !containsText(got, `data-block="C.0.0"`) {
+ t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
+ }
+ if !containsText(got, `data-block="C.0.1"`) {
+ t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
+ }
+}
+
func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("item"))
@@ -179,12 +261,30 @@ func TestEachSeq_NestedLayout_PreservesBlockPath_Good(t *testing.T) {
})
got := NewLayout("C").C(node).Render(ctx)
- want := `item`
+ want := `item`
if got != want {
t.Fatalf("EachSeq nested layout render = %q, want %q", got, want)
}
}
+func TestEachSeq_MultipleLayouts_GetDistinctPaths_Good(t *testing.T) {
+ ctx := NewContext()
+ first := NewLayout("C").C(Raw("one"))
+ second := NewLayout("C").C(Raw("two"))
+
+ node := EachSeq(slices.Values([]Node{first, second}), func(item Node) Node {
+ return item
+ })
+
+ got := NewLayout("C").C(node).Render(ctx)
+ if !containsText(got, `data-block="C.0.0"`) {
+ t.Fatalf("first layout item should receive a distinct block path, got:\n%s", got)
+ }
+ if !containsText(got, `data-block="C.0.1"`) {
+ t.Fatalf("second layout item should receive a distinct block path, got:\n%s", got)
+ }
+}
+
func TestElNode_Attr_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("content")), "class", "container")
@@ -195,6 +295,96 @@ func TestElNode_Attr_Good(t *testing.T) {
}
}
+func TestElNode_AttrRecursiveThroughEachSeq_Good(t *testing.T) {
+ ctx := NewContext()
+ node := Attr(
+ EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
+ return El("span", Raw(item))
+ }),
+ "data-kind",
+ "item",
+ )
+
+ got := NewLayout("C").C(node).Render(ctx)
+ if count := countText(got, `data-kind="item"`); count != 2 {
+ t.Fatalf("Attr through EachSeq should apply to every item, got %d in:\n%s", count, got)
+ }
+}
+
+func TestElNode_AttrRecursiveThroughSwitch_Good(t *testing.T) {
+ ctx := NewContext()
+ node := Attr(
+ Switch(
+ func(*Context) string { return "match" },
+ map[string]Node{
+ "match": El("span", Raw("visible")),
+ "miss": El("span", Raw("hidden")),
+ },
+ ),
+ "data-state",
+ "selected",
+ )
+
+ got := node.Render(ctx)
+ if !containsText(got, `data-state="selected"`) {
+ t.Fatalf("Attr through Switch should reach the selected case, got:\n%s", got)
+ }
+}
+
+func TestAccessibilityHelpers_Good(t *testing.T) {
+ ctx := NewContext()
+
+ button := Role(
+ AriaLabel(
+ TabIndex(
+ AutoFocus(El("button", Raw("save"))),
+ 3,
+ ),
+ "Save changes",
+ ),
+ "button",
+ )
+
+ got := button.Render(ctx)
+ for _, want := range []string{
+ `aria-label="Save changes"`,
+ `autofocus="autofocus"`,
+ `role="button"`,
+ `tabindex="3"`,
+ ">save",
+ } {
+ if !containsText(got, want) {
+ t.Fatalf("accessibility helpers missing %q in:\n%s", want, got)
+ }
+ }
+
+ img := AltText(El("img"), "Profile photo")
+ if got := img.Render(ctx); got != `
` {
+ t.Fatalf("AltText() = %q, want %q", got, `
`)
+ }
+}
+
+func TestSwitchNode_Good(t *testing.T) {
+ ctx := NewContext()
+ ctx.Locale = "en-GB"
+
+ node := Switch(
+ func(ctx *Context) string { return ctx.Locale },
+ map[string]Node{
+ "en-GB": Raw("hello"),
+ "fr-FR": Raw("bonjour"),
+ },
+ )
+
+ if got := node.Render(ctx); got != "hello" {
+ t.Fatalf("Switch matched case = %q, want %q", got, "hello")
+ }
+
+ if got := Switch(func(*Context) string { return "de-DE" }, map[string]Node{"en-GB": Raw("hello")}).Render(ctx); got != "" {
+ t.Fatalf("Switch missing case = %q, want empty", got)
+ }
+}
+
func TestElNode_AttrEscaping_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
@@ -266,6 +456,29 @@ func TestAttr_NonElement_Ugly(t *testing.T) {
}
}
+func TestAttr_TypedNilWrappers_Ugly(t *testing.T) {
+ tests := []struct {
+ name string
+ node Node
+ }{
+ {name: "layout", node: (*Layout)(nil)},
+ {name: "responsive", node: (*Responsive)(nil)},
+ {name: "if", node: (*ifNode)(nil)},
+ {name: "unless", node: (*unlessNode)(nil)},
+ {name: "entitled", node: (*entitledNode)(nil)},
+ {name: "switch", node: (*switchNode)(nil)},
+ {name: "each", node: (*eachNode[string])(nil)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := Attr(tt.node, "data-test", "x"); got != nil {
+ t.Fatalf("Attr on typed nil %s should return nil, got %#v", tt.name, got)
+ }
+ })
+ }
+}
+
func TestUnlessNode_True_Good(t *testing.T) {
ctx := NewContext()
node := Unless(func(*Context) bool { return true }, Raw("hidden"))
@@ -327,6 +540,30 @@ func TestAttr_ThroughSwitchNode_Good(t *testing.T) {
}
}
+func TestAttr_ThroughLayout_Good(t *testing.T) {
+ ctx := NewContext()
+ layout := NewLayout("C").C(El("div", Raw("content")))
+ Attr(layout, "class", "page")
+
+ got := layout.Render(ctx)
+ want := `content
`
+ if got != want {
+ t.Errorf("Attr through Layout = %q, want %q", got, want)
+ }
+}
+
+func TestAttr_ThroughResponsive_Good(t *testing.T) {
+ ctx := NewContext()
+ resp := NewResponsive().Variant("mobile", NewLayout("C").C(El("div", Raw("content"))))
+ Attr(resp, "data-kind", "page")
+
+ got := resp.Render(ctx)
+ want := ``
+ if got != want {
+ t.Errorf("Attr through Responsive = %q, want %q", got, want)
+ }
+}
+
func TestAttr_ThroughEachNode_Good(t *testing.T) {
ctx := NewContext()
node := Each([]string{"a", "b"}, func(item string) Node {
diff --git a/path.go b/path.go
index 8084c6c..7502516 100644
--- a/path.go
+++ b/path.go
@@ -1,28 +1,134 @@
package html
-import "strings"
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
+// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
+// stdlib strings is safe for WASM.
// ParseBlockID extracts the slot sequence from a data-block ID.
-// Usage example: slots := ParseBlockID("L-0-C-0")
-// "L-0-C-0" → ['L', 'C']
+// Usage example: slots := ParseBlockID("C.0.1")
+// It accepts the current dotted coordinate form and the older hyphenated
+// form for compatibility. Mixed separators and malformed coordinates are
+// rejected.
func ParseBlockID(id string) []byte {
if id == "" {
return nil
}
- // 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 {
+ tokens := make([]string, 0, 4)
+ sepKind := byte(0)
+
+ for i := 0; i < len(id); {
+ start := i
+ for i < len(id) && id[i] != '.' && id[i] != '-' {
+ i++
+ }
+
+ token := id[start:i]
+ if token == "" {
+ return nil
+ }
+ tokens = append(tokens, token)
+
+ if i == len(id) {
+ break
+ }
+
+ sep := id[i]
+ if sepKind == 0 {
+ sepKind = sep
+ } else if sepKind != sep {
+ return nil
+ }
+ i++
+ if i == len(id) {
+ return nil
+ }
+ }
+
+ switch sepKind {
+ case 0, '.':
+ return parseDottedBlockID(tokens)
+ case '-':
+ return parseHyphenatedBlockID(tokens)
+ default:
+ return nil
+ }
+}
+
+func parseDottedBlockID(tokens []string) []byte {
+ if len(tokens) == 0 || !isSlotToken(tokens[0]) {
+ return nil
+ }
+ if len(tokens) > 1 && isSlotToken(tokens[len(tokens)-1]) {
+ return nil
+ }
+
+ slots := make([]byte, 0, len(tokens))
+ slots = append(slots, tokens[0][0])
+
+ prevWasSlot := true
+ for i := 1; i < len(tokens); i++ {
+ token := tokens[i]
+ if isSlotToken(token) {
+ if prevWasSlot {
+ return nil
+ }
+ slots = append(slots, token[0])
+ prevWasSlot = true
+ continue
+ }
+
+ if !allDigits(token) {
+ return nil
+ }
+ prevWasSlot = false
+ }
+
+ return slots
+}
+
+func parseHyphenatedBlockID(tokens []string) []byte {
+ if len(tokens) < 2 || len(tokens)%2 != 0 {
+ return nil
+ }
+ if !isSlotToken(tokens[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" {
+ slots := make([]byte, 0, len(tokens)/2)
+ for i, token := range tokens {
+ switch {
+ case i%2 == 0:
+ if !isSlotToken(token) {
+ return nil
+ }
+ slots = append(slots, token[0])
+ case token != "0":
return nil
}
- slots = append(slots, parts[i][0])
}
+
return slots
}
+
+func isSlotToken(token string) bool {
+ if len(token) != 1 {
+ return false
+ }
+ _, ok := slotRegistry[token[0]]
+ return ok
+}
+
+func allDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ ch := s[i]
+ if ch < '0' || ch > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/path_test.go b/path_test.go
index 7301466..fee5160 100644
--- a/path_test.go
+++ b/path_test.go
@@ -11,14 +11,14 @@ func TestNestedLayout_PathChain_Good(t *testing.T) {
got := outer.Render(NewContext())
// Inner layout paths must be prefixed with parent block ID
- for _, want := range []string{`data-block="L-0-H-0"`, `data-block="L-0-C-0"`, `data-block="L-0-F-0"`} {
+ for _, want := range []string{`data-block="L.0"`, `data-block="L.0.1"`, `data-block="L.0.2"`} {
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"`} {
+ for _, want := range []string{`data-block="H"`, `data-block="C"`, `data-block="F"`} {
if !containsText(got, want) {
t.Errorf("outer layout missing %q in:\n%s", want, got)
}
@@ -31,30 +31,46 @@ func TestNestedLayout_DeepNesting_Ugly(t *testing.T) {
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"`} {
+ for _, want := range []string{`data-block="C"`, `data-block="C.0"`, `data-block="C.0.0"`} {
if !containsText(got, want) {
t.Errorf("deep nesting missing %q in:\n%s", want, got)
}
}
}
+func TestNestedLayout_StablePathsAcrossEmptySlots_Good(t *testing.T) {
+ inner := NewLayout("HCF").
+ C(Raw("body")).
+ F(Raw("links"))
+ outer := NewLayout("C").C(inner)
+
+ got := outer.Render(NewContext())
+ want := `body`
+ if got != want {
+ t.Fatalf("nested layout with empty leading slots = %q, want %q", got, want)
+ }
+}
+
func TestBlockID_BuildsPath_Good(t *testing.T) {
tests := []struct {
- path string
- slot byte
- want string
+ path string
+ slot byte
+ rendered int
+ want string
}{
- {"", 'H', "H-0"},
- {"L-0-", 'C', "L-0-C-0"},
- {"C-0-C-0-", 'C', "C-0-C-0-C-0"},
- {"", 'F', "F-0"},
+ {"", 'H', 0, "H"},
+ {"", 'H', 1, "H.1"},
+ {"", 'F', 0, "F"},
+ {"L.0", 'C', 0, "L.0"},
+ {"L.0", 'C', 1, "L.0.1"},
+ {"C.0.1", 'C', 0, "C.0.1"},
}
for _, tt := range tests {
l := &Layout{path: tt.path}
- got := l.blockID(tt.slot)
+ got := l.blockID(tt.slot, tt.rendered)
if got != tt.want {
- t.Errorf("blockID(%q, %c) = %q, want %q", tt.path, tt.slot, got, tt.want)
+ t.Errorf("blockID(%q, %c, %d) = %q, want %q", tt.path, tt.slot, tt.rendered, got, tt.want)
}
}
}
@@ -65,7 +81,13 @@ func TestParseBlockID_ExtractsSlots_Good(t *testing.T) {
want []byte
}{
{"L-0-C-0", []byte{'L', 'C'}},
- {"H-0", []byte{'H'}},
+ {"L.0.C.0", []byte{'L', 'C'}},
+ {"L.0", []byte{'L'}},
+ {"L.0.1", []byte{'L'}},
+ {"C.0", []byte{'C'}},
+ {"C.2.1", []byte{'C'}},
+ {"C.0.1.2", []byte{'C'}},
+ {"H", []byte{'H'}},
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
{"", nil},
}
@@ -88,7 +110,10 @@ func TestParseBlockID_InvalidInput_Good(t *testing.T) {
tests := []string{
"L-1-C-0",
"L-0-C",
- "L-0-",
+ "L.0.",
+ "L.0-C.0",
+ "C.C.0",
+ "C-0-0",
"X",
}
diff --git a/pipeline.go b/pipeline.go
index 0e50703..88307ba 100644
--- a/pipeline.go
+++ b/pipeline.go
@@ -5,7 +5,8 @@ package html
import (
core "dappco.re/go/core"
- "dappco.re/go/core/i18n/reversal"
+ "dappco.re/go/i18n/reversal"
+ "unicode/utf8"
)
// StripTags removes HTML tags from rendered output, returning plain text.
@@ -14,34 +15,85 @@ import (
// Does not handle script/style element content (go-html does not generate these).
func StripTags(html string) string {
b := core.NewBuilder()
- inTag := false
prevSpace := true // starts true to trim leading space
- for _, r := range html {
+
+ for i := 0; i < len(html); {
+ r, size := utf8.DecodeRuneInString(html[i:])
+
if r == '<' {
- inTag = true
- continue
+ next, nextSize := nextRune(html, i+size)
+ if nextSize > 0 && isTagStartRune(next) {
+ if end, ok := findTagCloser(html, i+size+nextSize); ok {
+ if !prevSpace {
+ b.WriteByte(' ')
+ prevSpace = true
+ }
+ i = end + 1
+ continue
+ }
+ }
}
- if r == '>' {
- inTag = false
+
+ switch r {
+ case ' ', '\t', '\n', '\r':
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
- continue
+ default:
+ _, _ = b.WriteString(html[i : i+size])
+ prevSpace = false
}
- if !inTag {
- if r == ' ' || r == '\t' || r == '\n' {
- if !prevSpace {
- b.WriteByte(' ')
- prevSpace = true
- }
- } else {
- b.WriteRune(r)
- prevSpace = false
+
+ i += size
+ }
+
+ return core.Trim(b.String())
+}
+
+func nextRune(s string, i int) (rune, int) {
+ if i >= len(s) {
+ return 0, 0
+ }
+ return utf8.DecodeRuneInString(s[i:])
+}
+
+func isTagStartRune(r rune) bool {
+ switch {
+ case r >= 'a' && r <= 'z':
+ return true
+ case r >= 'A' && r <= 'Z':
+ return true
+ case r == '/', r == '!', r == '?':
+ return true
+ default:
+ return false
+ }
+}
+
+func findTagCloser(s string, start int) (int, bool) {
+ inSingleQuote := false
+ inDoubleQuote := false
+
+ for i := start; i < len(s); {
+ r, size := utf8.DecodeRuneInString(s[i:])
+ switch r {
+ case '\'':
+ if !inDoubleQuote {
+ inSingleQuote = !inSingleQuote
+ }
+ case '"':
+ if !inSingleQuote {
+ inDoubleQuote = !inDoubleQuote
+ }
+ case '>':
+ if !inSingleQuote && !inDoubleQuote {
+ return i, true
}
}
+ i += size
}
- return core.Trim(b.String())
+ return 0, false
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
@@ -82,14 +134,19 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if v.layout == nil {
continue
}
- imp := Imprint(v.layout, ctx)
+ imp := Imprint(v.layout, cloneContext(ctx))
imprints = append(imprints, named{name: v.name, imp: imp})
}
scores := make(map[string]float64)
for i := range len(imprints) {
for j := i + 1; j < len(imprints); j++ {
- key := imprints[i].name + ":" + imprints[j].name
+ left := imprints[i].name
+ right := imprints[j].name
+ if right < left {
+ left, right = right, left
+ }
+ key := left + ":" + right
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
}
}
diff --git a/pipeline_test.go b/pipeline_test.go
index 9e556d2..e07974e 100644
--- a/pipeline_test.go
+++ b/pipeline_test.go
@@ -5,7 +5,7 @@ package html
import (
"testing"
- i18n "dappco.re/go/core/i18n"
+ i18n "dappco.re/go/i18n"
)
func TestStripTags_Simple_Good(t *testing.T) {
@@ -46,6 +46,22 @@ func TestStripTags_NoTags_Good(t *testing.T) {
}
}
+func TestStripTags_PreservesComparisonOperators_Good(t *testing.T) {
+ got := StripTags(`1 < 2 and 3 > 2
`)
+ want := "1 < 2 and 3 > 2"
+ if got != want {
+ t.Errorf("StripTags(comparisons) = %q, want %q", got, want)
+ }
+}
+
+func TestStripTags_LiteralAngleBracket_Good(t *testing.T) {
+ got := StripTags(`aanswer`,
+ want: "answer",
+ },
+ {
+ name: "single quotes",
+ input: `
answer
`,
+ want: "answer",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := StripTags(tt.input)
+ if got != tt.want {
+ t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
func TestImprint_FromNode_Good(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
@@ -128,3 +172,19 @@ func TestCompareVariants_SameContent_Good(t *testing.T) {
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
}
}
+
+func TestCompareVariants_KeyOrderDeterministic_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ i18n.SetDefault(svc)
+ ctx := NewContext()
+
+ r := NewResponsive().
+ Variant("beta", NewLayout("C").C(El("p", Text("Building project")))).
+ Variant("alpha", NewLayout("C").C(El("p", Text("Building project"))))
+
+ scores := CompareVariants(r, ctx)
+
+ if _, ok := scores["alpha:beta"]; !ok {
+ t.Fatalf("CompareVariants should use deterministic key ordering, got keys: %v", scores)
+ }
+}
diff --git a/pkg/api/handlers.go b/pkg/api/handlers.go
new file mode 100644
index 0000000..1fe78a9
--- /dev/null
+++ b/pkg/api/handlers.go
@@ -0,0 +1,107 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net/http"
+
+ html "dappco.re/go/html"
+ "dappco.re/go/i18n/reversal"
+ "github.com/gin-gonic/gin"
+)
+
+const todoRenderDataBinding = "TODO(#731): implement go-html template/data render primitives for non-Go consumers"
+
+type renderRequest struct {
+ Template string `json:"template"`
+ Data map[string]any `json:"data,omitempty"`
+ Locale string `json:"locale,omitempty"`
+}
+
+type renderResponse struct {
+ HTML string `json:"html"`
+}
+
+type grammarCheckRequest struct {
+ HTML string `json:"html"`
+ Locale string `json:"locale,omitempty"`
+ Reference *reversal.GrammarImprint `json:"reference,omitempty"`
+ MinSimilarity float64 `json:"min_similarity,omitempty"`
+}
+
+type grammarCheckResponse struct {
+ Valid bool `json:"valid"`
+ Imprint reversal.GrammarImprint `json:"imprint"`
+ Similarity *float64 `json:"similarity,omitempty"`
+ TokenCount int `json:"token_count"`
+}
+
+func (p *HTMLProvider) render(c *gin.Context) {
+ if c == nil {
+ return
+ }
+
+ var req renderRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON request body"})
+ return
+ }
+ if req.Template == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "template is required"})
+ return
+ }
+
+ if len(req.Data) > 0 {
+ c.JSON(http.StatusNotImplemented, gin.H{
+ "error": "template data binding is not implemented",
+ "todo": todoRenderDataBinding,
+ })
+ return
+ }
+
+ ctx := html.NewContext()
+ if req.Locale != "" {
+ ctx.SetLocale(req.Locale)
+ }
+ c.JSON(http.StatusOK, renderResponse{HTML: html.Render(html.Raw(req.Template), ctx)})
+}
+
+func (p *HTMLProvider) checkGrammar(c *gin.Context) {
+ if c == nil {
+ return
+ }
+
+ var req grammarCheckRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON request body"})
+ return
+ }
+ if req.HTML == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "html is required"})
+ return
+ }
+
+ ctx := html.NewContext()
+ if req.Locale != "" {
+ ctx.SetLocale(req.Locale)
+ }
+
+ imprint := html.Imprint(html.Raw(req.HTML), ctx)
+ resp := grammarCheckResponse{
+ Valid: true,
+ Imprint: imprint,
+ TokenCount: imprint.TokenCount,
+ }
+
+ if req.Reference != nil {
+ threshold := req.MinSimilarity
+ if threshold == 0 {
+ threshold = 0.8
+ }
+ similarity := imprint.Similar(*req.Reference)
+ resp.Similarity = &similarity
+ resp.Valid = similarity >= threshold
+ }
+
+ c.JSON(http.StatusOK, resp)
+}
diff --git a/pkg/api/handlers_test.go b/pkg/api/handlers_test.go
new file mode 100644
index 0000000..d99d312
--- /dev/null
+++ b/pkg/api/handlers_test.go
@@ -0,0 +1,101 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+func TestRenderRoute_Good(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/render", `{"template":"
Hello"}`)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST /render status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp renderResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if resp.HTML != "
Hello" {
+ t.Fatalf("html = %q, want %q", resp.HTML, "
Hello")
+ }
+}
+
+func TestRenderRoute_Bad(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/render", `{"template":`)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /render invalid JSON status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+}
+
+func TestRenderRoute_DataBindingNotImplemented_Bad(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/render", `{"template":"
{{title}}","data":{"title":"Hello"}}`)
+
+ if rec.Code != http.StatusNotImplemented {
+ t.Fatalf("POST /render data binding status = %d, want %d", rec.Code, http.StatusNotImplemented)
+ }
+ if !strings.Contains(rec.Body.String(), "#731") {
+ t.Fatalf("POST /render 501 body should point at #731, got %s", rec.Body.String())
+ }
+}
+
+func TestGrammarCheckRoute_Good(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/grammar/check", `{"html":"
The user creates reports."}`)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST /grammar/check status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp grammarCheckResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if !resp.Valid {
+ t.Fatal("valid = false, want true")
+ }
+ if resp.TokenCount == 0 {
+ t.Fatal("token_count = 0, want > 0")
+ }
+}
+
+func TestGrammarCheckRoute_Bad(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/grammar/check", `{"html":""}`)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /grammar/check empty html status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+}
+
+func testRouter() *gin.Engine {
+ provider := NewProvider()
+ router := gin.New()
+ provider.RegisterRoutes(router.Group(provider.BasePath()))
+ return router
+}
+
+func postJSON(t *testing.T, router http.Handler, path string, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
diff --git a/pkg/api/provider.go b/pkg/api/provider.go
new file mode 100644
index 0000000..fc17908
--- /dev/null
+++ b/pkg/api/provider.go
@@ -0,0 +1,102 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+// Package api exposes go-html through the Core service provider shape.
+package api
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// HTMLProvider exposes the go-html render and grammar endpoints.
+//
+// Registration in core/api is intentionally left to the owning core/api repo.
+type HTMLProvider struct{}
+
+// RouteDescription mirrors the Core API route metadata used by provider
+// consumers without pulling the full provider runtime into go-html.
+type RouteDescription struct {
+ Method string
+ Path string
+ Summary string
+ Description string
+ Tags []string
+ StatusCode int
+ RequestBody map[string]any
+ Response map[string]any
+}
+
+// NewProvider creates the go-html provider.
+func NewProvider() *HTMLProvider {
+ return &HTMLProvider{}
+}
+
+// Name returns the provider identity.
+func (p *HTMLProvider) Name() string { return "html" }
+
+// BasePath returns the provider route prefix.
+func (p *HTMLProvider) BasePath() string { return "/v1/html" }
+
+// RegisterRoutes mounts the go-html HTTP surface.
+func (p *HTMLProvider) RegisterRoutes(rg *gin.RouterGroup) {
+ if rg == nil {
+ return
+ }
+ rg.POST("/render", p.render)
+ rg.POST("/grammar/check", p.checkGrammar)
+}
+
+// Describe returns route metadata for API discovery.
+func (p *HTMLProvider) Describe() []RouteDescription {
+ return []RouteDescription{
+ {
+ Method: http.MethodPost,
+ Path: "/render",
+ Summary: "Render an HTML template",
+ Description: "Renders raw HTML templates today; data-bound template rendering is tracked by #731.",
+ Tags: []string{"html"},
+ StatusCode: http.StatusOK,
+ RequestBody: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "template": map[string]any{"type": "string"},
+ "data": map[string]any{"type": "object"},
+ "locale": map[string]any{"type": "string"},
+ },
+ "required": []string{"template"},
+ },
+ Response: map[string]any{
+ "type": "object",
+ "properties": map[string]any{"html": map[string]any{"type": "string"}},
+ },
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/grammar/check",
+ Summary: "Check rendered HTML grammar",
+ Description: "Builds a GrammarImprint from rendered HTML and optionally compares it to a supplied imprint.",
+ Tags: []string{"html"},
+ StatusCode: http.StatusOK,
+ RequestBody: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "html": map[string]any{"type": "string"},
+ "locale": map[string]any{"type": "string"},
+ "reference": map[string]any{"type": "object"},
+ "min_similarity": map[string]any{"type": "number"},
+ },
+ "required": []string{"html"},
+ },
+ Response: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "valid": map[string]any{"type": "boolean"},
+ "imprint": map[string]any{"type": "object"},
+ "similarity": map[string]any{"type": "number"},
+ "token_count": map[string]any{"type": "integer"},
+ },
+ },
+ },
+ }
+}
diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go
new file mode 100644
index 0000000..6117885
--- /dev/null
+++ b/pkg/api/provider_test.go
@@ -0,0 +1,61 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestNewProvider_Good(t *testing.T) {
+ provider := NewProvider()
+ if provider == nil {
+ t.Fatal("NewProvider() returned nil")
+ }
+ if provider.Name() != "html" {
+ t.Fatalf("Name() = %q, want %q", provider.Name(), "html")
+ }
+ if provider.BasePath() != "/v1/html" {
+ t.Fatalf("BasePath() = %q, want %q", provider.BasePath(), "/v1/html")
+ }
+}
+
+func TestNewProvider_Bad(t *testing.T) {
+ provider := &HTMLProvider{}
+ if provider.Name() != "html" {
+ t.Fatalf("zero-value Name() = %q, want %q", provider.Name(), "html")
+ }
+ if provider.BasePath() != "/v1/html" {
+ t.Fatalf("zero-value BasePath() = %q, want %q", provider.BasePath(), "/v1/html")
+ }
+}
+
+func TestNewProvider_Ugly(t *testing.T) {
+ var provider *HTMLProvider
+ if provider.Name() != "html" {
+ t.Fatalf("nil receiver Name() = %q, want %q", provider.Name(), "html")
+ }
+ if provider.BasePath() != "/v1/html" {
+ t.Fatalf("nil receiver BasePath() = %q, want %q", provider.BasePath(), "/v1/html")
+ }
+ provider.RegisterRoutes(nil)
+}
+
+func TestProviderDescribe_Good(t *testing.T) {
+ routes := NewProvider().Describe()
+ want := map[string]bool{
+ http.MethodPost + " /render": false,
+ http.MethodPost + " /grammar/check": false,
+ }
+ for _, route := range routes {
+ key := route.Method + " " + route.Path
+ if _, ok := want[key]; ok {
+ want[key] = true
+ }
+ }
+ for route, seen := range want {
+ if !seen {
+ t.Fatalf("Describe() missing route %s", route)
+ }
+ }
+}
diff --git a/render.go b/render.go
index ad14109..4af8869 100644
--- a/render.go
+++ b/render.go
@@ -1,5 +1,9 @@
package html
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so this helper stays dependency-free and
+// delegates all rendering work to the shared Node contract.
+
// 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 {
diff --git a/render_test.go b/render_test.go
index 2b71858..68c0e33 100644
--- a/render_test.go
+++ b/render_test.go
@@ -3,7 +3,7 @@ package html
import (
"testing"
- i18n "dappco.re/go/core/i18n"
+ i18n "dappco.re/go/i18n"
)
func TestRender_FullPage_Good(t *testing.T) {
diff --git a/responsive.go b/responsive.go
index 896d0ae..2a42330 100644
--- a/responsive.go
+++ b/responsive.go
@@ -1,12 +1,15 @@
package html
-import (
- "strconv"
- "strings"
-)
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so we deliberately avoid importing
+// dappco.re/go/core here — it transitively pulls in fmt/os/log (~500 KB+).
+// The stdlib strconv primitive is safe for WASM.
+
+import "strconv"
// Compile-time interface check.
var _ Node = (*Responsive)(nil)
+var _ layoutPathRenderer = (*Responsive)(nil)
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Usage example: r := NewResponsive().Variant("mobile", NewLayout("C"))
@@ -18,6 +21,7 @@ type Responsive struct {
type responsiveVariant struct {
name string
layout *Layout
+ media string // optional CSS media-query hint (e.g. "(min-width: 768px)")
}
// NewResponsive creates a new multi-variant responsive compositor.
@@ -29,17 +33,35 @@ func NewResponsive() *Responsive {
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Usage example: NewResponsive().Variant("desktop", NewLayout("HLCRF"))
// Variants render in insertion order.
+// Variant is equivalent to Add(name, layout) with no media-query hint.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
+ return r.Add(name, layout)
+}
+
+// Add registers a responsive variant. The optional media argument carries a
+// CSS media-query hint for downstream CSS generation (e.g. "(min-width: 768px)").
+// When supplied, Render emits it on the container as data-media.
+//
+// Usage example: NewResponsive().Add("desktop", NewLayout("HLCRF"), "(min-width: 1024px)")
+func (r *Responsive) Add(name string, layout *Layout, media ...string) *Responsive {
if r == nil {
r = NewResponsive()
}
- r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
+ variant := responsiveVariant{name: name, layout: layout}
+ if len(media) > 0 {
+ variant.media = media[0]
+ }
+ r.variants = append(r.variants, variant)
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 {
+ return r.renderWithLayoutPath(ctx, "")
+}
+
+func (r *Responsive) renderWithLayoutPath(ctx *Context, path string) string {
if r == nil {
return ""
}
@@ -55,8 +77,12 @@ func (r *Responsive) Render(ctx *Context) string {
b.WriteString(`
`)
- b.WriteString(v.layout.Render(ctx))
+ b.WriteString(renderWithLayoutPath(v.layout, ctx, path))
b.WriteString(`
`)
}
return b.String()
@@ -73,7 +99,7 @@ func escapeCSSString(s string) string {
return ""
}
- var b strings.Builder
+ b := newTextBuilder()
for _, r := range s {
switch r {
case '\\', '"':
@@ -82,9 +108,13 @@ func escapeCSSString(s string) string {
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
- esc := strings.ToUpper(strconv.FormatInt(int64(r), 16))
+ esc := strconv.FormatInt(int64(r), 16)
for i := 0; i < len(esc); i++ {
- b.WriteByte(esc[i])
+ c := esc[i]
+ if c >= 'a' && c <= 'f' {
+ c -= 'a' - 'A'
+ }
+ b.WriteByte(c)
}
b.WriteByte(' ')
continue
diff --git a/responsive_test.go b/responsive_test.go
index a930892..63f2188 100644
--- a/responsive_test.go
+++ b/responsive_test.go
@@ -14,11 +14,26 @@ func TestResponsive_SingleVariant_Good(t *testing.T) {
if !containsText(got, `data-variant="desktop"`) {
t.Errorf("responsive should contain data-variant, got:\n%s", got)
}
- if !containsText(got, `data-block="H-0"`) {
+ if !containsText(got, `data-block="H"`) {
t.Errorf("responsive should contain layout content, got:\n%s", got)
}
}
+func TestResponsive_Add_MediaHint_Good(t *testing.T) {
+ ctx := NewContext()
+ r := NewResponsive().
+ Add("desktop", NewLayout("C").C(Raw("content")), "(min-width: 1024px)")
+
+ got := r.Render(ctx)
+
+ if !containsText(got, `data-variant="desktop"`) {
+ t.Fatalf("responsive should still contain data-variant, got:\n%s", got)
+ }
+ if !containsText(got, `data-media="(min-width: 1024px)"`) {
+ t.Fatalf("responsive should expose media hint, got:\n%s", got)
+ }
+}
+
func TestResponsive_MultiVariant_Good(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
@@ -61,11 +76,29 @@ func TestResponsive_NestedPaths_Good(t *testing.T) {
got := r.Render(ctx)
- 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 !containsText(got, `data-block="C.0"`) {
+ t.Errorf("nested layout in responsive variant missing C.0 in:\n%s", got)
+ }
+ if !containsText(got, `data-block="C.0.1"`) {
+ t.Errorf("nested layout in responsive variant missing C.0.1 in:\n%s", got)
+ }
+ if !containsText(got, `data-block="C.0.2"`) {
+ t.Errorf("nested layout in responsive variant missing C.0.2 in:\n%s", got)
+ }
+}
+
+func TestResponsive_NestedInsideLayout_PreservesBlockPath_Good(t *testing.T) {
+ ctx := NewContext()
+ r := NewResponsive().
+ Variant("mobile", NewLayout("C").C(Raw("content")))
+
+ got := NewLayout("C").C(r).Render(ctx)
+
+ if !containsText(got, `data-variant="mobile"`) {
+ t.Fatalf("responsive wrapper missing variant container in:\n%s", got)
}
- 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)
+ if !containsText(got, `data-block="C.0"`) {
+ t.Fatalf("responsive wrapper should preserve outer layout path, got:\n%s", got)
}
}
@@ -77,9 +110,9 @@ func TestResponsive_VariantsIndependent_Good(t *testing.T) {
got := r.Render(ctx)
- count := countText(got, `data-block="C-0"`)
+ count := countText(got, `data-block="C"`)
if count != 2 {
- t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
+ t.Errorf("expected 2 independent C blocks, got %d in:\n%s", count, got)
}
}
@@ -95,7 +128,7 @@ func TestResponsive_Variant_NilResponsive_Ugly(t *testing.T) {
t.Fatal("expected non-nil responsive from Variant on nil receiver")
}
- if output := got.Render(NewContext()); output != `
content
` {
+ if output := got.Render(NewContext()); output != `
content
` {
t.Fatalf("unexpected output from nil receiver Variant path: %q", output)
}
}
@@ -105,7 +138,7 @@ func TestResponsive_Render_NilContext_Good(t *testing.T) {
Variant("mobile", NewLayout("C").C(Raw("content")))
got := r.Render(nil)
- want := `
content
`
+ want := `
content
`
if got != want {
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
}
diff --git a/shadow.go b/shadow.go
new file mode 100644
index 0000000..032d986
--- /dev/null
+++ b/shadow.go
@@ -0,0 +1,219 @@
+package html
+
+import (
+ "strings"
+ "unicode"
+)
+
+// ShadowComponent describes a Web Component class generated from a static
+// go-html node tree.
+type ShadowComponent struct {
+ Name string
+ Template Node
+ Style string
+ Mode string
+}
+
+// RenderClass returns the JavaScript class source for the component.
+func (sc *ShadowComponent) RenderClass() string {
+ if sc == nil || sc.Name == "" {
+ return ""
+ }
+
+ className := pascalCase(sc.Name)
+ if className == "" {
+ return ""
+ }
+
+ body := Render(sc.Template, NewContext())
+ if sc.Style != "" {
+ body = "" + body
+ }
+
+ var b strings.Builder
+ b.WriteString("class ")
+ b.WriteString(className)
+ b.WriteString(" extends HTMLElement {\n")
+ b.WriteString(" constructor() {\n")
+ b.WriteString(" super();\n")
+ b.WriteString(" const shadow = this.attachShadow({ mode: ")
+ b.WriteString(jsStringLiteral(shadowMode(sc.Mode)))
+ b.WriteString(" });\n")
+ b.WriteString(" shadow.innerHTML = ")
+ b.WriteString(jsStringLiteral(body))
+ b.WriteString(";\n")
+ b.WriteString(" }\n")
+ b.WriteString("}")
+ return b.String()
+}
+
+// Register returns the customElements.define() registration source.
+func (sc *ShadowComponent) Register() string {
+ if sc == nil || sc.Name == "" {
+ return ""
+ }
+
+ tagName := kebabCase(sc.Name)
+ className := pascalCase(sc.Name)
+ if tagName == "" || className == "" {
+ return ""
+ }
+
+ return "customElements.define(" + jsStringLiteral(tagName) + ", " + className + ");"
+}
+
+// RenderAll returns the class definition followed by the custom element
+// registration line.
+func (sc *ShadowComponent) RenderAll() string {
+ classSource := sc.RenderClass()
+ registerSource := sc.Register()
+ if classSource == "" || registerSource == "" {
+ return ""
+ }
+ return classSource + "\n" + registerSource
+}
+
+func shadowMode(mode string) string {
+ if mode == "open" {
+ return "open"
+ }
+ return "closed"
+}
+
+func pascalCase(s string) string {
+ var b strings.Builder
+ upperNext := true
+ for _, r := range s {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ upperNext = true
+ continue
+ }
+ if upperNext && unicode.IsLetter(r) {
+ r = unicode.ToUpper(r)
+ }
+ b.WriteRune(r)
+ upperNext = false
+ }
+ return b.String()
+}
+
+type kebabRuneKind int
+
+const (
+ kebabNone kebabRuneKind = iota
+ kebabLower
+ kebabUpper
+ kebabDigit
+)
+
+func kebabCase(s string) string {
+ runes := []rune(s)
+ var b strings.Builder
+ lastWasDash := true
+ previous := kebabNone
+
+ for i, r := range runes {
+ kind := classifyKebabRune(r)
+ if kind == kebabNone {
+ if b.Len() > 0 && !lastWasDash {
+ b.WriteByte('-')
+ lastWasDash = true
+ }
+ previous = kebabNone
+ continue
+ }
+
+ if b.Len() > 0 && !lastWasDash && shouldInsertKebabDash(previous, kind, runes, i) {
+ b.WriteByte('-')
+ }
+ b.WriteRune(unicode.ToLower(r))
+ lastWasDash = false
+ previous = kind
+ }
+
+ return strings.Trim(b.String(), "-")
+}
+
+func classifyKebabRune(r rune) kebabRuneKind {
+ switch {
+ case unicode.IsDigit(r):
+ return kebabDigit
+ case unicode.IsUpper(r):
+ return kebabUpper
+ case unicode.IsLetter(r):
+ return kebabLower
+ default:
+ return kebabNone
+ }
+}
+
+func shouldInsertKebabDash(previous, current kebabRuneKind, runes []rune, index int) bool {
+ if current != kebabUpper {
+ return false
+ }
+ if previous == kebabLower || previous == kebabDigit {
+ return true
+ }
+ return previous == kebabUpper && nextKebabRuneKind(runes, index) == kebabLower
+}
+
+func nextKebabRuneKind(runes []rune, index int) kebabRuneKind {
+ if index+1 >= len(runes) {
+ return kebabNone
+ }
+ return classifyKebabRune(runes[index+1])
+}
+
+func jsStringLiteral(s string) string {
+ var b strings.Builder
+ b.WriteByte('"')
+ appendJSStringLiteral(&b, s)
+ b.WriteByte('"')
+ return b.String()
+}
+
+func appendJSStringLiteral(b *strings.Builder, s string) {
+ for _, r := range s {
+ switch r {
+ case '\\':
+ b.WriteString(`\\`)
+ case '"':
+ b.WriteString(`\"`)
+ case '\b':
+ b.WriteString(`\b`)
+ case '\f':
+ b.WriteString(`\f`)
+ case '\n':
+ b.WriteString(`\n`)
+ case '\r':
+ b.WriteString(`\r`)
+ case '\t':
+ b.WriteString(`\t`)
+ case 0x2028:
+ b.WriteString(`\u2028`)
+ case 0x2029:
+ b.WriteString(`\u2029`)
+ default:
+ if r < 0x20 {
+ appendUnicodeEscape(b, r)
+ continue
+ }
+ if r > 0xFFFF {
+ rr := r - 0x10000
+ appendUnicodeEscape(b, rune(0xD800+(rr>>10)))
+ appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF)))
+ continue
+ }
+ b.WriteRune(r)
+ }
+ }
+}
+
+func appendUnicodeEscape(b *strings.Builder, r rune) {
+ const hex = "0123456789ABCDEF"
+ b.WriteString(`\u`)
+ b.WriteByte(hex[(r>>12)&0xF])
+ b.WriteByte(hex[(r>>8)&0xF])
+ b.WriteByte(hex[(r>>4)&0xF])
+ b.WriteByte(hex[r&0xF])
+}
diff --git a/shadow_test.go b/shadow_test.go
new file mode 100644
index 0000000..404f5a7
--- /dev/null
+++ b/shadow_test.go
@@ -0,0 +1,102 @@
+package html
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestShadowComponent_Good(t *testing.T) {
+ component := &ShadowComponent{
+ Name: "my-button",
+ Template: El("button", Text("Press")),
+ Style: ":host { display: block; }",
+ }
+
+ got := component.RenderAll()
+ for _, want := range []string{
+ "class MyButton extends HTMLElement",
+ "super();",
+ `this.attachShadow({ mode: "closed" });`,
+ `shadow.innerHTML = "
";`,
+ `customElements.define("my-button", MyButton);`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("RenderAll() should contain %q, got:\n%s", want, got)
+ }
+ }
+
+ if strings.Contains(got, `" + "`) {
+ t.Fatalf("RenderAll() should emit a static JS string literal, got:\n%s", got)
+ }
+}
+
+func TestShadowComponent_EmptyName_Bad(t *testing.T) {
+ component := &ShadowComponent{
+ Template: El("button", Text("Press")),
+ }
+
+ if got := component.RenderClass(); got != "" {
+ t.Fatalf("RenderClass() = %q, want empty string", got)
+ }
+ if got := component.Register(); got != "" {
+ t.Fatalf("Register() = %q, want empty string", got)
+ }
+ if got := component.RenderAll(); got != "" {
+ t.Fatalf("RenderAll() = %q, want empty string", got)
+ }
+}
+
+func TestShadowNamingHelpers_Good(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantPascal string
+ wantKebab string
+ }{
+ {
+ name: "kebab name",
+ input: "my-button",
+ wantPascal: "MyButton",
+ wantKebab: "my-button",
+ },
+ {
+ name: "pascal name",
+ input: "MyButton",
+ wantPascal: "MyButton",
+ wantKebab: "my-button",
+ },
+ {
+ name: "acronym name",
+ input: "HTMLButton",
+ wantPascal: "HTMLButton",
+ wantKebab: "html-button",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := pascalCase(tt.input); got != tt.wantPascal {
+ t.Fatalf("pascalCase(%q) = %q, want %q", tt.input, got, tt.wantPascal)
+ }
+ if got := kebabCase(tt.input); got != tt.wantKebab {
+ t.Fatalf("kebabCase(%q) = %q, want %q", tt.input, got, tt.wantKebab)
+ }
+ })
+ }
+}
+
+func TestShadowNamingHelpers_LeadingTrailingDashes_Ugly(t *testing.T) {
+ const input = "-my-button-"
+
+ if got := pascalCase(input); got != "MyButton" {
+ t.Fatalf("pascalCase(%q) = %q, want %q", input, got, "MyButton")
+ }
+ if got := kebabCase(input); got != "my-button" {
+ t.Fatalf("kebabCase(%q) = %q, want %q", input, got, "my-button")
+ }
+
+ component := &ShadowComponent{Name: input}
+ if got := component.Register(); got != `customElements.define("my-button", MyButton);` {
+ t.Fatalf("Register() = %q, want normalised custom element registration", got)
+ }
+}
diff --git a/tests/cli/html/Taskfile.yaml b/tests/cli/html/Taskfile.yaml
new file mode 100644
index 0000000..02169ac
--- /dev/null
+++ b/tests/cli/html/Taskfile.yaml
@@ -0,0 +1,26 @@
+version: "3"
+
+tasks:
+ default:
+ deps:
+ - build
+ - vet
+ - test
+
+ build:
+ desc: Compile every package + cmd binaries (codegen, wasm).
+ dir: ../../..
+ cmds:
+ - GOWORK=off go build ./...
+
+ vet:
+ desc: Run go vet across the module.
+ dir: ../../..
+ cmds:
+ - GOWORK=off go vet ./...
+
+ test:
+ desc: Run unit tests.
+ dir: ../../..
+ cmds:
+ - GOWORK=off go test -count=1 ./...
diff --git a/text_translate.go b/text_translate.go
index 4e3ee8f..3dbf404 100644
--- a/text_translate.go
+++ b/text_translate.go
@@ -3,8 +3,11 @@
package html
func translateText(ctx *Context, key string, args ...any) string {
- if ctx != nil && ctx.service != nil {
- return ctx.service.T(key, args...)
+ if ctx != nil {
+ args = translationArgs(ctx, key, args)
+ if ctx.service != nil {
+ return ctx.service.T(key, args...)
+ }
}
return translateDefault(key, args...)
diff --git a/text_translate_args.go b/text_translate_args.go
new file mode 100644
index 0000000..73ad8f6
--- /dev/null
+++ b/text_translate_args.go
@@ -0,0 +1,109 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the
+// 3.5 MB raw / 1 MB gzip size budget, so count argument normalisation uses
+// small stdlib helpers instead of importing dappco.re/go/core.
+
+import (
+ "strconv"
+ "strings"
+)
+
+func translationArgs(ctx *Context, key string, args []any) []any {
+ if ctx == nil {
+ return args
+ }
+ if !strings.HasPrefix(key, "i18n.count.") {
+ return args
+ }
+
+ count, ok := contextCount(ctx)
+ if !ok {
+ return args
+ }
+
+ if len(args) == 0 {
+ return []any{count}
+ }
+ if !isCountLike(args[0]) {
+ return append([]any{count}, args...)
+ }
+ return args
+}
+
+func contextCount(ctx *Context) (int, bool) {
+ if ctx == nil {
+ return 0, false
+ }
+
+ if n, ok := contextCountMap(ctx.Data); ok {
+ return n, true
+ }
+ if n, ok := contextCountMap(ctx.Metadata); ok {
+ return n, true
+ }
+ return 0, false
+}
+
+func contextCountMap(data map[string]any) (int, bool) {
+ if len(data) == 0 {
+ return 0, false
+ }
+
+ if v, ok := data["Count"]; ok {
+ if n, ok := countInt(v); ok {
+ return n, true
+ }
+ }
+ if v, ok := data["count"]; ok {
+ if n, ok := countInt(v); ok {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+func countInt(v any) (int, bool) {
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int8:
+ return int(n), true
+ case int16:
+ return int(n), true
+ case int32:
+ return int(n), true
+ case int64:
+ return int(n), true
+ case uint:
+ return int(n), true
+ case uint8:
+ return int(n), true
+ case uint16:
+ return int(n), true
+ case uint32:
+ return int(n), true
+ case uint64:
+ return int(n), true
+ case float32:
+ return int(n), true
+ case float64:
+ return int(n), true
+ case string:
+ n = strings.TrimSpace(n)
+ if n == "" {
+ return 0, false
+ }
+ if parsed, err := strconv.Atoi(n); err == nil {
+ return parsed, true
+ }
+ }
+ return 0, false
+}
+
+func isCountLike(v any) bool {
+ _, ok := countInt(v)
+ return ok
+}
diff --git a/text_translate_default.go b/text_translate_default.go
index 3bb280c..d2e3447 100644
--- a/text_translate_default.go
+++ b/text_translate_default.go
@@ -4,7 +4,9 @@
package html
-import i18n "dappco.re/go/core/i18n"
+import (
+ i18n "dappco.re/go/i18n"
+)
func translateDefault(key string, args ...any) string {
return i18n.T(key, args...)
diff --git a/wasm.go b/wasm.go
new file mode 100644
index 0000000..df03a4f
--- /dev/null
+++ b/wasm.go
@@ -0,0 +1,333 @@
+//go:build js && wasm
+
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+import (
+ // AX-6-exception: syscall/js is the required WASM bridge for globalThis; no core/* equivalent exists.
+ "syscall/js"
+)
+
+const (
+ wasmNodeMaxDepth = 64
+ wasmNodeMaxChildren = 1024
+)
+
+// Keep the callback alive for the lifetime of the WASM module.
+var wasmRenderToStringFunc js.Func
+
+type wasmFragmentNode []Node
+
+func (n wasmFragmentNode) Render(ctx *Context) string {
+ if len(n) == 0 {
+ return ""
+ }
+
+ b := newTextBuilder()
+ for _, child := range n {
+ if child == nil {
+ continue
+ }
+ b.WriteString(child.Render(ctx))
+ }
+ return b.String()
+}
+
+// RenderToString renders a Node tree to an HTML string.
+func RenderToString(node Node) string {
+ return Render(node, NewContext())
+}
+
+func init() {
+ wasmRenderToStringFunc = js.FuncOf(wasmRenderToString)
+
+ global := wasmGlobalThis()
+ api := global.Get("coreHTML")
+ if api.Type() != js.TypeObject {
+ api = js.Global().Get("Object").New()
+ global.Set("coreHTML", api)
+ }
+ api.Set("renderToString", wasmRenderToStringFunc)
+}
+
+func wasmGlobalThis() js.Value {
+ global := js.Global()
+ globalThis := global.Get("globalThis")
+ switch globalThis.Type() {
+ case js.TypeObject, js.TypeFunction:
+ return globalThis
+ default:
+ return global
+ }
+}
+
+func wasmRenderToString(_ js.Value, args []js.Value) (out any) {
+ defer func() {
+ if recover() != nil {
+ out = ""
+ }
+ }()
+
+ if len(args) < 1 {
+ return ""
+ }
+
+ node, ok := wasmNodeFromJS(args[0])
+ if !ok {
+ return ""
+ }
+ return RenderToString(node)
+}
+
+func wasmNodeFromJS(value js.Value) (Node, bool) {
+ return wasmNodeFromJSValue(value, 0, true)
+}
+
+func wasmNodeFromJSValue(value js.Value, depth int, parseStringAsJSON bool) (Node, bool) {
+ if depth > wasmNodeMaxDepth {
+ return nil, false
+ }
+
+ switch value.Type() {
+ case js.TypeString:
+ if !parseStringAsJSON {
+ return Text(value.String()), true
+ }
+ parsed, ok := wasmParseJSON(value.String())
+ if !ok {
+ return nil, false
+ }
+ return wasmNodeFromJSValue(parsed, depth, false)
+ case js.TypeObject:
+ if wasmIsArray(value) {
+ return wasmFragmentFromJSArray(value, depth)
+ }
+ return wasmNodeFromJSObject(value, depth)
+ default:
+ return nil, false
+ }
+}
+
+func wasmParseJSON(input string) (parsed js.Value, ok bool) {
+ if input == "" {
+ return js.Value{}, false
+ }
+
+ defer func() {
+ if recover() != nil {
+ parsed = js.Value{}
+ ok = false
+ }
+ }()
+
+ parsed = wasmGlobalThis().Get("JSON").Call("parse", input)
+ switch parsed.Type() {
+ case js.TypeUndefined, js.TypeNull:
+ return js.Value{}, false
+ default:
+ return parsed, true
+ }
+}
+
+func wasmNodeFromJSObject(value js.Value, depth int) (Node, bool) {
+ kind, hasKind := wasmStringProp(value, "type", "kind")
+ if hasKind {
+ switch kind {
+ case "el", "element":
+ return wasmElementFromJS(value, depth)
+ case "text":
+ text, _ := wasmStringProp(value, "key", "text", "value", "content")
+ return Text(text), true
+ case "raw", "html":
+ content, _ := wasmStringProp(value, "html", "content", "value")
+ return Raw(content), true
+ case "layout":
+ return wasmLayoutFromJS(value, depth)
+ case "fragment":
+ return wasmFragmentFromJSArray(value.Get("children"), depth)
+ default:
+ return nil, false
+ }
+ }
+
+ if _, ok := wasmStringProp(value, "tag"); ok {
+ return wasmElementFromJS(value, depth)
+ }
+ if content, ok := wasmStringProp(value, "html", "content"); ok {
+ return Raw(content), true
+ }
+ if text, ok := wasmStringProp(value, "key", "text", "value"); ok {
+ return Text(text), true
+ }
+ if wasmIsArray(value.Get("children")) {
+ return wasmFragmentFromJSArray(value.Get("children"), depth)
+ }
+ return nil, false
+}
+
+func wasmElementFromJS(value js.Value, depth int) (Node, bool) {
+ tag, ok := wasmStringProp(value, "tag")
+ if !ok || tag == "" {
+ return nil, false
+ }
+
+ node := El(tag, wasmChildNodes(value, depth)...)
+ attrs := value.Get("attrs")
+ if attrs.Type() != js.TypeObject || wasmIsArray(attrs) {
+ return node, true
+ }
+
+ keys := wasmObjectKeys(attrs)
+ for i, length := 0, wasmArrayLength(keys); i < length; i++ {
+ key := keys.Index(i).String()
+ attr, ok := wasmScalarString(attrs.Get(key))
+ if !ok {
+ continue
+ }
+ node = Attr(node, key, attr)
+ }
+ return node, true
+}
+
+func wasmLayoutFromJS(value js.Value, depth int) (Node, bool) {
+ variant, ok := wasmStringProp(value, "variant")
+ if !ok || variant == "" {
+ return nil, false
+ }
+
+ layout := NewLayout(variant)
+ slots := value.Get("slots")
+ if slots.Type() != js.TypeObject || wasmIsArray(slots) {
+ return layout, true
+ }
+
+ for _, slot := range []string{"H", "L", "C", "R", "F"} {
+ nodes := wasmSlotNodes(slots.Get(slot), depth+1)
+ if len(nodes) == 0 {
+ continue
+ }
+
+ switch slot {
+ case "H":
+ layout.H(nodes...)
+ case "L":
+ layout.L(nodes...)
+ case "C":
+ layout.C(nodes...)
+ case "R":
+ layout.R(nodes...)
+ case "F":
+ layout.F(nodes...)
+ }
+ }
+ return layout, true
+}
+
+func wasmChildNodes(value js.Value, depth int) []Node {
+ children := value.Get("children")
+ if !wasmIsArray(children) {
+ return nil
+ }
+ return wasmNodesFromJSArray(children, depth+1)
+}
+
+func wasmSlotNodes(value js.Value, depth int) []Node {
+ switch value.Type() {
+ case js.TypeUndefined, js.TypeNull:
+ return nil
+ }
+ if wasmIsArray(value) {
+ return wasmNodesFromJSArray(value, depth)
+ }
+
+ node, ok := wasmNodeFromJSValue(value, depth, false)
+ if !ok {
+ return nil
+ }
+ return []Node{node}
+}
+
+func wasmFragmentFromJSArray(value js.Value, depth int) (Node, bool) {
+ if depth > wasmNodeMaxDepth || !wasmIsArray(value) {
+ return nil, false
+ }
+ return wasmFragmentNode(wasmNodesFromJSArray(value, depth+1)), true
+}
+
+func wasmNodesFromJSArray(value js.Value, depth int) []Node {
+ if depth > wasmNodeMaxDepth || !wasmIsArray(value) {
+ return nil
+ }
+
+ length := wasmArrayLength(value)
+ nodes := make([]Node, 0, length)
+ for i := 0; i < length; i++ {
+ node, ok := wasmNodeFromJSValue(value.Index(i), depth, false)
+ if !ok {
+ continue
+ }
+ nodes = append(nodes, node)
+ }
+ return nodes
+}
+
+func wasmStringProp(value js.Value, names ...string) (string, bool) {
+ for _, name := range names {
+ prop := value.Get(name)
+ if prop.Type() == js.TypeString {
+ return prop.String(), true
+ }
+ }
+ return "", false
+}
+
+func wasmScalarString(value js.Value) (string, bool) {
+ switch value.Type() {
+ case js.TypeString:
+ return value.String(), true
+ case js.TypeBoolean:
+ if value.Bool() {
+ return "true", true
+ }
+ return "false", true
+ case js.TypeNumber:
+ return wasmGlobalThis().Get("String").Invoke(value).String(), true
+ default:
+ return "", false
+ }
+}
+
+func wasmIsArray(value js.Value) bool {
+ if value.Type() != js.TypeObject {
+ return false
+ }
+ return wasmGlobalThis().Get("Array").Call("isArray", value).Bool()
+}
+
+func wasmObjectKeys(value js.Value) js.Value {
+ if value.Type() != js.TypeObject {
+ return js.Value{}
+ }
+ return wasmGlobalThis().Get("Object").Call("keys", value)
+}
+
+func wasmArrayLength(value js.Value) int {
+ if value.Type() != js.TypeObject {
+ return 0
+ }
+
+ length := value.Get("length")
+ if length.Type() != js.TypeNumber {
+ return 0
+ }
+
+ n := length.Int()
+ if n < 0 {
+ return 0
+ }
+ if n > wasmNodeMaxChildren {
+ return wasmNodeMaxChildren
+ }
+ return n
+}
diff --git a/wasm_test.go b/wasm_test.go
new file mode 100644
index 0000000..1ddbc99
--- /dev/null
+++ b/wasm_test.go
@@ -0,0 +1,78 @@
+//go:build js && wasm
+
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+import (
+ "strings"
+ "testing"
+
+ // AX-6-exception: syscall/js is required to exercise the WASM globalThis bridge.
+ "syscall/js"
+)
+
+func TestRenderToString_Good(t *testing.T) {
+ nodeJSON := js.ValueOf(map[string]any{
+ "type": "element",
+ "tag": "section",
+ "attrs": map[string]any{
+ "id": "intro",
+ },
+ "children": []any{
+ map[string]any{
+ "type": "text",
+ "value": "hello",
+ },
+ },
+ })
+
+ got := invokeWASMRenderToString(t, nodeJSON)
+ want := `
`
+ if got != want {
+ t.Fatalf("renderToString(simple node) = %q, want %q", got, want)
+ }
+}
+
+func TestRenderToString_MalformedJSON_Bad(t *testing.T) {
+ got := invokeWASMRenderToString(t, js.ValueOf(`{"type":`))
+ if got != "" {
+ t.Fatalf("renderToString(malformed JSON) = %q, want empty string", got)
+ }
+}
+
+func TestRenderToString_DeeplyNestedInput_Ugly(t *testing.T) {
+ depth := wasmNodeMaxDepth + 20
+ input := strings.Repeat(`{"type":"element","tag":"div","children":[`, depth) +
+ `{"type":"text","value":"leaf"}` +
+ strings.Repeat(`]}`, depth)
+
+ got := invokeWASMRenderToString(t, js.ValueOf(input))
+ maxLen := (wasmNodeMaxDepth + 1) * len("
")
+ if len(got) > maxLen {
+ t.Fatalf("renderToString(deep input) length = %d, want <= %d", len(got), maxLen)
+ }
+ if strings.Contains(got, "leaf") {
+ t.Fatalf("renderToString(deep input) rendered beyond depth bound: %q", got)
+ }
+}
+
+func invokeWASMRenderToString(t *testing.T, nodeJSON js.Value) string {
+ t.Helper()
+
+ api := wasmGlobalThis().Get("coreHTML")
+ if api.Type() != js.TypeObject {
+ t.Fatalf("globalThis.coreHTML type = %s, want object", api.Type().String())
+ }
+
+ renderToString := api.Get("renderToString")
+ if renderToString.Type() != js.TypeFunction {
+ t.Fatalf("globalThis.coreHTML.renderToString type = %s, want function", renderToString.Type().String())
+ }
+
+ got := renderToString.Invoke(nodeJSON)
+ if got.Type() != js.TypeString {
+ t.Fatalf("renderToString return type = %s, want string", got.Type().String())
+ }
+ return got.String()
+}