feat(html): add accessibility helpers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 16:26:46 +00:00
parent baca8f26cf
commit 739f1f52fc
12 changed files with 241 additions and 7 deletions

3
deps/core/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core
go 1.26.0

3
deps/go-i18n/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/i18n
go 1.26.0

34
deps/go-i18n/i18n.go vendored Normal file
View file

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

113
deps/go-i18n/reversal/reversal.go vendored Normal file
View file

@ -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
}

3
deps/go-io/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/io
go 1.26.0

17
deps/go-io/io.go vendored Normal file
View file

@ -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
}

3
deps/go-log/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/log
go 1.26.0

20
deps/go-log/log.go vendored Normal file
View file

@ -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) {}

View file

@ -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{
`<header role="banner" data-block="H-0"></header>`,
`<aside role="complementary" data-block="L-0"></aside>`,
`<main role="main" data-block="C-0"></main>`,
`<aside role="complementary" data-block="R-0"></aside>`,
`<footer role="contentinfo" data-block="F-0"></footer>`,
} {
if !strings.Contains(got, want) {
t.Errorf("layout with empty slots missing %q in:\n%s", want, got)
}
}
}

8
go.mod
View file

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

10
node.go
View file

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

View file

@ -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 := `<button aria-label="Open navigation">menu</button>`
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 &#34;quoted&#34; 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")