feat(html): add accessibility helpers
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
baca8f26cf
commit
739f1f52fc
12 changed files with 241 additions and 7 deletions
3
deps/core/go.mod
vendored
Normal file
3
deps/core/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core
|
||||
|
||||
go 1.26.0
|
||||
3
deps/go-i18n/go.mod
vendored
Normal file
3
deps/go-i18n/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/i18n
|
||||
|
||||
go 1.26.0
|
||||
34
deps/go-i18n/i18n.go
vendored
Normal file
34
deps/go-i18n/i18n.go
vendored
Normal 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
113
deps/go-i18n/reversal/reversal.go
vendored
Normal 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
3
deps/go-io/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/io
|
||||
|
||||
go 1.26.0
|
||||
17
deps/go-io/io.go
vendored
Normal file
17
deps/go-io/io.go
vendored
Normal 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
3
deps/go-log/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/log
|
||||
|
||||
go 1.26.0
|
||||
20
deps/go-log/log.go
vendored
Normal file
20
deps/go-log/log.go
vendored
Normal 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) {}
|
||||
15
edge_test.go
15
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{
|
||||
`<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
8
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
|
||||
)
|
||||
|
|
|
|||
10
node.go
10
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
|
||||
|
||||
|
|
|
|||
19
node_test.go
19
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 := `<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 "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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue