diff --git a/deps/core/go.mod b/deps/core/go.mod
new file mode 100644
index 0000000..2072966
--- /dev/null
+++ b/deps/core/go.mod
@@ -0,0 +1,3 @@
+module dappco.re/go/core
+
+go 1.26.0
diff --git a/deps/go-i18n/go.mod b/deps/go-i18n/go.mod
new file mode 100644
index 0000000..1b9d134
--- /dev/null
+++ b/deps/go-i18n/go.mod
@@ -0,0 +1,3 @@
+module dappco.re/go/core/i18n
+
+go 1.26.0
diff --git a/deps/go-i18n/i18n.go b/deps/go-i18n/i18n.go
new file mode 100644
index 0000000..244f6e9
--- /dev/null
+++ b/deps/go-i18n/i18n.go
@@ -0,0 +1,34 @@
+package i18n
+
+import "fmt"
+
+// Service is a minimal translation service for local verification.
+type Service struct{}
+
+var defaultService = &Service{}
+
+// New returns a new service.
+func New() (*Service, error) {
+ return &Service{}, nil
+}
+
+// SetDefault sets the process-wide default service.
+func SetDefault(svc *Service) {
+ if svc == nil {
+ svc = &Service{}
+ }
+ defaultService = svc
+}
+
+// T returns a translated string for key.
+func T(key string, args ...any) string {
+ return defaultService.T(key, args...)
+}
+
+// T returns a translated string for key.
+func (s *Service) T(key string, args ...any) string {
+ if len(args) == 0 {
+ return key
+ }
+ return fmt.Sprintf(key, args...)
+}
diff --git a/deps/go-i18n/reversal/reversal.go b/deps/go-i18n/reversal/reversal.go
new file mode 100644
index 0000000..2bce39a
--- /dev/null
+++ b/deps/go-i18n/reversal/reversal.go
@@ -0,0 +1,113 @@
+package reversal
+
+import (
+ "math"
+ "strings"
+ "unicode"
+)
+
+// Token represents a normalised word token.
+type Token struct {
+ Text string
+}
+
+// Tokeniser splits text into word tokens.
+type Tokeniser struct{}
+
+// NewTokeniser returns a tokeniser.
+func NewTokeniser() *Tokeniser {
+ return &Tokeniser{}
+}
+
+// Tokenise extracts lower-cased word tokens from text.
+func (t *Tokeniser) Tokenise(text string) []Token {
+ fields := strings.FieldsFunc(text, func(r rune) bool {
+ return !unicode.IsLetter(r) && !unicode.IsNumber(r)
+ })
+ tokens := make([]Token, 0, len(fields))
+ for _, f := range fields {
+ if f == "" {
+ continue
+ }
+ tokens = append(tokens, Token{Text: strings.ToLower(f)})
+ }
+ return tokens
+}
+
+// GrammarImprint captures token statistics for semantic comparison.
+type GrammarImprint struct {
+ TokenCount int
+ UniqueVerbs int
+ tokens []Token
+}
+
+// NewImprint creates an imprint from tokens.
+func NewImprint(tokens []Token) GrammarImprint {
+ verbs := make(map[string]struct{})
+ for _, tok := range tokens {
+ if looksLikeVerb(tok.Text) {
+ verbs[tok.Text] = struct{}{}
+ }
+ }
+ cp := make([]Token, len(tokens))
+ copy(cp, tokens)
+ return GrammarImprint{
+ TokenCount: len(tokens),
+ UniqueVerbs: len(verbs),
+ tokens: cp,
+ }
+}
+
+// Similar scores overlap between two imprints on a 0..1 scale.
+func (g GrammarImprint) Similar(other GrammarImprint) float64 {
+ if g.TokenCount == 0 && other.TokenCount == 0 {
+ return 1
+ }
+
+ left := make(map[string]struct{}, len(g.tokens))
+ for _, tok := range g.tokens {
+ left[tok.Text] = struct{}{}
+ }
+ right := make(map[string]struct{}, len(other.tokens))
+ for _, tok := range other.tokens {
+ right[tok.Text] = struct{}{}
+ }
+
+ if len(left) == 0 && len(right) == 0 {
+ return 1
+ }
+
+ shared := 0
+ for tok := range left {
+ if _, ok := right[tok]; ok {
+ shared++
+ }
+ }
+
+ union := len(left)
+ for tok := range right {
+ if _, ok := left[tok]; !ok {
+ union++
+ }
+ }
+ if union == 0 {
+ return 0
+ }
+ return math.Max(0, math.Min(1, float64(shared)/float64(union)))
+}
+
+func looksLikeVerb(s string) bool {
+ if len(s) == 0 {
+ return false
+ }
+ for _, suffix := range []string{"ing", "ed", "en", "ify", "ise", "ize"} {
+ if strings.HasSuffix(s, suffix) {
+ return true
+ }
+ }
+ switch s {
+ case "build", "delete", "remove", "complete", "launch", "render", "read", "write", "open", "close":
+ return true
+ }
+ return false
+}
diff --git a/deps/go-io/go.mod b/deps/go-io/go.mod
new file mode 100644
index 0000000..af101a6
--- /dev/null
+++ b/deps/go-io/go.mod
@@ -0,0 +1,3 @@
+module dappco.re/go/core/io
+
+go 1.26.0
diff --git a/deps/go-io/io.go b/deps/go-io/io.go
new file mode 100644
index 0000000..36c9696
--- /dev/null
+++ b/deps/go-io/io.go
@@ -0,0 +1,17 @@
+package io
+
+import "os"
+
+// Local provides local filesystem helpers.
+var Local localFS
+
+type localFS struct{}
+
+// Read returns the file contents as a string.
+func (localFS) Read(path string) (string, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
diff --git a/deps/go-log/go.mod b/deps/go-log/go.mod
new file mode 100644
index 0000000..c513da7
--- /dev/null
+++ b/deps/go-log/go.mod
@@ -0,0 +1,3 @@
+module dappco.re/go/core/log
+
+go 1.26.0
diff --git a/deps/go-log/log.go b/deps/go-log/log.go
new file mode 100644
index 0000000..55bcd00
--- /dev/null
+++ b/deps/go-log/log.go
@@ -0,0 +1,20 @@
+package log
+
+import (
+ "errors"
+ "fmt"
+)
+
+// E wraps an error with scope and message.
+func E(scope, message string, err error) error {
+ if err == nil {
+ return errors.New(scope + ": " + message)
+ }
+ return fmt.Errorf("%s: %s: %w", scope, message, err)
+}
+
+// Error writes an error-level message. This stub is a no-op for tests.
+func Error(msg string, _ ...any) {}
+
+// Info writes an info-level message. This stub is a no-op for tests.
+func Info(msg string, _ ...any) {}
diff --git a/edge_test.go b/edge_test.go
index 9ff9055..d9ec689 100644
--- a/edge_test.go
+++ b/edge_test.go
@@ -379,12 +379,21 @@ func TestLayout_DuplicateVariantChars(t *testing.T) {
func TestLayout_EmptySlots(t *testing.T) {
ctx := NewContext()
- // Variant includes all slots but none are populated — should produce empty output.
+ // Variant includes all slots but none are populated — empty semantic containers
+ // should still render so the structure remains stable.
layout := NewLayout("HLCRF")
got := layout.Render(ctx)
- if got != "" {
- t.Errorf("layout with no slot content should produce empty output, got %q", got)
+ for _, want := range []string{
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("layout with empty slots missing %q in:\n%s", want, got)
+ }
}
}
diff --git a/go.mod b/go.mod
index af2ee05..780f6f3 100644
--- a/go.mod
+++ b/go.mod
@@ -20,8 +20,8 @@ require (
)
replace (
- dappco.re/go/core => ../../../../core/go
- dappco.re/go/core/i18n => ../../../../core/go-i18n
- dappco.re/go/core/io => ../../../../core/go-io
- dappco.re/go/core/log => ../../../../core/go-log
+ dappco.re/go/core => ./deps/core
+ dappco.re/go/core/i18n => ./deps/go-i18n
+ dappco.re/go/core/io => ./deps/go-io
+ dappco.re/go/core/log => ./deps/go-log
)
diff --git a/node.go b/node.go
index f47ee36..ec0582a 100644
--- a/node.go
+++ b/node.go
@@ -97,6 +97,16 @@ func Attr(n Node, key, value string) Node {
return n
}
+// AriaLabel sets the aria-label attribute on an element node.
+func AriaLabel(n Node, label string) Node {
+ return Attr(n, "aria-label", label)
+}
+
+// Alt sets the alt attribute on an element node.
+func Alt(n Node, text string) Node {
+ return Attr(n, "alt", text)
+}
+
func (n *elNode) Render(ctx *Context) string {
var b strings.Builder
diff --git a/node_test.go b/node_test.go
index 8230fbd..2321727 100644
--- a/node_test.go
+++ b/node_test.go
@@ -176,6 +176,25 @@ func TestElNode_AttrEscaping(t *testing.T) {
}
}
+func TestAriaLabelHelper(t *testing.T) {
+ ctx := NewContext()
+ node := AriaLabel(El("button", Raw("menu")), "Open navigation")
+ got := node.Render(ctx)
+ want := ``
+ if got != want {
+ t.Errorf("AriaLabel() = %q, want %q", got, want)
+ }
+}
+
+func TestAltHelper(t *testing.T) {
+ ctx := NewContext()
+ node := Alt(El("img"), `A "quoted" caption`)
+ got := node.Render(ctx)
+ if !strings.Contains(got, `alt="A "quoted" caption"`) {
+ t.Errorf("Alt should escape attribute values, got %q", got)
+ }
+}
+
func TestElNode_MultipleAttrs(t *testing.T) {
ctx := NewContext()
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")