Compare commits

...
Sign in to create a new pull request.

125 commits
dev ... main

Author SHA1 Message Date
Virgil
6d710aadb1 feat(html): expose structured block paths
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 02:18:03 +00:00
Virgil
18765404ef fix: validate block id slot letters
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 02:13:31 +00:00
Virgil
2a16ce6717 feat(html): add autocomplete helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 02:11:21 +00:00
Virgil
2a5e2ee5a3 fix(html): preserve locale on cloned translators
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 02:07:05 +00:00
Virgil
b3b44ae432 feat(html): restore context translator swapping
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 02:03:39 +00:00
Virgil
436bd3716f fix(html): unescape stripped html entities
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:59:54 +00:00
Virgil
9230d3b66c fix(html): dedupe ARIA relationship tokens
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:56:28 +00:00
Virgil
daaae16493 fix(html): snapshot responsive variants
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:52:03 +00:00
Virgil
acaf9d83a0 fix(html): keep striptags single-pass
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:48:31 +00:00
Virgil
68874bed0b fix(context): make translator selection immutable
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:45:35 +00:00
Virgil
e974a153a7 feat(html): expose deterministic block ids
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:42:32 +00:00
Virgil
6e22cc1f7f fix(html): improve validation guidance
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-04 01:38:45 +00:00
Virgil
3bc82e27ce test(wasm): add host-side render coverage
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:35:13 +00:00
Virgil
799317b788 feat(codegen): reject reserved custom element tags
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:31:38 +00:00
Virgil
5fc370b691 feat(html): add placeholder helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:26:50 +00:00
Virgil
7cbf678738 fix(html): clone mutable translators in context copies
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:23:40 +00:00
Virgil
575150d686 fix(codegen): keep watch mode alive on missing input files
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:19:44 +00:00
Virgil
030a41f732 fix(wasm): bubble wc-ready lifecycle event
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:16:08 +00:00
Virgil
87ebb7958d feat(html): add title helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:11:58 +00:00
Virgil
bfacf35c81 fix(wasm): declare private shadow field
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:08:19 +00:00
Virgil
f3c2bb1ca7 fix(html): isolate shared nodes in Each
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:05:53 +00:00
Virgil
cc75f3b533 refactor(html): share deterministic attribute rendering
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:01:12 +00:00
Virgil
2e8886bbd7 feat(html): relax block id parsing
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:57:06 +00:00
Virgil
d12408ffb9 feat(html): expose deep clone 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>
2026-04-04 00:53:36 +00:00
Virgil
a4d2341211 fix(html): isolate cloned default translators
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:49:13 +00:00
Virgil
c1e7cfaab0 feat(html): add fluent entitlements context setters
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:45:09 +00:00
Virgil
dc00e10ec0 feat(html): add standard boolean attribute 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>
2026-04-04 00:42:29 +00:00
Virgil
9741659442 fix(html): honour locale in wasm translator
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:38:33 +00:00
Virgil
f828848cc0 feat(html): add aria-atomic helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:32:08 +00:00
Virgil
3fa2a86664 feat(html): add cloneable locale and service 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>
2026-04-04 00:28:08 +00:00
Virgil
9b7626eb91 feat(html): add cloneable identity helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:24:55 +00:00
Virgil
c56924d95c feat(html): add cloneable context data 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>
2026-04-04 00:20:41 +00:00
Virgil
9a90819aaf feat(context): add fluent data setter
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:16:08 +00:00
Virgil
5b69aec575 feat(html): add aria details 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>
2026-04-04 00:13:10 +00:00
Virgil
ad01f04a51 feat(html): add aria role description helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:41:55 +00:00
Virgil
19ab9d246a feat(html): add context identity setter
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:38:10 +00:00
Virgil
831a47f1d3 feat(html): add context locale setters
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:35:13 +00:00
Virgil
0ecef2e72f feat(html): canonicalise variant comparison keys
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:25:22 +00:00
Virgil
de3a40000c feat(html): add aria-modal helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:21:57 +00:00
Virgil
94833edf43 fix(wasm): align custom element runtime source
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:18:01 +00:00
Virgil
bf5ec80c8b fix(codegen): reduce poll timer churn
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Use a ticker in watch mode and build registration strings without fmt.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:15:11 +00:00
Virgil
9c0a925876 fix(html): make context immutable after construction
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:12:13 +00:00
Virgil
82ddc736a9 fix(html): harden selector list splitting
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:08:50 +00:00
Virgil
fde2f9b884 fix(html): reserve responsive variant attribute
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:05:04 +00:00
Virgil
b6120a1929 feat(html): add aria-haspopup helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 23:01:34 +00:00
Virgil
cb7d45f21b feat(html): add aria-keyshortcuts helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 22:58:53 +00:00
Virgil
aa6e064247 feat(html): add aria-readonly helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:29:42 +00:00
Virgil
a184549013 fix(html): ignore whitespace-only helper values
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:25:15 +00:00
Virgil
8900d25cfe fix(html): isolate wrapper attrs from shared nodes
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:21:24 +00:00
Virgil
b5d170817c fix(html): scope selector lists correctly
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:16:26 +00:00
Virgil
967182a676 feat(wasm): add component registration export
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:13:14 +00:00
Virgil
850dbdb0b6 feat(html): index repeated layout block ids
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:08:13 +00:00
Virgil
c6bca226a9 feat(html): add id and for 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>
2026-04-03 20:04:27 +00:00
Virgil
1134683e1b feat(html): add aria owns helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 20:01:23 +00:00
Virgil
9806dea825 feat(codegen): export generated type declarations
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:58:21 +00:00
Virgil
9b620cf673 refactor(html): add AX docs to helper wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:54:45 +00:00
Virgil
fd1f7cea74 feat(html): add hidden helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:51:26 +00:00
Virgil
24565df459 docs(html): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:44:46 +00:00
Virgil
cafa24163d feat(html): add aria checked helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:41:09 +00:00
Virgil
94eb419914 feat(html): add aria invalid and required 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>
2026-04-03 19:38:02 +00:00
Virgil
9def509187 fix(html): make fluent builders nil-safe
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:34:23 +00:00
Virgil
3c64352a3b fix(html): trim helper tokens
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:31:54 +00:00
Virgil
3375ad0b22 feat(html): export layout variant diagnostics
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:28:46 +00:00
Virgil
3fcd8daf59 feat(html): expose layout variant positions
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:26:11 +00:00
Virgil
cb75de9bf3 feat(html): isolate text translation fallback
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:22:48 +00:00
Virgil
d74b65913a feat(layout): add variant validity helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:18:02 +00:00
Virgil
669163fcd6 feat(html): make default context locale-aware
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:13:18 +00:00
Virgil
113dab6635 feat(codegen): deduplicate repeated tags
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:09:00 +00:00
Virgil
0716fe991f docs(codegen): standardise AX examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:05:50 +00:00
Virgil
89d2870e20 fix(codegen): reject invalid slot keys
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 19:02:50 +00:00
Virgil
2ce8876cb5 fix(html): ignore empty helper tokens
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Drop empty strings from join-based accessibility helpers and class names so generated attributes stay clean.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:59:24 +00:00
Virgil
511a10f54b fix(html): ignore empty selector segments in ScopeVariant
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:56:57 +00:00
Virgil
1958cc79b1 docs(html): align public API docs with AX principles
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:53:50 +00:00
Virgil
7b95c1fc74 feat(html): add aria-description helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:51:28 +00:00
Virgil
6f65fc903c feat(html): restore context mutators
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:48:46 +00:00
Virgil
3d841efa12 feat(html): add aria-live helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:46:36 +00:00
Virgil
667965da19 feat(html): add aria-busy helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:44:10 +00:00
Virgil
2b796e57eb fix(html): remove mutable context setters
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:40:46 +00:00
Virgil
a388848626 feat(codegen): type wc-ready events in declarations
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:37:32 +00:00
Virgil
30f64a3d59 feat(html): add aria state 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>
2026-04-03 18:34:00 +00:00
Virgil
a7433675ba feat(html): add AltText helper alias
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:30:49 +00:00
Virgil
7091a5f341 feat(html): add aria-disabled helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:28:49 +00:00
Virgil
ec18122233 feat(html): add aria-current helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:25:09 +00:00
Virgil
a5e02f6472 feat(html): add class helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:21:16 +00:00
Virgil
a925142e4e feat(html): add aria-controls helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:18:30 +00:00
Virgil
ec2ccc7653 feat(html): add aria-expanded helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:15:48 +00:00
Virgil
c240116c1d fix(codegen): validate custom element tags
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:12:47 +00:00
Virgil
0fcffb029d fix(html): preserve responsive block paths
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:09:46 +00:00
Virgil
e8d2a7f7e7 fix(html): normalise zero-value context defaults
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:08:04 +00:00
Virgil
ae286563fd fix(html): normalise nil render context
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:05:06 +00:00
Virgil
aa00f27db4 fix(codegen): ignore transient invalid watch input
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 18:01:45 +00:00
Virgil
aa282056fa feat(html): preserve nested block paths through elements
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:58:21 +00:00
Virgil
d4cacb80ec feat(html): scope selector lists in responsive variants
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:55:34 +00:00
Virgil
957bc85c64 fix(html): improve layout variant diagnostics
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:52:31 +00:00
Virgil
a029931f76 fix(wasm): validate renderToString JS args
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:49:16 +00:00
Virgil
4c0669ef1a feat(html): support attributes on responsive wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:45:51 +00:00
Virgil
d0e7f60dab feat(html): add dir helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Add a Dir convenience helper for the dir attribute so RTL/text-direction markup can be applied without raw Attr calls.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:42:50 +00:00
Virgil
4d767fa0bd fix(html): omit aria-hidden when visible
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:39:54 +00:00
Virgil
9721c23202 feat(html): allow attributes on layouts
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:36:48 +00:00
Virgil
edb53a4a29 fix(html): harden variant selector escaping
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:33:41 +00:00
Virgil
e4ee677bb7 feat(codegen): order generated slots predictably
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:30:55 +00:00
Virgil
f8558a52ef feat(html): add lang helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:28:27 +00:00
Virgil
25dc761d0b fix(html): reject unknown slots in block IDs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:25:49 +00:00
Virgil
c84bd21cf4 docs: align html API comments with AX guidance
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:23:37 +00:00
Virgil
a86c8ef770 feat(html): recurse attrs through iterator wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:20:17 +00:00
Virgil
264ecc3f84 feat(html): add aria-labelledby helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:16:39 +00:00
Virgil
97a48fc73d fix(html): validate block path decoding
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:14:10 +00:00
Virgil
25d809fc88 feat(html): add aria-describedby helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:11:51 +00:00
Virgil
f7843ae180 feat(html): add autofocus helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:09:10 +00:00
Virgil
1d71ac4676 fix(html): harden nil-safe pipeline rendering
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:06:27 +00:00
Virgil
7814f669fd feat(html): add role accessibility helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:03:23 +00:00
Virgil
46a8b7e904 fix(html): harden nil rendering paths
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 17:00:58 +00:00
Virgil
8dfce51659 fix(html): preserve each path prefixes
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:58:12 +00:00
Virgil
ba384aeb12 fix: add nil-safe rendering paths
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:55:48 +00:00
Virgil
3d2fdf4e22 feat(codegen): restore watch mode
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:52:47 +00:00
Virgil
afa0337bbd feat(html): add context locale setter
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:48:59 +00:00
Virgil
58380d3d87 feat(responsive): add CSS scoping 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>
2026-04-03 16:46:05 +00:00
Virgil
56bd6638db feat(codegen): add TypeScript declarations
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Generate .d.ts output for Web Component bundles and add a CLI -dts mode.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:42:41 +00:00
Virgil
8bf49c8935 fix(html): preserve nested layout paths through wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:39:34 +00:00
Virgil
8682eeb929 feat(html): add layout variant validation helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:36:20 +00:00
Virgil
84ad59cd09 feat(context): accept optional locale
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Allow callers to seed Locale directly when constructing a rendering context, while preserving the existing no-argument API.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:33:38 +00:00
Virgil
149d31b140 feat(html): add tabindex helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:31:22 +00:00
Virgil
48884f7974 feat(html): add aria-hidden helper
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:29:33 +00:00
Virgil
739f1f52fc 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>
2026-04-03 16:26:46 +00:00
Virgil
baca8f26cf feat(layout): render empty variant slots
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:21:46 +00:00
51 changed files with 5063 additions and 231 deletions

View file

@ -27,7 +27,7 @@ See `docs/architecture.md` for full detail. Summary:
- **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order
- **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only)
- **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/`
- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip
- **WASM**: `cmd/wasm/` exports `renderToString()` and `registerComponents()` — size gate: < 3.5 MB raw, < 1 MB gzip
## Server/Client Split

View file

@ -4,7 +4,7 @@
# go-html
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()` and `registerComponents()`.
**Module**: `forge.lthn.ai/core/go-html`
**Licence**: EUPL-1.2

View file

@ -1,42 +1,152 @@
//go:build !js
// Package main provides a build-time CLI for generating Web Component JS bundles.
// Reads a JSON slot map from stdin, writes the generated JS to stdout.
// Reads a JSON slot map from stdin, writes the generated JS to stdout, and can
// optionally watch a slot file and rewrite an output bundle on change.
//
// Usage:
//
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -dts > components.d.ts
// echo '{"H":"nav-bar","C":"main-content"}' > slots.json
// go run ./cmd/codegen/ -watch -input slots.json -output components.js
package main
import (
"context"
"encoding/json"
"errors"
"flag"
goio "io"
"os"
"os/signal"
"time"
"dappco.re/go/core/html/codegen"
coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log"
)
var emitTypeDefinitions = flag.Bool("dts", false, "emit TypeScript declarations instead of JavaScript")
var watchMode = flag.Bool("watch", false, "poll an input file and rewrite an output bundle when it changes")
var watchInputPath = flag.String("input", "", "path to the JSON slot map used by -watch")
var watchOutputPath = flag.String("output", "", "path to the generated bundle written by -watch")
var watchPollInterval = flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
func run(r goio.Reader, w goio.Writer) error {
return runWithMode(r, w, false)
}
func runTypeDefinitions(r goio.Reader, w goio.Writer) error {
return runWithMode(r, w, true)
}
func runWithMode(r goio.Reader, w goio.Writer, emitTypes bool) error {
data, err := goio.ReadAll(r)
if err != nil {
return log.E("codegen", "reading stdin", err)
}
var slots map[string]string
if err := json.Unmarshal(data, &slots); err != nil {
return log.E("codegen", "invalid JSON", err)
}
js, err := codegen.GenerateBundle(slots)
out, err := generate(data, emitTypes)
if err != nil {
return err
}
_, err = goio.WriteString(w, js)
_, err = goio.WriteString(w, out)
return err
}
func generate(data []byte, emitTypes bool) (string, error) {
var slots map[string]string
if err := json.Unmarshal(data, &slots); err != nil {
return "", log.E("codegen", "invalid JSON", err)
}
if emitTypes {
return codegen.GenerateTypeDefinitions(slots)
}
return codegen.GenerateBundle(slots)
}
func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
if inputPath == "" {
return log.E("codegen", "watch mode requires -input", nil)
}
if outputPath == "" {
return log.E("codegen", "watch mode requires -output", nil)
}
if pollInterval <= 0 {
pollInterval = 250 * time.Millisecond
}
var lastInput string
var lastOutput string
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
input, err := coreio.Local.Read(inputPath)
if err != nil {
if os.IsNotExist(err) {
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-ticker.C:
}
continue
}
return log.E("codegen", "reading input file", err)
}
if input != lastInput {
out, err := generate([]byte(input), emitTypes)
if err != nil {
// Watch mode should keep running through transient bad edits.
log.Error("codegen watch skipped invalid input", "err", err)
lastInput = input
} else {
if out != lastOutput {
if err := coreio.Local.Write(outputPath, out); err != nil {
return log.E("codegen", "writing output file", err)
}
lastOutput = out
}
lastInput = input
}
}
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-ticker.C:
}
}
}
func main() {
if err := run(os.Stdin, os.Stdout); err != nil {
flag.Parse()
var err error
if *watchMode {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
err = runDaemon(ctx, *watchInputPath, *watchOutputPath, *emitTypeDefinitions, *watchPollInterval)
} else {
if *emitTypeDefinitions {
err = runTypeDefinitions(os.Stdin, os.Stdout)
} else {
err = run(os.Stdin, os.Stdout)
}
}
if err != nil {
log.Error("codegen failed", "err", err)
os.Exit(1)
}

View file

@ -1,9 +1,14 @@
//go:build !js
package main
import (
"bytes"
"context"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -23,6 +28,20 @@ func TestRun_Good(t *testing.T) {
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
}
func TestRunTypeDefinitions_Good(t *testing.T) {
input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
var output bytes.Buffer
err := runTypeDefinitions(input, &output)
require.NoError(t, err)
dts := output.String()
assert.Contains(t, dts, "declare global")
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.Contains(t, dts, `"main-content": MainContent;`)
assert.Contains(t, dts, "export {};")
}
func TestRun_Bad_InvalidJSON(t *testing.T) {
input := strings.NewReader(`not json`)
var output bytes.Buffer
@ -41,6 +60,29 @@ func TestRun_Bad_InvalidTag(t *testing.T) {
assert.Contains(t, err.Error(), "hyphen")
}
func TestRun_Bad_InvalidSlotKey(t *testing.T) {
input := strings.NewReader(`{"X":"nav-bar"}`)
var output bytes.Buffer
err := run(input, &output)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid slot key")
assert.Contains(t, err.Error(), `"X"`)
}
func TestRunTypeDefinitions_SkipsInvalidTags(t *testing.T) {
input := strings.NewReader(`{"H":"nav-bar","C":"Nav-Bar","F":"nav bar"}`)
var output bytes.Buffer
err := runTypeDefinitions(input, &output)
require.NoError(t, err)
dts := output.String()
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.NotContains(t, dts, "Nav-Bar")
assert.NotContains(t, dts, "nav bar")
}
func TestRun_Good_Empty(t *testing.T) {
input := strings.NewReader(`{}`)
var output bytes.Buffer
@ -49,3 +91,106 @@ func TestRun_Good_Empty(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, output.String())
}
func TestRunDaemon_WritesBundle(t *testing.T) {
dir := t.TempDir()
inputPath := dir + "/slots.json"
outputPath := dir + "/bundle.js"
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
}()
require.Eventually(t, func() bool {
got, err := readTestFile(outputPath)
if err != nil {
return false
}
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
}, time.Second, 10*time.Millisecond)
cancel()
require.NoError(t, <-done)
}
func TestRunDaemon_RecoversFromInvalidJSON(t *testing.T) {
dir := t.TempDir()
inputPath := dir + "/slots.json"
outputPath := dir + "/bundle.js"
require.NoError(t, writeTestFile(inputPath, `not json`))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
}()
time.Sleep(20 * time.Millisecond)
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
require.Eventually(t, func() bool {
got, err := readTestFile(outputPath)
if err != nil {
return false
}
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
}, time.Second, 10*time.Millisecond)
cancel()
require.NoError(t, <-done)
}
func TestRunDaemon_RecoversFromMissingInputFile(t *testing.T) {
dir := t.TempDir()
inputPath := dir + "/slots.json"
outputPath := dir + "/bundle.js"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
}()
time.Sleep(20 * time.Millisecond)
require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
require.Eventually(t, func() bool {
got, err := readTestFile(outputPath)
if err != nil {
return false
}
return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
}, time.Second, 10*time.Millisecond)
cancel()
require.NoError(t, <-done)
}
func TestRunDaemon_MissingPaths(t *testing.T) {
err := runDaemon(context.Background(), "", "", false, time.Millisecond)
require.Error(t, err)
assert.Contains(t, err.Error(), "watch mode requires -input")
}
func writeTestFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
func readTestFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}

83
cmd/wasm/components.go Normal file
View file

@ -0,0 +1,83 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import (
"strconv"
"strings"
)
var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"}
var reservedCustomElementTags = map[string]struct{}{
"annotation-xml": {},
"color-profile": {},
"font-face": {},
"font-face-src": {},
"font-face-uri": {},
"font-face-format": {},
"font-face-name": {},
"missing-glyph": {},
}
// cmd/wasm/components.go: isValidCustomElementTag reports whether tag is a safe
// custom element name.
// It mirrors the codegen package validation without importing the heavier
// template and logging dependencies into the WASM-linked path.
func isValidCustomElementTag(tag string) bool {
if tag == "" || !strings.Contains(tag, "-") {
return false
}
if tag[0] < 'a' || tag[0] > 'z' {
return false
}
if _, reserved := reservedCustomElementTags[tag]; reserved {
return false
}
for i := range len(tag) {
ch := tag[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
case ch == '-' || ch == '.' || ch == '_':
default:
return false
}
}
return true
}
// cmd/wasm/components.go: tagToClassName converts a kebab-case tag into a
// PascalCase class name.
// Example: tagToClassName("nav-bar") returns NavBar.
func tagToClassName(tag string) string {
var b strings.Builder
for part := range strings.SplitSeq(tag, "-") {
if len(part) == 0 {
continue
}
b.WriteString(strings.ToUpper(part[:1]))
b.WriteString(part[1:])
}
return b.String()
}
// cmd/wasm/components.go: jsStringLiteral returns a quoted JavaScript string literal.
func jsStringLiteral(s string) string {
return strconv.Quote(s)
}
// cmd/wasm/components.go: customElementClassSource returns a JavaScript class
// expression that mirrors the codegen bundle's closed-shadow custom element
// behaviour.
func customElementClassSource(tag, slot string) string {
className := tagToClassName(tag)
return "class " + className + " extends HTMLElement {" +
"#shadow;" +
"constructor(){super();this.#shadow=this.attachShadow({mode:\"closed\"});}" +
"connectedCallback(){this.#shadow.textContent=\"\";const slot=this.getAttribute(\"data-slot\")||" + jsStringLiteral(slot) + ";" +
"this.dispatchEvent(new CustomEvent(\"wc-ready\",{detail:{tag:" + jsStringLiteral(tag) + ",slot},bubbles:true,composed:true}));}" +
"render(html){const tpl=document.createElement(\"template\");tpl.insertAdjacentHTML(\"afterbegin\",html);" +
"this.#shadow.textContent=\"\";this.#shadow.appendChild(tpl.content.cloneNode(true));}" +
"}"
}

View file

@ -16,43 +16,75 @@ func renderToString(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return ""
}
variant := args[0].String()
ctx := html.NewContext()
if len(args) >= 2 {
ctx.Locale = args[1].String()
if args[0].Type() != js.TypeString {
return ""
}
layout := html.NewLayout(variant)
variant := args[0].String()
locale := ""
if len(args) >= 2 && args[1].Type() == js.TypeString {
locale = args[1].String()
}
slots := make(map[string]html.Node)
if len(args) >= 3 && args[2].Type() == js.TypeObject {
slots := args[2]
jsSlots := args[2]
for _, slot := range []string{"H", "L", "C", "R", "F"} {
content := slots.Get(slot)
content := jsSlots.Get(slot)
if content.Type() == js.TypeString && content.String() != "" {
switch slot {
case "H":
layout.H(html.Raw(content.String()))
case "L":
layout.L(html.Raw(content.String()))
case "C":
layout.C(html.Raw(content.String()))
case "R":
layout.R(html.Raw(content.String()))
case "F":
layout.F(html.Raw(content.String()))
}
slots[slot] = html.Raw(content.String())
}
}
}
return layout.Render(ctx)
return renderLayout(variant, locale, slots)
}
// registerComponents defines custom elements from the HLCRF slot map.
// The input mirrors the codegen slot mapping: keys are HLCRF slot letters and
// values are custom element tag names.
func registerComponents(_ js.Value, args []js.Value) any {
if len(args) < 1 || args[0].Type() != js.TypeObject {
return 0
}
slots := args[0]
customElements := js.Global().Get("customElements")
seenTags := make(map[string]struct{}, len(canonicalSlotOrder))
registered := 0
for _, slot := range canonicalSlotOrder {
content := slots.Get(slot)
if content.Type() != js.TypeString {
continue
}
tag := content.String()
if !isValidCustomElementTag(tag) {
continue
}
if _, seen := seenTags[tag]; seen {
continue
}
if existing := customElements.Call("get", tag); existing.Truthy() {
seenTags[tag] = struct{}{}
continue
}
factory := js.Global().Get("Function").New("return " + customElementClassSource(tag, slot) + ";")
ctor := factory.Invoke()
customElements.Call("define", tag, ctor)
seenTags[tag] = struct{}{}
registered++
}
return registered
}
func main() {
js.Global().Set("gohtml", js.ValueOf(map[string]any{
"renderToString": js.FuncOf(renderToString),
"renderToString": js.FuncOf(renderToString),
"registerComponents": js.FuncOf(registerComponents),
}))
select {}

55
cmd/wasm/main_test.go Normal file
View file

@ -0,0 +1,55 @@
//go:build js && wasm
package main
import (
"testing"
"syscall/js"
)
func TestRenderToString_Good(t *testing.T) {
gotAny := renderToString(js.Value{}, []js.Value{
js.ValueOf("C"),
js.ValueOf("en-GB"),
js.ValueOf(map[string]any{"C": "<strong>hello</strong>"}),
})
got, ok := gotAny.(string)
if !ok {
t.Fatalf("renderToString should return string, got %T", gotAny)
}
want := `<main role="main" data-block="C-0"><strong>hello</strong></main>`
if got != want {
t.Fatalf("renderToString(...) = %q, want %q", got, want)
}
}
func TestRenderToString_VariantTypeGuard(t *testing.T) {
if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
t.Fatalf("non-string variant should be empty, got %q", got)
}
if got := renderToString(js.Value{}, []js.Value{}); got != "" {
t.Fatalf("missing variant should be empty, got %q", got)
}
}
func TestRenderToString_LocaleTypeGuard(t *testing.T) {
gotAny := renderToString(js.Value{}, []js.Value{
js.ValueOf("C"),
js.ValueOf(123),
js.ValueOf(map[string]any{"C": "x"}),
})
got, ok := gotAny.(string)
if !ok {
t.Fatalf("renderToString should return string, got %T", gotAny)
}
want := `<main role="main" data-block="C-0">x</main>`
if got != want {
t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
}
}

View file

@ -9,7 +9,8 @@ import (
log "dappco.re/go/core/log"
)
// buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
// cmd/wasm/register.go: buildComponentJS takes a JSON slot map and returns the WC bundle JS string.
// Example: js, err := buildComponentJS(`{"H":"site-header","C":"app-content"}`).
// This is the pure-Go part testable without WASM.
// Excluded from WASM builds — encoding/json and text/template are too heavy.
// Use cmd/codegen/ CLI instead for build-time generation.

View file

@ -22,3 +22,55 @@ func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
_, err := buildComponentJS("not json")
assert.Error(t, err)
}
func TestTagToClassName(t *testing.T) {
tests := []struct {
tag string
want string
}{
{tag: "nav-bar", want: "NavBar"},
{tag: "my-super-widget", want: "MySuperWidget"},
{tag: "x", want: "X"},
}
for _, tt := range tests {
t.Run(tt.tag, func(t *testing.T) {
assert.Equal(t, tt.want, tagToClassName(tt.tag))
})
}
}
func TestIsValidCustomElementTag(t *testing.T) {
tests := []struct {
tag string
want bool
}{
{tag: "nav-bar", want: true},
{tag: "main-content", want: true},
{tag: "NavBar", want: false},
{tag: "nav", want: false},
{tag: "nav_bar", want: false},
{tag: "annotation-xml", want: false},
}
for _, tt := range tests {
t.Run(tt.tag, func(t *testing.T) {
assert.Equal(t, tt.want, isValidCustomElementTag(tt.tag))
})
}
}
func TestCustomElementClassSource(t *testing.T) {
src := customElementClassSource(`nav-bar`, `H`)
assert.Contains(t, src, `class NavBar extends HTMLElement`)
assert.Contains(t, src, `#shadow;`)
assert.Contains(t, src, `mode:"closed"`)
assert.Contains(t, src, `#shadow`)
assert.Contains(t, src, `data-slot`)
assert.Contains(t, src, `wc-ready`)
assert.Contains(t, src, `bubbles:true`)
assert.Contains(t, src, `composed:true`)
assert.Contains(t, src, `nav-bar`)
assert.Contains(t, src, `H`)
}

35
cmd/wasm/render_shared.go Normal file
View file

@ -0,0 +1,35 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import html "dappco.re/go/core/html"
// renderLayout builds an HLCRF layout from slot nodes and renders it.
// The helper is shared by the JS entrypoint and host-side tests so the
// slot-to-layout mapping stays covered outside the wasm build.
func renderLayout(variant, locale string, slots map[string]html.Node) string {
ctx := html.NewContext(locale)
layout := html.NewLayout(variant)
for _, slot := range canonicalSlotOrder {
node, ok := slots[slot]
if !ok || node == nil {
continue
}
switch slot {
case "H":
layout.H(node)
case "L":
layout.L(node)
case "C":
layout.C(node)
case "R":
layout.R(node)
case "F":
layout.F(node)
}
}
return layout.Render(ctx)
}

View file

@ -0,0 +1,49 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package main
import (
"testing"
html "dappco.re/go/core/html"
)
func TestRenderLayout_RendersSlotsInVariantOrder(t *testing.T) {
got := renderLayout("HCF", "en-GB", map[string]html.Node{
"H": html.Raw("head"),
"C": html.Raw("body"),
"F": html.Raw("foot"),
})
want := `<header role="banner" data-block="H-0">head</header>` +
`<main role="main" data-block="C-0">body</main>` +
`<footer role="contentinfo" data-block="F-0">foot</footer>`
if got != want {
t.Fatalf("renderLayout() = %q, want %q", got, want)
}
}
func TestRenderLayout_UsesLocaleAwareTextNodes(t *testing.T) {
got := renderLayout("C", "fr-FR", map[string]html.Node{
"C": html.El("p", html.Text("prompt.yes")),
})
want := `<main role="main" data-block="C-0"><p>o</p></main>`
if got != want {
t.Fatalf("renderLayout() with locale = %q, want %q", got, want)
}
}
func TestRenderLayout_IgnoresMissingAndUnknownSlots(t *testing.T) {
got := renderLayout("C", "en-GB", map[string]html.Node{
"C": html.Raw("content"),
"X": html.Raw("ignored"),
})
want := `<main role="main" data-block="C-0">content</main>`
if got != want {
t.Fatalf("renderLayout() with unknown slots = %q, want %q", got, want)
}
}

View file

@ -1,13 +1,57 @@
package codegen
import (
"fmt"
"slices"
"strconv"
"strings"
"text/template"
log "dappco.re/go/core/log"
)
// isValidCustomElementTag reports whether tag is a safe custom element name.
// The generator rejects values that would fail at customElements.define() time.
var reservedCustomElementTags = map[string]struct{}{
"annotation-xml": {},
"color-profile": {},
"font-face": {},
"font-face-src": {},
"font-face-uri": {},
"font-face-format": {},
"font-face-name": {},
"missing-glyph": {},
}
func isReservedCustomElementTag(tag string) bool {
_, reserved := reservedCustomElementTags[tag]
return reserved
}
func isValidCustomElementTag(tag string) bool {
if tag == "" || !strings.Contains(tag, "-") {
return false
}
if tag[0] < 'a' || tag[0] > 'z' {
return false
}
if isReservedCustomElementTag(tag) {
return false
}
for i := range len(tag) {
ch := tag[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
case ch == '-' || ch == '.' || ch == '_':
default:
return false
}
}
return true
}
// wcTemplate is the Web Component class template.
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
// DOM API using trusted go-html codegen output (never user input).
@ -20,7 +64,7 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
connectedCallback() {
this.#shadow.textContent = "";
const slot = this.getAttribute("data-slot") || "{{.Slot}}";
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot } }));
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot }, bubbles: true, composed: true }));
}
render(html) {
const tpl = document.createElement("template");
@ -30,10 +74,16 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
}
}`))
// GenerateClass produces a JS class definition for a custom element.
// codegen/codegen.go: GenerateClass produces a JS class definition for a custom element.
//
// Example: cls, err := GenerateClass("nav-bar", "H")
func GenerateClass(tag, slot string) (string, error) {
if !strings.Contains(tag, "-") {
return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil)
if !isValidCustomElementTag(tag) {
message := "custom element tag must be a lowercase hyphenated name: " + tag
if isReservedCustomElementTag(tag) {
message = "custom element tag is reserved by the Web Components spec: " + tag
}
return "", log.E("codegen.GenerateClass", message, nil)
}
var b strings.Builder
err := wcTemplate.Execute(&b, struct {
@ -49,12 +99,22 @@ func GenerateClass(tag, slot string) (string, error) {
return b.String(), nil
}
// GenerateRegistration produces the customElements.define() call.
// codegen/codegen.go: GenerateRegistration produces the customElements.define() call.
//
// Example: GenerateRegistration("nav-bar", "NavBar")
func GenerateRegistration(tag, className string) string {
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
var b strings.Builder
b.WriteString(`customElements.define("`)
b.WriteString(tag)
b.WriteString(`", `)
b.WriteString(className)
b.WriteString(`);`)
return b.String()
}
// TagToClassName converts a kebab-case tag to PascalCase class name.
// codegen/codegen.go: TagToClassName converts a kebab-case tag to PascalCase class name.
//
// Example: className := TagToClassName("nav-bar") // NavBar
func TagToClassName(tag string) string {
var b strings.Builder
for p := range strings.SplitSeq(tag, "-") {
@ -66,26 +126,137 @@ func TagToClassName(tag string) string {
return b.String()
}
// GenerateBundle produces all WC class definitions and registrations
// codegen/codegen.go: GenerateBundle produces all WC class definitions and registrations
// for a set of HLCRF slot assignments.
//
// Example: js, err := GenerateBundle(map[string]string{"H":"nav-bar", "C":"main-content"})
func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool)
if err := validateSlotKeys(slots); err != nil {
return "", err
}
var b strings.Builder
for slot, tag := range slots {
if seen[tag] {
continue
}
seen[tag] = true
cls, err := GenerateClass(tag, slot)
for _, entry := range orderedSlotEntries(slots) {
cls, err := GenerateClass(entry.Tag, entry.Slot)
if err != nil {
return "", err
}
b.WriteString(cls)
b.WriteByte('\n')
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
b.WriteString(GenerateRegistration(entry.Tag, TagToClassName(entry.Tag)))
b.WriteByte('\n')
}
return b.String(), nil
}
// codegen/codegen.go: GenerateTypeDefinitions produces a TypeScript declaration file for the
// generated custom elements.
//
// Example: dts, err := GenerateTypeDefinitions(map[string]string{"H":"nav-bar"})
func GenerateTypeDefinitions(slots map[string]string) (string, error) {
if err := validateSlotKeys(slots); err != nil {
return "", err
}
entries := orderedSlotEntries(slots)
var b strings.Builder
for _, entry := range entries {
if !isValidCustomElementTag(entry.Tag) {
continue
}
className := TagToClassName(entry.Tag)
b.WriteString("export declare class ")
b.WriteString(className)
b.WriteString(" extends HTMLElement {\n")
b.WriteString(" connectedCallback(): void;\n")
b.WriteString(" render(html: string): void;\n")
b.WriteString("}\n")
}
b.WriteString("\ndeclare global {\n")
b.WriteString(" interface WcReadyDetail {\n")
b.WriteString(" tag: string;\n")
b.WriteString(" slot: string;\n")
b.WriteString(" }\n")
b.WriteString(" interface HTMLElementEventMap {\n")
b.WriteString(` "wc-ready": CustomEvent<WcReadyDetail>;` + "\n")
b.WriteString(" }\n")
b.WriteString(" interface HTMLElementTagNameMap {\n")
for _, entry := range entries {
if !isValidCustomElementTag(entry.Tag) {
continue
}
className := TagToClassName(entry.Tag)
b.WriteString(` "`)
b.WriteString(entry.Tag)
b.WriteString(`": `)
b.WriteString(className)
b.WriteString(";\n")
}
b.WriteString(" }\n")
b.WriteString("}\n")
b.WriteString("\nexport {};\n")
return b.String(), nil
}
type slotEntry struct {
Slot string
Tag string
}
var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"}
const validSlotKeys = "H, L, C, R, F"
func validateSlotKeys(slots map[string]string) error {
if len(slots) == 0 {
return nil
}
valid := map[string]struct{}{
"H": {},
"L": {},
"C": {},
"R": {},
"F": {},
}
var invalid []string
for slot := range slots {
if _, ok := valid[slot]; ok {
continue
}
invalid = append(invalid, slot)
}
if len(invalid) == 0 {
return nil
}
slices.Sort(invalid)
quoted := make([]string, 0, len(invalid))
for _, slot := range invalid {
quoted = append(quoted, strconv.Quote(slot))
}
return log.E("codegen", "invalid slot key(s): "+strings.Join(quoted, ", ")+"; valid keys: "+validSlotKeys, nil)
}
func orderedSlotEntries(slots map[string]string) []slotEntry {
entries := make([]slotEntry, 0, len(slots))
seenTags := make(map[string]struct{}, len(slots))
for _, slot := range canonicalSlotOrder {
tag, ok := slots[slot]
if !ok {
continue
}
if _, seen := seenTags[tag]; seen {
continue
}
seenTags[tag] = struct{}{}
entries = append(entries, slotEntry{Slot: slot, Tag: tag})
}
return entries
}

View file

@ -14,12 +14,23 @@ func TestGenerateClass_Good(t *testing.T) {
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
assert.Contains(t, js, "attachShadow")
assert.Contains(t, js, `mode: "closed"`)
assert.Contains(t, js, `bubbles: true`)
assert.Contains(t, js, `composed: true`)
assert.Contains(t, js, "photo-grid")
}
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
_, err := GenerateClass("invalid", "C")
assert.Error(t, err, "custom element names must contain a hyphen")
_, err = GenerateClass("Nav-Bar", "C")
assert.Error(t, err, "custom element names must be lowercase")
_, err = GenerateClass("nav bar", "C")
assert.Error(t, err, "custom element names must reject spaces")
_, err = GenerateClass("annotation-xml", "C")
assert.Error(t, err, "reserved custom element names must be rejected")
}
func TestGenerateRegistration_Good(t *testing.T) {
@ -43,12 +54,122 @@ func TestTagToClassName_Good(t *testing.T) {
func TestGenerateBundle_Good(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "main-content",
"H": "nav-bar",
"F": "page-footer",
}
js, err := GenerateBundle(slots)
require.NoError(t, err)
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
assert.Contains(t, js, "PageFooter")
assert.Equal(t, 3, strings.Count(js, "extends HTMLElement"))
h := strings.Index(js, "NavBar")
c := strings.Index(js, "MainContent")
f := strings.Index(js, "PageFooter")
assert.True(t, h >= 0 && c >= 0 && f >= 0, "expected all generated classes in output")
assert.True(t, h < c && c < f, "expected canonical HLCRF order in generated bundle")
}
func TestGenerateBundle_DeduplicatesTags(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "nav-bar",
"F": "page-footer",
}
js, err := GenerateBundle(slots)
require.NoError(t, err)
assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
assert.Equal(t, 1, strings.Count(js, "class NavBar extends HTMLElement"))
assert.Equal(t, 1, strings.Count(js, "class PageFooter extends HTMLElement"))
}
func TestGenerateBundle_Bad_InvalidSlotKey(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"X": "custom-widget",
}
_, err := GenerateBundle(slots)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid slot key")
assert.Contains(t, err.Error(), `"X"`)
assert.Contains(t, err.Error(), "valid keys: H, L, C, R, F")
}
func TestGenerateBundle_Bad_ReservedTag(t *testing.T) {
slots := map[string]string{
"H": "annotation-xml",
}
_, err := GenerateBundle(slots)
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved")
}
func TestGenerateTypeDefinitions_Good(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "main-content",
}
dts, err := GenerateTypeDefinitions(slots)
require.NoError(t, err)
assert.Contains(t, dts, "declare global")
assert.Contains(t, dts, "interface WcReadyDetail")
assert.Contains(t, dts, `interface HTMLElementEventMap`)
assert.Contains(t, dts, `"wc-ready": CustomEvent<WcReadyDetail>;`)
assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.Contains(t, dts, `"main-content": MainContent;`)
assert.Contains(t, dts, "export {};")
}
func TestGenerateTypeDefinitions_DeduplicatesTags(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "nav-bar",
"F": "page-footer",
}
dts, err := GenerateTypeDefinitions(slots)
require.NoError(t, err)
assert.Equal(t, 2, strings.Count(dts, "extends HTMLElement"))
assert.Equal(t, 1, strings.Count(dts, `"nav-bar": NavBar;`))
assert.Equal(t, 1, strings.Count(dts, `"page-footer": PageFooter;`))
}
func TestGenerateTypeDefinitions_SkipsInvalidTags(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "Nav-Bar",
"F": "nav bar",
}
dts, err := GenerateTypeDefinitions(slots)
require.NoError(t, err)
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.NotContains(t, dts, "Nav-Bar")
assert.NotContains(t, dts, "nav bar")
assert.Equal(t, 1, strings.Count(dts, "extends HTMLElement"))
}
func TestGenerateTypeDefinitions_SkipsReservedTags(t *testing.T) {
slots := map[string]string{
"H": "annotation-xml",
"C": "nav-bar",
}
dts, err := GenerateTypeDefinitions(slots)
require.NoError(t, err)
assert.Contains(t, dts, `"nav-bar": NavBar;`)
assert.NotContains(t, dts, "annotation-xml")
assert.Equal(t, 1, strings.Count(dts, "extends HTMLElement"))
}

View file

@ -1,27 +1,232 @@
package html
import i18n "dappco.re/go/core/i18n"
// context.go: Translator provides Text() lookups for a rendering context.
// Example: type service struct{}
//
// func (service) T(key string, args ...any) string { return key }
//
// ctx := NewContextWithService(service{}, "en-GB")
type Translator interface {
T(key string, args ...any) string
}
// Context carries rendering state through the node tree.
type translatorCloner interface {
Clone() Translator
}
// context.go: Context carries rendering state through the node tree.
// Example: NewContext("en-GB") initialises locale-specific rendering state.
// Locale and translator selection are managed through dedicated setters.
type Context struct {
Identity string
Locale string
Entitlements func(feature string) bool
Data map[string]any
service *i18n.Service
service Translator
}
// NewContext creates a new rendering context with sensible defaults.
func NewContext() *Context {
return &Context{
Data: make(map[string]any),
// Clone returns a shallow copy of the context with an independent Data map.
// Example: next := ctx.Clone().SetData("theme", "dark").
func (ctx *Context) Clone() *Context {
if ctx == nil {
return nil
}
clone := *ctx
if ctx.Data != nil {
clone.Data = make(map[string]any, len(ctx.Data))
for key, value := range ctx.Data {
clone.Data[key] = value
}
}
clone.service = cloneTranslator(clone.service, clone.Locale)
return &clone
}
func applyLocaleToService(svc Translator, locale string) {
if svc == nil || locale == "" {
return
}
if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
base := locale
for i := 0; i < len(base); i++ {
if base[i] == '-' || base[i] == '_' {
base = base[:i]
break
}
}
_ = setter.SetLanguage(base)
}
}
// NewContextWithService creates a rendering context backed by a specific i18n service.
func NewContextWithService(svc *i18n.Service) *Context {
return &Context{
// ensureContextDefaults initialises lazily-created context fields.
func ensureContextDefaults(ctx *Context) {
if ctx == nil {
return
}
if ctx.Data == nil {
ctx.Data = make(map[string]any)
}
}
// normaliseContext ensures render paths always have a usable context.
// A nil input is replaced with a fresh default context.
func normaliseContext(ctx *Context) *Context {
if ctx != nil {
ensureContextDefaults(ctx)
return ctx
}
return NewContext()
}
// context.go: NewContext creates a new rendering context with sensible defaults.
// Example: ctx := NewContext("en-GB").
// An optional locale may be provided as the first argument.
func NewContext(locale ...string) *Context {
ctx := &Context{
Data: make(map[string]any),
service: newDefaultTranslator(),
}
if len(locale) > 0 {
ctx.Locale = locale[0]
applyLocaleToService(ctx.service, ctx.Locale)
}
return ctx
}
// context.go: NewContextWithService creates a rendering context backed by a specific translator.
// Example: ctx := NewContextWithService(svc, "fr-FR").
// An optional locale may be provided as the second argument.
func NewContextWithService(svc Translator, locale ...string) *Context {
ctx := &Context{
Data: make(map[string]any),
service: svc,
}
if len(locale) > 0 {
ctx.Locale = locale[0]
}
applyLocaleToService(ctx.service, ctx.Locale)
return ctx
}
// SetService swaps the translator used by the context and reapplies the
// current locale to it.
// Example: ctx.SetService(svc).
func (ctx *Context) SetService(svc Translator) *Context {
if ctx == nil {
return nil
}
ensureContextDefaults(ctx)
ctx.service = svc
applyLocaleToService(ctx.service, ctx.Locale)
return ctx
}
// SetEntitlements updates the feature gate callback used by Entitled nodes and
// returns the same context.
// Example: ctx.SetEntitlements(func(feature string) bool { return feature == "premium" }).
func (ctx *Context) SetEntitlements(entitlements func(feature string) bool) *Context {
if ctx == nil {
return nil
}
ensureContextDefaults(ctx)
ctx.Entitlements = entitlements
return ctx
}
// SetIdentity updates the context identity and returns the same context.
// Example: ctx.SetIdentity("user-123").
func (ctx *Context) SetIdentity(identity string) *Context {
if ctx == nil {
return nil
}
ensureContextDefaults(ctx)
ctx.Identity = identity
return ctx
}
// SetData stores an arbitrary per-request value on the context and returns the
// same context.
// Example: ctx.SetData("theme", "dark").
func (ctx *Context) SetData(key string, value any) *Context {
if ctx == nil {
return nil
}
ensureContextDefaults(ctx)
ctx.Data[key] = value
return ctx
}
// WithData returns a cloned context with one additional data value set.
// Example: next := ctx.WithData("theme", "dark").
func (ctx *Context) WithData(key string, value any) *Context {
clone := ctx.Clone()
if clone == nil {
return nil
}
clone.SetData(key, value)
return clone
}
// WithIdentity returns a cloned context with a different identity value.
// Example: next := ctx.WithIdentity("user-123").
func (ctx *Context) WithIdentity(identity string) *Context {
clone := ctx.Clone()
if clone == nil {
return nil
}
clone.SetIdentity(identity)
return clone
}
// WithLocale returns a cloned context with a different locale value.
// Example: next := ctx.WithLocale("fr-FR").
func (ctx *Context) WithLocale(locale string) *Context {
clone := ctx.Clone()
if clone == nil {
return nil
}
clone.SetLocale(locale)
return clone
}
// WithService returns a cloned context with a different translator.
// Example: next := ctx.WithService(svc).
func (ctx *Context) WithService(svc Translator) *Context {
clone := ctx.Clone()
if clone == nil {
return nil
}
clone.SetService(svc)
return clone
}
// WithEntitlements returns a cloned context with a different feature gate callback.
// Example: next := ctx.WithEntitlements(func(feature string) bool { return feature == "premium" }).
func (ctx *Context) WithEntitlements(entitlements func(feature string) bool) *Context {
clone := ctx.Clone()
if clone == nil {
return nil
}
clone.SetEntitlements(entitlements)
return clone
}
// SetLocale updates the context locale and reapplies it to the active
// translator.
// Example: ctx.SetLocale("en-US").
func (ctx *Context) SetLocale(locale string) *Context {
if ctx == nil {
return nil
}
ensureContextDefaults(ctx)
ctx.Locale = locale
applyLocaleToService(ctx.service, ctx.Locale)
return ctx
}

450
context_test.go Normal file
View file

@ -0,0 +1,450 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
import (
"testing"
i18n "dappco.re/go/core/i18n"
"github.com/stretchr/testify/require"
)
type localeTranslator struct {
language string
}
func (t *localeTranslator) T(key string, args ...any) string {
if key == "prompt.yes" && t.language == "fr" {
return "o"
}
if key == "prompt.yes" && t.language == "en" {
return "y"
}
return key
}
func (t *localeTranslator) SetLanguage(language string) error {
t.language = language
return nil
}
func (t *localeTranslator) Clone() Translator {
if t == nil {
return (*localeTranslator)(nil)
}
clone := *t
return &clone
}
type resettingTranslator struct {
language string
}
func (t *resettingTranslator) T(key string, args ...any) string {
if key == "prompt.yes" && t.language == "fr" {
return "o"
}
if key == "prompt.yes" && t.language == "en" {
return "y"
}
return key
}
func (t *resettingTranslator) SetLanguage(language string) error {
t.language = language
return nil
}
func (t *resettingTranslator) Clone() Translator {
if t == nil {
return (*resettingTranslator)(nil)
}
return &resettingTranslator{}
}
func TestContext_NewContextWithService_AppliesLocale(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "fr-FR")
if svc.language != "fr" {
t.Fatalf("NewContextWithService should apply locale to translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
}
}
func TestContext_NewContext_AppliesLocaleToDefaultService(t *testing.T) {
ctx := NewContext("fr-FR")
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("NewContext(locale) translation = %q, want %q", got, "o")
}
}
func TestContext_NewContextWithService_UsesLocale(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
if svc.language != "en" {
t.Fatalf("NewContextWithService should apply locale to translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(ctx); got != "y" {
t.Fatalf("NewContextWithService translation = %q, want %q", got, "y")
}
}
func TestContext_SetLocale_ReappliesToTranslator(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
ctx.SetLocale("fr-FR")
if ctx.Locale != "fr-FR" {
t.Fatalf("SetLocale should update context locale, got %q", ctx.Locale)
}
if svc.language != "fr" {
t.Fatalf("SetLocale should reapply locale to translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("SetLocale translation = %q, want %q", got, "o")
}
}
func TestContext_SetService_ReappliesCurrentLocale(t *testing.T) {
ctx := NewContext("fr-FR")
svc := &localeTranslator{}
if got := ctx.SetService(svc); got != ctx {
t.Fatalf("SetService should return the same context")
}
if ctx.service != svc {
t.Fatalf("SetService should replace the active translator")
}
if svc.language != "fr" {
t.Fatalf("SetService should apply the existing locale to the new translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("SetService translation = %q, want %q", got, "o")
}
}
func TestContext_SetEntitlements_UpdatesFeatureGate(t *testing.T) {
ctx := NewContext()
if got := ctx.SetEntitlements(func(feature string) bool { return feature == "premium" }); got != ctx {
t.Fatalf("SetEntitlements should return the same context")
}
if ctx.Entitlements == nil {
t.Fatal("SetEntitlements should store the callback")
}
if !ctx.Entitlements("premium") {
t.Fatal("SetEntitlements should preserve the requested callback")
}
}
func TestContext_SetIdentity_UpdatesIdentity(t *testing.T) {
ctx := NewContext()
if got := ctx.SetIdentity("user-123"); got != ctx {
t.Fatalf("SetIdentity should return the same context")
}
if ctx.Identity != "user-123" {
t.Fatalf("SetIdentity should update context identity, got %q", ctx.Identity)
}
if ctx.Data == nil {
t.Fatal("SetIdentity should preserve initialised Data map")
}
}
func TestContext_SetData_StoresValue(t *testing.T) {
ctx := NewContext()
if got := ctx.SetData("theme", "dark"); got != ctx {
t.Fatalf("SetData should return the same context")
}
if got := ctx.Data["theme"]; got != "dark" {
t.Fatalf("SetData should store the requested value, got %v", got)
}
}
func TestContext_SetData_InitialisesNilMap(t *testing.T) {
ctx := &Context{}
ctx.SetData("theme", "light")
if ctx.Data == nil {
t.Fatal("SetData should initialise the Data map on demand")
}
if got := ctx.Data["theme"]; got != "light" {
t.Fatalf("SetData should store the requested value in a nil map context, got %v", got)
}
}
func TestContext_CloneCopiesDataWithoutSharingMap(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
ctx.SetIdentity("user-123")
ctx.SetData("theme", "dark")
clone := ctx.Clone()
if clone == ctx {
t.Fatal("Clone should return a distinct context instance")
}
if clone.service == ctx.service {
t.Fatal("Clone should duplicate cloneable translators")
}
if clone.Locale != ctx.Locale {
t.Fatalf("Clone should preserve locale, got %q want %q", clone.Locale, ctx.Locale)
}
if clone.Identity != ctx.Identity {
t.Fatalf("Clone should preserve identity, got %q want %q", clone.Identity, ctx.Identity)
}
clone.SetData("theme", "light")
if got := ctx.Data["theme"]; got != "dark" {
t.Fatalf("Clone should not share Data map with original, got %v", got)
}
}
func TestContext_CloneDoesNotShareDefaultTranslator(t *testing.T) {
ctx := NewContext("en-GB")
clone := ctx.Clone()
if clone == nil {
t.Fatal("Clone should return a context")
}
clone.SetLocale("fr-FR")
if got := Text("prompt.yes").Render(ctx); got != "y" {
t.Fatalf("Clone should not mutate original default translator, got %q", got)
}
if got := Text("prompt.yes").Render(clone); got != "o" {
t.Fatalf("Clone should keep its own default translator, got %q", got)
}
}
func TestContext_CloneClonesMutableTranslator(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
clone := ctx.Clone()
if clone == nil {
t.Fatal("Clone should return a context")
}
if clone.service == ctx.service {
t.Fatal("Clone should isolate cloneable translators")
}
clone.SetLocale("fr-FR")
if got := svc.language; got != "en" {
t.Fatalf("Clone should not mutate the original translator, got %q", got)
}
if got := Text("prompt.yes").Render(ctx); got != "y" {
t.Fatalf("Clone should leave original context translation unchanged, got %q", got)
}
if got := Text("prompt.yes").Render(clone); got != "o" {
t.Fatalf("Clone should reapply locale to the cloned translator, got %q", got)
}
}
func TestContext_CloneReappliesLocaleAfterTranslatorClone(t *testing.T) {
svc := &resettingTranslator{}
ctx := NewContextWithService(svc, "fr-FR")
clone := ctx.Clone()
if clone == nil {
t.Fatal("Clone should return a context")
}
if clone.service == ctx.service {
t.Fatal("Clone should duplicate cloneable translators")
}
if got := Text("prompt.yes").Render(clone); got != "o" {
t.Fatalf("Clone should reapply locale after cloning the translator, got %q", got)
}
if got := clone.Locale; got != "fr-FR" {
t.Fatalf("Clone should preserve locale, got %q", got)
}
}
func TestContext_WithDataReturnsClonedContext(t *testing.T) {
ctx := NewContext()
ctx.SetData("theme", "dark")
next := ctx.WithData("locale", "fr-FR")
if next == ctx {
t.Fatal("WithData should return a cloned context")
}
if got := ctx.Data["locale"]; got != nil {
t.Fatalf("WithData should not mutate the original context, got %v", got)
}
if got := next.Data["locale"]; got != "fr-FR" {
t.Fatalf("WithData should set the requested value on the clone, got %v", got)
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithData should preserve existing data on the clone, got %v", got)
}
}
func TestContext_WithIdentityReturnsClonedContext(t *testing.T) {
ctx := NewContext()
ctx.SetIdentity("user-001")
ctx.SetData("theme", "dark")
next := ctx.WithIdentity("user-123")
if next == ctx {
t.Fatal("WithIdentity should return a cloned context")
}
if got := ctx.Identity; got != "user-001" {
t.Fatalf("WithIdentity should not mutate the original context, got %q", got)
}
if got := next.Identity; got != "user-123" {
t.Fatalf("WithIdentity should set the requested identity on the clone, got %q", got)
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithIdentity should preserve existing data on the clone, got %v", got)
}
}
func TestContext_WithLocaleReturnsClonedContext(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
ctx.SetIdentity("user-001")
ctx.SetData("theme", "dark")
next := ctx.WithLocale("fr-FR")
if next == ctx {
t.Fatal("WithLocale should return a cloned context")
}
if got := ctx.Locale; got != "en-GB" {
t.Fatalf("WithLocale should not mutate the original context locale, got %q", got)
}
if got := next.Locale; got != "fr-FR" {
t.Fatalf("WithLocale should set the requested locale on the clone, got %q", got)
}
if got := next.service; got == ctx.service {
t.Fatal("WithLocale should duplicate cloneable translators on the clone")
}
if svc.language != "en" {
t.Fatalf("WithLocale should not mutate the original translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(next); got != "o" {
t.Fatalf("WithLocale should reapply locale to the cloned service, got %q", got)
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithLocale should preserve existing data on the clone, got %v", got)
}
}
func TestContext_WithServiceReturnsClonedContext(t *testing.T) {
ctx := NewContext("fr-FR")
ctx.SetIdentity("user-001")
ctx.SetData("theme", "dark")
svc := &localeTranslator{}
next := ctx.WithService(svc)
if next == ctx {
t.Fatal("WithService should return a cloned context")
}
if got := ctx.service; got == svc {
t.Fatal("WithService should not mutate the original context service")
}
if got := next.service; got != svc {
t.Fatalf("WithService should set the requested service on the clone, got %v", got)
}
if svc.language != "fr" {
t.Fatalf("WithService should apply the existing locale to the new translator, got %q", svc.language)
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithService should preserve existing data on the clone, got %v", got)
}
if got := next.Locale; got != "fr-FR" {
t.Fatalf("WithService should preserve the locale on the clone, got %q", got)
}
}
func TestContext_WithEntitlementsReturnsClonedContext(t *testing.T) {
ctx := NewContext()
ctx.SetIdentity("user-001")
ctx.SetData("theme", "dark")
ctx.SetEntitlements(func(feature string) bool { return feature == "basic" })
next := ctx.WithEntitlements(func(feature string) bool { return feature == "premium" })
if next == ctx {
t.Fatal("WithEntitlements should return a cloned context")
}
if ctx.Entitlements == nil {
t.Fatal("WithEntitlements should not clear the original callback")
}
if !ctx.Entitlements("basic") {
t.Fatal("WithEntitlements should preserve the original callback")
}
if next.Entitlements == nil {
t.Fatal("WithEntitlements should store the new callback on the clone")
}
if next.Entitlements("basic") {
t.Fatal("WithEntitlements should replace the callback on the clone")
}
if !next.Entitlements("premium") {
t.Fatal("WithEntitlements should preserve the new callback")
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithEntitlements should preserve existing data on the clone, got %v", got)
}
if got := next.Identity; got != "user-001" {
t.Fatalf("WithEntitlements should preserve identity on the clone, got %q", got)
}
}
func TestContext_Setters_NilReceiver(t *testing.T) {
var ctx *Context
if got := ctx.SetIdentity("user-123"); got != nil {
t.Fatalf("nil Context.SetIdentity should return nil, got %v", got)
}
if got := ctx.SetData("theme", "dark"); got != nil {
t.Fatalf("nil Context.SetData should return nil, got %v", got)
}
if got := ctx.SetLocale("en-GB"); got != nil {
t.Fatalf("nil Context.SetLocale should return nil, got %v", got)
}
if got := ctx.SetService(&localeTranslator{}); got != nil {
t.Fatalf("nil Context.SetService should return nil, got %v", got)
}
if got := ctx.SetEntitlements(func(string) bool { return true }); got != nil {
t.Fatalf("nil Context.SetEntitlements should return nil, got %v", got)
}
if got := ctx.WithLocale("en-GB"); got != nil {
t.Fatalf("nil Context.WithLocale should return nil, got %v", got)
}
if got := ctx.WithService(&localeTranslator{}); got != nil {
t.Fatalf("nil Context.WithService should return nil, got %v", got)
}
if got := ctx.WithEntitlements(func(string) bool { return true }); got != nil {
t.Fatalf("nil Context.WithEntitlements should return nil, got %v", got)
}
}
func TestText_RenderFallsBackToDefaultTranslator(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
require.NoError(t, svc.SetLanguage("fr"))
ctx := &Context{}
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("Text() fallback translation = %q, want %q", got, "o")
}
}

View file

@ -0,0 +1,11 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import i18n "dappco.re/go/core/i18n"
func newDefaultTranslator() Translator {
return &i18n.Service{}
}

46
default_translator_js.go Normal file
View file

@ -0,0 +1,46 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
type defaultTranslator struct {
language string
}
func (t *defaultTranslator) T(key string, args ...any) string {
if key == "prompt.yes" {
switch t.language {
case "fr":
return "o"
case "en":
return "y"
}
}
if len(args) == 0 {
return key
}
return key
}
func (t *defaultTranslator) SetLanguage(language string) error {
if t == nil {
return nil
}
t.language = language
return nil
}
func (t *defaultTranslator) Clone() Translator {
if t == nil {
return (*defaultTranslator)(nil)
}
clone := *t
return &clone
}
func newDefaultTranslator() Translator {
return &defaultTranslator{}
}

View file

@ -0,0 +1,15 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import "testing"
func TestDefaultTranslatorJS_AppliesLocale(t *testing.T) {
ctx := NewContext("fr-FR")
if got := Text("prompt.yes").Render(ctx); got != "o" {
t.Fatalf("Text(prompt.yes) with js default translator = %q, want %q", got, "o")
}
}

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

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

@ -0,0 +1,72 @@
package i18n
import (
"fmt"
"strings"
)
// deps/go-i18n/i18n.go: Service is a minimal translation service for local verification.
type Service struct {
language string
}
var defaultService = &Service{}
// deps/go-i18n/i18n.go: New returns a new translation service.
// Example: svc, err := New().
func New() (*Service, error) {
return &Service{}, nil
}
// deps/go-i18n/i18n.go: SetDefault sets the process-wide default service.
// Example: SetDefault(svc).
func SetDefault(svc *Service) {
if svc == nil {
svc = &Service{}
}
defaultService = svc
}
// deps/go-i18n/i18n.go: SetLanguage records the active language for locale-aware lookups.
// Example: _ = svc.SetLanguage("en-GB").
func (s *Service) SetLanguage(language string) error {
if s == nil {
return nil
}
base := language
for i := 0; i < len(base); i++ {
if base[i] == '-' || base[i] == '_' {
base = base[:i]
break
}
}
s.language = strings.ToLower(base)
return nil
}
// deps/go-i18n/i18n.go: T returns a translated string for key.
// Example: label := T("prompt.yes").
func T(key string, args ...any) string {
return defaultService.T(key, args...)
}
// deps/go-i18n/i18n.go: T returns a translated string for key.
// Example: label := svc.T("prompt.yes").
func (s *Service) T(key string, args ...any) string {
if s != nil {
switch key {
case "prompt.yes":
switch s.language {
case "fr":
return "o"
case "en":
return "y"
}
}
}
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

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

@ -0,0 +1,22 @@
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
}
// Write stores content at path, replacing any existing file.
func (localFS) Write(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}

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

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

@ -0,0 +1,25 @@
package log
import (
"errors"
"fmt"
)
// deps/go-log/log.go: E wraps an error with scope and message.
// Example: err := E("cmd/scan", "load manifest", io.EOF).
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)
}
// deps/go-log/log.go: Error writes an error-level message.
// Example: Error("manifest validation failed", "path", path).
// This stub is a no-op for tests.
func Error(msg string, _ ...any) {}
// deps/go-log/log.go: Info writes an info-level message.
// Example: Info("registry reloaded", "count", 3).
// This stub is a no-op for tests.
func Info(msg string, _ ...any) {}

View file

@ -22,7 +22,7 @@ All concrete node types are unexported structs with exported constructor functio
| Constructor | Behaviour |
|-------------|-----------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Switch`, and iterator wrappers. Returns the node for chaining. |
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
@ -32,6 +32,49 @@ All concrete node types are unexported structs with exported constructor functio
| `Switch(selector, cases)` | Renders one of several named cases based on a runtime selector function. Returns empty string when no case matches. |
| `Entitled(feature, Node)` | Renders the child only when the context's entitlement function grants the named feature. Deny-by-default: returns empty string when no entitlement function is set. |
Accessibility-oriented helpers are also provided for common attribute patterns:
- `AriaLabel(node, label)`
- `AriaDescribedBy(node, ids...)`
- `AriaLabelledBy(node, ids...)`
- `AriaControls(node, ids...)`
- `AriaHasPopup(node, popup)`
- `AriaOwns(node, ids...)`
- `AriaKeyShortcuts(node, shortcuts...)`
- `AriaCurrent(node, current)`
- `AriaBusy(node, busy)`
- `AriaLive(node, live)`
- `AriaAtomic(node, atomic)`
- `AriaDescription(node, description)`
- `AriaDetails(node, ids...)`
- `AriaErrorMessage(node, ids...)`
- `AriaRoleDescription(node, description)`
- `Role(node, role)`
- `Lang(node, locale)`
- `Dir(node, direction)`
- `Alt(node, text)`
- `Title(node, text)`
- `Placeholder(node, text)`
- `Class(node, classes...)`
- `AriaHidden(node, hidden)`
- `AriaExpanded(node, expanded)`
- `AriaDisabled(node, disabled)`
- `AriaModal(node, modal)`
- `AriaChecked(node, checked)`
- `AriaInvalid(node, invalid)`
- `AriaRequired(node, required)`
- `AriaReadOnly(node, readonly)`
- `Disabled(node, disabled)`
- `Checked(node, checked)`
- `Required(node, required)`
- `ReadOnly(node, readonly)`
- `Selected(node, selected)`
- `TabIndex(node, index)`
- `AutoFocus(node)`
- `ID(node, id)`
- `For(node, target)`
- `Autocomplete(node, value)`
### Safety Guarantees
- **XSS prevention**: `Text()` nodes always HTML-escape their output via `html.EscapeString()`. User-supplied strings passed through `Text()` cannot inject HTML.
@ -59,7 +102,7 @@ Two constructors are provided:
- `NewContext()` creates a context with sensible defaults and an empty `Data` map.
- `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance.
The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction.
The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. Callers can replace the active translator with `SetService()` or `WithService()`, which reapply the current locale to the new service.
## HLCRF Layout
@ -84,7 +127,7 @@ NewLayout("C") // content only
NewLayout("LC") // left sidebar and content
```
Slot letters not present in the variant string are ignored, even if nodes have been appended to those slots. Unrecognised characters (lowercase, digits, special characters) are silently skipped -- no error is returned.
Slot letters not present in the variant string are ignored, even if nodes have been appended to those slots. Unrecognised characters (lowercase, digits, special characters) are silently skipped during rendering, but `ValidateLayoutVariant()` and `Layout.VariantError()` report the invalid input.
### Deterministic Block IDs
@ -165,6 +208,13 @@ Each variant renders inside a `<div data-variant="name">` container. Variants re
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant.
Two helpers support CSS targeting:
```go
VariantSelector("desktop") // [data-variant="desktop"]
ScopeVariant("desktop", ".nav") // [data-variant="desktop"] .nav
```
## Grammar Pipeline (Server-Side Only)
The grammar pipeline is excluded from WASM builds via `//go:build !js` on `pipeline.go`. It bridges the rendering layer to the semantic analysis layer.
@ -208,10 +258,11 @@ A single-variant `Responsive` produces an empty score map (no pairs to compare).
## WASM Module
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function:
The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes two JavaScript functions:
```js
gohtml.renderToString(variant, locale, slots)
gohtml.registerComponents(slots)
```
**Parameters:**
@ -222,6 +273,8 @@ gohtml.renderToString(variant, locale, slots)
Slot content is injected via `Raw()`. The caller is responsible for sanitisation -- the WASM module is a rendering engine for trusted content produced server-side or by the application's own templates.
`registerComponents(slots)` accepts the same slot-map shape used by the codegen CLI and registers closed-shadow custom elements in the browser at runtime. It skips invalid or duplicate tags and mirrors the generated component lifecycle by dispatching a bubbling, composed `wc-ready` event when each element connects.
### Size Budget
The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`:
@ -256,7 +309,7 @@ The `codegen` package (`codegen/codegen.go`) generates ES2022 class definitions
1. A class extending `HTMLElement` with a private `#shadow` field.
2. `constructor()` attaching a closed shadow root (`mode: "closed"`).
3. `connectedCallback()` dispatching a `wc-ready` custom event with the tag name and slot.
3. `connectedCallback()` dispatching a bubbling, composed `wc-ready` custom event with the tag name and slot.
4. `render(html)` method that sets shadow content from a `<template>` clone.
5. A `customElements.define()` registration call.

View file

@ -145,6 +145,16 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
To run the daemon mode, point it at an input JSON file and an output bundle path:
```bash
go run ./cmd/codegen/ -watch -input slots.json -output components.js
```
Watch mode keeps polling through transient missing files and invalid JSON edits, then rewrites the output bundle once the input becomes valid again.
Add `-dts` to emit TypeScript declarations instead of JavaScript in either mode.
To test the CLI:
```bash
@ -291,6 +301,6 @@ func TestGenerateClass_Good(t *testing.T) {
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first.
- `Context.service` is unexported. Custom i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction.
- `Context.service` is unexported and fixed at construction time. Use `NewContextWithService()` to supply a custom translator, or `NewContext(locale...)` to apply a locale to the default translator up front.
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.

View file

@ -95,17 +95,17 @@ The test is skipped under `go test -short` and is guarded with `//go:build !js`.
These are not regressions; they are design choices or deferred work recorded for future consideration.
1. **Invalid layout variants are silent.** `NewLayout("XYZ")` produces empty output. No error, no warning. Adding validation would require changing the return type of `NewLayout` from `*Layout` to `(*Layout, error)`, which is a breaking API change.
1. **Invalid layout variants are reported, not fatal.** `NewLayout("XYZ")` still produces empty output at render time, but `ValidateLayoutVariant()` and `Layout.VariantError()` surface the invalid characters without changing the constructor signature.
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
4. **Context.service is private, but swappable through setters.** The i18n service remains unexported, but `SetService()` and `WithService()` let callers replace it while keeping the current locale applied.
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
5. **TypeScript definitions are generated.** `codegen.GenerateTypeDefinitions()` produces a `.d.ts` companion for the generated Web Components.
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
6. **CSS scoping helper added.** `VariantSelector()` and `ScopeVariant()` generate selectors for `data-variant` containers, making responsive variants easier to target from CSS.
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
@ -113,7 +113,8 @@ These are not regressions; they are design choices or deferred work recorded for
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
- **Accessibility helpers**`aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. Implemented via `GenerateTypeDefinitions()`.
- **Accessibility helpers**`aria-label`, `alt`, `aria-hidden`, and `tabindex` helpers. The layout has semantic HTML and ARIA roles, and the node layer now exposes common accessibility attribute shortcuts beyond `Attr()`.
- **Layout variant validation**`ValidateLayoutVariant()` and `Layout.VariantError()` report unrecognised slot characters while preserving silent render-time skipping.
- **CSS scoping helper**`VariantSelector()` and `ScopeVariant()` generate selectors for responsive variants identified by `data-variant` attributes.
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.

View file

@ -39,20 +39,20 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| Path | Purpose |
|------|---------|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled` |
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AriaControls`, `AriaHasPopup`, `AriaOwns`, `AriaKeyShortcuts`, `Alt`/`AltText`, `Title`, `Placeholder`, `Autocomplete`, `AriaBusy`, `AriaLive`, `AriaAtomic`, `AriaDescription`, `AriaDetails`, `AriaErrorMessage`, `AriaRoleDescription`, `AriaDisabled`, `AriaModal`, `AriaChecked`, `AriaInvalid`, `AriaRequired`, `AriaPressed`, `AriaSelected`, `Hidden`, `Disabled`, `Checked`, `Required`, `ReadOnly`, `Selected`, `TabIndex`, and `AutoFocus` helpers |
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers, CSS scoping helpers) |
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
| `render.go` | `Render()` convenience function |
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
| `codegen/codegen.go` | Web Component class generation and TypeScript declarations (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output, `-watch` for file polling |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` and `registerComponents()` to JavaScript |
## Key Concepts
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`).
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), plus accessibility and visibility helpers such as `AriaLabel()`, `AriaControls()`, `AriaHasPopup()`, `AriaOwns()`, `AriaKeyShortcuts()`, `AriaCurrent()`, `AriaBusy()`, `AriaLive()`, `AriaAtomic()`, `AriaDescription()`, `AriaDetails()`, `AriaErrorMessage()`, `AriaRoleDescription()`, `AriaHidden()`, `Hidden()`, `AriaDisabled()`, `AriaModal()`, `AriaChecked()`, `AriaInvalid()`, `AriaRequired()`, `AriaReadOnly()`, `Disabled()`, `Checked()`, `Required()`, `ReadOnly()`, `Selected()`, `TabIndex()`, and the common HTML attribute helpers `Alt()`, `AltText()`, `Title()`, `Placeholder()`, `Autocomplete()`, `ID()`, `For()`, `Class()`, and `AutoFocus()`.
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.
@ -60,7 +60,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
**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.
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime.
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime. It also supports `-watch` for polling an input JSON file and rewriting an output bundle in place.
## Dependencies

View file

@ -366,7 +366,7 @@ func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
func TestLayout_DuplicateVariantChars(t *testing.T) {
ctx := NewContext()
// "CCC" — C appears three times. Should render C slot content three times.
// "CCC" — C appears three times. Each occurrence should get its own block index.
layout := NewLayout("CCC").C(Raw("content"))
got := layout.Render(ctx)
@ -374,17 +374,31 @@ func TestLayout_DuplicateVariantChars(t *testing.T) {
if count != 3 {
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
}
for _, want := range []string{`data-block="C-0"`, `data-block="C-1"`, `data-block="C-2"`} {
if !strings.Contains(got, want) {
t.Errorf("CCC variant should contain %q in:\n%s", want, got)
}
}
}
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)
}
}
}

15
go.mod
View file

@ -10,18 +10,17 @@ require (
)
require (
dappco.re/go/core v0.5.0 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/text v0.35.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
dappco.re/go/core => ../../../../core/go
dappco.re/go/core/i18n => ../../../../core/go-i18n
dappco.re/go/core/io => ../../../../core/go-io
dappco.re/go/core/log => ../../../../core/go-log
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
)

12
go.sum
View file

@ -1,21 +1,21 @@
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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=

259
layout.go
View file

@ -1,10 +1,22 @@
package html
import "strings"
import (
"errors"
"maps"
"strconv"
"strings"
)
// Compile-time interface check.
var _ Node = (*Layout)(nil)
// ErrInvalidLayoutVariant reports that a layout variant string contains at
// least one unrecognised slot character.
// Example: errors.Is(ValidateLayoutVariant("HXC"), ErrInvalidLayoutVariant).
var ErrInvalidLayoutVariant = errors.New("html: invalid layout variant")
const validLayoutSlots = "H, L, C, R, F"
// slotMeta holds the semantic HTML mapping for each HLCRF slot.
type slotMeta struct {
tag string
@ -20,79 +32,203 @@ var slotRegistry = map[byte]slotMeta{
'F': {tag: "footer", role: "contentinfo"},
}
// Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
// layout.go: Layout is an HLCRF compositor. Arranges nodes into semantic HTML regions
// with deterministic path-based IDs.
// Example: NewLayout("HCF").H(Raw("head")).C(Raw("body")).F(Raw("foot")).
type Layout struct {
variant string // "HLCRF", "HCF", "C", etc.
path string // "" for root, "L-0-" for nested
slots map[byte][]Node // H, L, C, R, F → children
variant string // "HLCRF", "HCF", "C", etc.
path string // "" for root, "L-0-" for nested
slots map[byte][]Node // H, L, C, R, F → children
attrs map[string]string
variantErr error
}
// NewLayout creates a new Layout with the given variant string.
// Clone returns a deep copy of the layout tree.
// Example: next := layout.Clone().
func (l *Layout) Clone() *Layout {
if l == nil {
return nil
}
clone, ok := l.cloneNode().(*Layout)
if !ok {
return nil
}
return clone
}
// layout.go: NewLayout creates a new Layout with the given variant string.
// Example: page := NewLayout("HCF").
// The variant determines which slots are rendered (e.g., "HLCRF", "HCF", "C").
func NewLayout(variant string) *Layout {
return &Layout{
l := &Layout{
variant: variant,
slots: make(map[byte][]Node),
attrs: make(map[string]string),
}
l.variantErr = ValidateLayoutVariant(variant)
return l
}
// layout.go: ValidateLayoutVariant reports whether a layout variant string contains only
// recognised slot characters.
// Example: ValidateLayoutVariant("HCF").
func ValidateLayoutVariant(variant string) error {
var invalidSlots []byte
var invalidPositions []int
for i := range len(variant) {
if _, ok := slotRegistry[variant[i]]; ok {
continue
}
invalidSlots = append(invalidSlots, variant[i])
invalidPositions = append(invalidPositions, i)
}
if len(invalidSlots) == 0 {
return nil
}
return &LayoutVariantError{
variant: variant,
invalidSlots: invalidSlots,
invalidPositions: invalidPositions,
}
}
// H appends nodes to the Header slot.
// layout.go: H appends nodes to the Header slot.
// Example: NewLayout("HCF").H(Raw("head")).
func (l *Layout) H(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['H'] = append(l.slots['H'], nodes...)
return l
}
// L appends nodes to the Left aside slot.
// layout.go: L appends nodes to the Left aside slot.
// Example: NewLayout("HLCRF").L(Raw("nav")).
func (l *Layout) L(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['L'] = append(l.slots['L'], nodes...)
return l
}
// C appends nodes to the Content (main) slot.
// layout.go: C appends nodes to the Content (main) slot.
// Example: NewLayout("C").C(Raw("body")).
func (l *Layout) C(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['C'] = append(l.slots['C'], nodes...)
return l
}
// R appends nodes to the Right aside slot.
// layout.go: R appends nodes to the Right aside slot.
// Example: NewLayout("HLCRF").R(Raw("aside")).
func (l *Layout) R(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['R'] = append(l.slots['R'], nodes...)
return l
}
// F appends nodes to the Footer slot.
// layout.go: F appends nodes to the Footer slot.
// Example: NewLayout("HCF").F(Raw("foot")).
func (l *Layout) F(nodes ...Node) *Layout {
if l == nil {
return nil
}
l.slots['F'] = append(l.slots['F'], nodes...)
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"
func (l *Layout) setAttr(key, value string) {
if l == nil {
return
}
if l.attrs == nil {
l.attrs = make(map[string]string)
}
l.attrs[key] = value
}
// Render produces the semantic HTML for this layout.
// BlockID returns the deterministic data-block attribute value for a slot
// occurrence within this layout variant.
// Example: NewLayout("C").BlockID('C', 0) returns "C-0".
func (l *Layout) BlockID(slot byte, index int) string {
return l.path + string(slot) + "-" + strconv.Itoa(index)
}
// layout.go: VariantError reports whether the layout variant string contained any invalid
// slot characters when the layout was constructed.
// Example: NewLayout("HXC").VariantError().
func (l *Layout) VariantError() error {
if l == nil {
return nil
}
return l.variantErr
}
// layout.go: VariantValid reports whether the layout variant string contains
// only recognised slot characters.
// Example: NewLayout("HCF").VariantValid().
func (l *Layout) VariantValid() bool {
return l == nil || l.variantErr == nil
}
func (l *Layout) cloneNode() Node {
if l == nil {
return (*Layout)(nil)
}
clone := *l
if l.attrs != nil {
clone.attrs = maps.Clone(l.attrs)
}
if l.slots != nil {
clone.slots = make(map[byte][]Node, len(l.slots))
for slot, children := range l.slots {
clonedChildren := make([]Node, len(children))
for i := range children {
clonedChildren[i] = cloneNode(children[i])
}
clone.slots[slot] = clonedChildren
}
}
return &clone
}
// layout.go: Render produces the semantic HTML for this layout.
// Example: NewLayout("C").C(Raw("body")).Render(NewContext()).
// Only slots present in the variant string are rendered.
func (l *Layout) Render(ctx *Context) string {
if l == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
slotCounts := make(map[byte]int)
for i := range len(l.variant) {
slot := l.variant[i]
children := l.slots[slot]
if len(children) == 0 {
continue
}
meta, ok := slotRegistry[slot]
if !ok {
continue
}
bid := l.blockID(slot)
index := slotCounts[slot]
slotCounts[slot] = index + 1
bid := l.BlockID(slot, index)
b.WriteByte('<')
b.WriteString(escapeHTML(meta.tag))
writeSortedAttrs(&b, l.attrs, func(key string) bool {
return key == "role" || key == "data-block"
})
b.WriteString(` role="`)
b.WriteString(escapeAttr(meta.role))
b.WriteString(`" data-block="`)
@ -100,14 +236,7 @@ func (l *Layout) Render(ctx *Context) string {
b.WriteString(`">`)
for _, child := range children {
// Clone nested layouts before setting path (thread-safe).
if inner, ok := child.(*Layout); ok {
clone := *inner
clone.path = bid + "-"
b.WriteString(clone.Render(ctx))
continue
}
b.WriteString(child.Render(ctx))
b.WriteString(renderNodeWithPath(child, ctx, bid+"-"))
}
b.WriteString("</")
@ -117,3 +246,77 @@ func (l *Layout) Render(ctx *Context) string {
return b.String()
}
// LayoutVariantError describes the invalid characters found in a layout
// variant string.
// Example: var variantErr *LayoutVariantError
//
// if errors.As(err, &variantErr) {
// _ = variantErr.InvalidSlots()
// }
type LayoutVariantError struct {
variant string
invalidSlots []byte
invalidPositions []int
}
func (e *LayoutVariantError) Error() string {
if e == nil {
return ErrInvalidLayoutVariant.Error()
}
var b strings.Builder
b.WriteString("html: invalid layout variant ")
b.WriteString(e.variant)
if len(e.invalidSlots) == 0 {
return b.String()
}
b.WriteString(" (invalid slot")
if len(e.invalidSlots) > 1 {
b.WriteString("s")
}
b.WriteString(": ")
for i, slot := range e.invalidSlots {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(strconv.QuoteRuneToASCII(rune(slot)))
if i < len(e.invalidPositions) {
b.WriteString(" at position ")
b.WriteString(strconv.Itoa(e.invalidPositions[i] + 1))
}
}
b.WriteString("; valid slots: ")
b.WriteString(validLayoutSlots)
b.WriteByte(')')
return b.String()
}
func (e *LayoutVariantError) Unwrap() error {
return ErrInvalidLayoutVariant
}
// InvalidSlots returns a copy of the invalid slot characters that were present
// in the original variant string.
// Example: string(variantErr.InvalidSlots()) // "1X?"
func (e *LayoutVariantError) InvalidSlots() []byte {
if e == nil || len(e.invalidSlots) == 0 {
return nil
}
return append([]byte(nil), e.invalidSlots...)
}
// InvalidPositions returns a copy of the 1-based positions of the invalid slot
// characters in the original variant string.
// Example: variantErr.InvalidPositions() // []int{2, 3, 4}
func (e *LayoutVariantError) InvalidPositions() []int {
if e == nil || len(e.invalidPositions) == 0 {
return nil
}
positions := make([]int, len(e.invalidPositions))
for i, pos := range e.invalidPositions {
positions[i] = pos + 1
}
return positions
}

28
layout_external_test.go Normal file
View file

@ -0,0 +1,28 @@
package html_test
import (
"errors"
"testing"
html "dappco.re/go/core/html"
)
func TestValidateLayoutVariant_ExportsPositions(t *testing.T) {
err := html.ValidateLayoutVariant("H1X?")
if err == nil {
t.Fatal("ValidateLayoutVariant returned nil, want error")
}
var variantErr *html.LayoutVariantError
if !errors.As(err, &variantErr) {
t.Fatalf("errors.As(%T) failed, want *html.LayoutVariantError", err)
}
if got := string(variantErr.InvalidSlots()); got != "1X?" {
t.Fatalf("InvalidSlots() = %q, want %q", got, "1X?")
}
if got := variantErr.InvalidPositions(); len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2, 3, 4})
}
}

View file

@ -1,6 +1,7 @@
package html
import (
"errors"
"strings"
"testing"
)
@ -98,6 +99,34 @@ func TestLayout_FluentAPI(t *testing.T) {
}
}
func TestLayout_CloneReturnsIndependentCopy(t *testing.T) {
original := NewLayout("HLCRF").
H(Raw("header")).
C(Raw("main")).
F(Raw("footer"))
clone := original.Clone()
if clone == nil {
t.Fatal("Clone should return a layout")
}
if clone == original {
t.Fatal("Clone should return a distinct layout instance")
}
clone.H(Raw("cloned-header"))
clone.C(Raw("cloned-main"))
originalGot := original.Render(NewContext())
cloneGot := clone.Render(NewContext())
if strings.Contains(originalGot, "cloned-header") || strings.Contains(originalGot, "cloned-main") {
t.Fatalf("Clone should not mutate original layout, got:\n%s", originalGot)
}
if !strings.Contains(cloneGot, "cloned-header") || !strings.Contains(cloneGot, "cloned-main") {
t.Fatalf("Clone should preserve mutations on the copy, got:\n%s", cloneGot)
}
}
func TestLayout_IgnoresInvalidSlots(t *testing.T) {
ctx := NewContext()
// "C" variant: populating L and R should have no effect
@ -114,3 +143,169 @@ func TestLayout_IgnoresInvalidSlots(t *testing.T) {
t.Errorf("C variant should ignore R slot content, got:\n%s", got)
}
}
func TestLayout_RendersEmptySlots(t *testing.T) {
ctx := NewContext()
layout := NewLayout("HCF")
got := layout.Render(ctx)
for _, want := range []string{`<header role="banner" data-block="H-0"></header>`, `<main role="main" data-block="C-0"></main>`, `<footer role="contentinfo" data-block="F-0"></footer>`} {
if !strings.Contains(got, want) {
t.Errorf("empty slot should still render %q in:\n%s", want, got)
}
}
}
func TestValidateLayoutVariant(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},
}
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)
}
})
}
}
func TestLayout_VariantError(t *testing.T) {
tests := []struct {
name string
variant string
wantErr bool
wantErrString string
wantRender string
}{
{
name: "valid variant",
variant: "HCF",
wantRender: `<header role="banner" data-block="H-0">header</header>` +
`<main role="main" data-block="C-0">main</main>` +
`<footer role="contentinfo" data-block="F-0">footer</footer>`,
},
{
name: "mixed invalid variant",
variant: "HXC",
wantErr: true,
wantErrString: "html: invalid layout variant HXC (invalid slot: 'X' at position 2; valid slots: H, L, C, R, F)",
wantRender: `<header role="banner" data-block="H-0">header</header>` +
`<main role="main" data-block="C-0">main</main>`,
},
{
name: "multiple invalid slots",
variant: "H1X?",
wantErr: true,
wantErrString: "html: invalid layout variant H1X? (invalid slots: '1' at position 2, 'X' at position 3, '?' at position 4; valid slots: H, L, C, R, F)",
wantRender: `<header role="banner" data-block="H-0">header</header>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
layout := NewLayout(tt.variant)
layout.H(Raw("header")).C(Raw("main")).F(Raw("footer"))
if tt.wantErr {
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)
}
if err, ok := layout.VariantError().(*LayoutVariantError); ok {
switch tt.variant {
case "HXC":
if got := string(err.InvalidSlots()); got != "X" {
t.Fatalf("InvalidSlots() = %q, want %q", got, "X")
}
if got := err.InvalidPositions(); len(got) != 1 || got[0] != 2 {
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2})
}
case "H1X?":
if got := string(err.InvalidSlots()); got != "1X?" {
t.Fatalf("InvalidSlots() = %q, want %q", got, "1X?")
}
if got := err.InvalidPositions(); len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
t.Fatalf("InvalidPositions() = %v, want %v", got, []int{2, 3, 4})
}
}
} else {
t.Fatalf("VariantError() has unexpected concrete type %T", layout.VariantError())
}
} else if layout.VariantError() != nil {
t.Fatalf("VariantError() = %v, want nil", layout.VariantError())
}
if got := layout.VariantValid(); got != !tt.wantErr {
t.Fatalf("VariantValid() = %v, want %v", got, !tt.wantErr)
}
got := layout.Render(NewContext())
if got != tt.wantRender {
t.Fatalf("Render() = %q, want %q", got, tt.wantRender)
}
})
}
}
func TestLayout_RenderNilReceiver(t *testing.T) {
var layout *Layout
got := layout.Render(NewContext())
if got != "" {
t.Fatalf("nil Layout should render empty string, got %q", got)
}
}
func TestLayout_BuilderNilReceiver(t *testing.T) {
var layout *Layout
if got := layout.H(Raw("header")); got != nil {
t.Fatalf("nil Layout.H() should return nil, got %v", got)
}
if got := layout.L(Raw("left")); got != nil {
t.Fatalf("nil Layout.L() should return nil, got %v", got)
}
if got := layout.C(Raw("main")); got != nil {
t.Fatalf("nil Layout.C() should return nil, got %v", got)
}
if got := layout.R(Raw("right")); got != nil {
t.Fatalf("nil Layout.R() should return nil, got %v", got)
}
if got := layout.F(Raw("footer")); got != nil {
t.Fatalf("nil Layout.F() should return nil, got %v", got)
}
}
func TestLayout_RenderNilContext(t *testing.T) {
layout := NewLayout("C").C(Raw("content"))
got := layout.Render(nil)
if !strings.Contains(got, `data-block="C-0"`) {
t.Fatalf("Layout.Render(nil) should still render the block ID, got:\n%s", got)
}
if !strings.Contains(got, "content") {
t.Fatalf("Layout.Render(nil) should still render content, got:\n%s", got)
}
}

793
node.go
View file

@ -5,16 +5,20 @@ import (
"iter"
"maps"
"slices"
"strconv"
"strings"
i18n "dappco.re/go/core/i18n"
)
// Node is anything renderable.
// node.go: Node is anything renderable.
// Example: El("p", Text("page.body")) returns a Node that can be passed to Render().
type Node interface {
Render(ctx *Context) string
}
type cloneableNode interface {
cloneNode() Node
}
// Compile-time interface checks.
var (
_ Node = (*rawNode)(nil)
@ -27,6 +31,70 @@ var (
_ Node = (*eachNode[any])(nil)
)
// renderNode renders a node while treating nil values as empty output.
func renderNode(n Node, ctx *Context) string {
if n == nil {
return ""
}
return n.Render(normaliseContext(ctx))
}
// renderNodeWithPath renders a node while preserving layout path prefixes for
// nested layouts that may be wrapped in conditional or switch nodes.
func renderNodeWithPath(n Node, ctx *Context, path string) string {
if n == nil {
return ""
}
ctx = normaliseContext(ctx)
switch t := n.(type) {
case *Layout:
if t == nil {
return ""
}
clone := *t
clone.path = path
return clone.Render(ctx)
case interface{ renderWithPath(*Context, string) string }:
return t.renderWithPath(ctx, path)
case *ifNode:
if t == nil || t.cond == nil || t.node == nil {
return ""
}
if t.cond(ctx) {
return renderNodeWithPath(t.node, ctx, path)
}
return ""
case *unlessNode:
if t == nil || t.cond == nil || t.node == nil {
return ""
}
if !t.cond(ctx) {
return renderNodeWithPath(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 renderNodeWithPath(t.node, ctx, path)
case *switchNode:
if t == nil || t.selector == nil || t.cases == nil {
return ""
}
key := t.selector(ctx)
if node, ok := t.cases[key]; ok {
return renderNodeWithPath(node, ctx, path)
}
return ""
default:
return n.Render(ctx)
}
}
// voidElements is the set of HTML elements that must not have a closing tag.
var voidElements = map[string]bool{
"area": true,
@ -49,18 +117,44 @@ func escapeAttr(s string) string {
return html.EscapeString(s)
}
// writeSortedAttrs renders a deterministic attribute list to the builder.
// An optional skip callback can omit reserved keys while preserving ordering
// for the remaining attributes.
func writeSortedAttrs(b *strings.Builder, attrs map[string]string, skip func(string) bool) {
if len(attrs) == 0 {
return
}
keys := slices.Collect(maps.Keys(attrs))
slices.Sort(keys)
for _, key := range keys {
if skip != nil && skip(key) {
continue
}
b.WriteByte(' ')
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
b.WriteString(escapeAttr(attrs[key]))
b.WriteByte('"')
}
}
// --- rawNode ---
type rawNode struct {
content string
}
// Raw creates a node that renders without escaping (escape hatch for trusted content).
// node.go: Raw creates a node that renders without escaping.
// Example: Raw("<strong>trusted</strong>") preserves the HTML verbatim.
func Raw(content string) Node {
return &rawNode{content: content}
}
func (n *rawNode) Render(_ *Context) string {
if n == nil {
return ""
}
return n.content
}
@ -72,7 +166,8 @@ type elNode struct {
attrs map[string]string
}
// El creates an HTML element node with children.
// node.go: El creates an HTML element node with children.
// Example: El("nav", Text("nav.label")) renders a semantic element with nested nodes.
func El(tag string, children ...Node) Node {
return &elNode{
tag: tag,
@ -81,38 +176,528 @@ func El(tag string, children ...Node) Node {
}
}
// Attr sets an attribute on an El node. Returns the node for chaining.
// It recursively traverses through wrappers like If, Unless, and Entitled.
// node.go: Attr sets an attribute on an El node and returns the same node for chaining.
// Example: Attr(El("img"), "alt", "Logo") adds an escaped alt attribute.
//
// It recursively traverses wrappers like If, Unless, Entitled, Switch,
// and Each/EachSeq so the attribute lands on the rendered element.
func Attr(n Node, key, value string) Node {
switch t := n.(type) {
case *elNode:
t.attrs[key] = value
case *ifNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *unlessNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *entitledNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *switchNode:
cloned := make(map[string]Node, len(t.cases))
for caseKey, caseNode := range t.cases {
cloned[caseKey] = Attr(cloneNode(caseNode), key, value)
}
t.cases = cloned
case interface{ setAttr(string, string) }:
t.setAttr(key, value)
}
return n
}
func cloneNode(n Node) Node {
if n == nil {
return nil
}
if cloner, ok := n.(cloneableNode); ok {
return cloner.cloneNode()
}
return n
}
// node.go: AriaLabel sets the aria-label attribute on an element node.
// Example: AriaLabel(El("button"), "Open menu").
func AriaLabel(n Node, label string) Node {
if value := trimmedNonEmpty(label); value != "" {
return Attr(n, "aria-label", value)
}
return n
}
// node.go: AriaDescribedBy sets the aria-describedby attribute on an element node.
// Example: AriaDescribedBy(El("input"), "help-text", "error-text").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaDescribedBy(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-describedby", value)
}
return n
}
// node.go: AriaLabelledBy sets the aria-labelledby attribute on an element node.
// Example: AriaLabelledBy(El("section"), "section-title").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaLabelledBy(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-labelledby", value)
}
return n
}
// node.go: AriaControls sets the aria-controls attribute on an element node.
// Example: AriaControls(El("button"), "menu-panel").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaControls(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-controls", value)
}
return n
}
// node.go: AriaHasPopup sets the aria-haspopup attribute on an element node.
// Example: AriaHasPopup(El("button"), "menu").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func AriaHasPopup(n Node, popup string) Node {
if value := trimmedNonEmpty(popup); value != "" {
return Attr(n, "aria-haspopup", value)
}
return n
}
// node.go: AriaOwns sets the aria-owns attribute on an element node.
// Example: AriaOwns(El("div"), "owned-panel").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaOwns(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-owns", value)
}
return n
}
// node.go: AriaKeyShortcuts sets the aria-keyshortcuts attribute on an element node.
// Example: AriaKeyShortcuts(El("button"), "Ctrl+S", "Meta+S").
// Multiple shortcuts are joined with spaces, matching the HTML attribute format.
func AriaKeyShortcuts(n Node, shortcuts ...string) Node {
if value := joinUniqueNonEmpty(shortcuts...); value != "" {
return Attr(n, "aria-keyshortcuts", value)
}
return n
}
// node.go: AriaCurrent sets the aria-current attribute on an element node.
// Example: AriaCurrent(El("a"), "page").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func AriaCurrent(n Node, current string) Node {
if value := trimmedNonEmpty(current); value != "" {
return Attr(n, "aria-current", value)
}
return n
}
// node.go: AriaBusy sets the aria-busy attribute on an element node.
// Example: AriaBusy(El("section"), true).
func AriaBusy(n Node, busy bool) Node {
if busy {
return Attr(n, "aria-busy", "true")
}
return Attr(n, "aria-busy", "false")
}
// node.go: AriaLive sets the aria-live attribute on an element node.
// Example: AriaLive(El("div"), "polite").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func AriaLive(n Node, live string) Node {
if value := trimmedNonEmpty(live); value != "" {
return Attr(n, "aria-live", value)
}
return n
}
// node.go: AriaAtomic sets the aria-atomic attribute on a live region node.
// Example: AriaAtomic(El("div"), true).
func AriaAtomic(n Node, atomic bool) Node {
if atomic {
return Attr(n, "aria-atomic", "true")
}
return Attr(n, "aria-atomic", "false")
}
// node.go: AriaDescription sets the aria-description attribute on an element node.
// Example: AriaDescription(El("button"), "Opens the navigation menu").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func AriaDescription(n Node, description string) Node {
if value := trimmedNonEmpty(description); value != "" {
return Attr(n, "aria-description", value)
}
return n
}
// node.go: AriaDetails sets the aria-details attribute on an element node.
// Example: AriaDetails(El("input"), "field-help").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaDetails(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-details", value)
}
return n
}
// node.go: AriaErrorMessage sets the aria-errormessage attribute on an element node.
// Example: AriaErrorMessage(El("input"), "field-error").
// Multiple IDs are joined with spaces, matching the HTML attribute format.
func AriaErrorMessage(n Node, ids ...string) Node {
if value := joinUniqueNonEmpty(ids...); value != "" {
return Attr(n, "aria-errormessage", value)
}
return n
}
// node.go: AriaRoleDescription sets the aria-roledescription attribute on an
// element node.
// Example: AriaRoleDescription(El("section"), "carousel").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func AriaRoleDescription(n Node, description string) Node {
if value := trimmedNonEmpty(description); value != "" {
return Attr(n, "aria-roledescription", value)
}
return n
}
// node.go: Role sets the role attribute on an element node.
// Example: Role(El("aside"), "complementary").
func Role(n Node, role string) Node {
if value := trimmedNonEmpty(role); value != "" {
return Attr(n, "role", value)
}
return n
}
// node.go: Lang sets the lang attribute on an element node.
// Example: Lang(El("html"), "en-GB").
func Lang(n Node, locale string) Node {
if value := trimmedNonEmpty(locale); value != "" {
return Attr(n, "lang", value)
}
return n
}
// node.go: Dir sets the dir attribute on an element node.
// Example: Dir(El("p"), "rtl").
func Dir(n Node, direction string) Node {
if value := trimmedNonEmpty(direction); value != "" {
return Attr(n, "dir", value)
}
return n
}
// node.go: Alt sets the alt attribute on an element node.
// Example: Alt(El("img"), "Product screenshot").
func Alt(n Node, text string) Node {
if value := trimmedNonEmpty(text); value != "" {
return Attr(n, "alt", value)
}
return n
}
// node.go: Title sets the title attribute on an element node.
// Example: Title(El("abbr"), "World Wide Web").
func Title(n Node, text string) Node {
if value := trimmedNonEmpty(text); value != "" {
return Attr(n, "title", value)
}
return n
}
// node.go: Placeholder sets the placeholder attribute on an element node.
// Example: Placeholder(El("input"), "Search by keyword").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func Placeholder(n Node, text string) Node {
if value := trimmedNonEmpty(text); value != "" {
return Attr(n, "placeholder", value)
}
return n
}
// node.go: Autocomplete sets the autocomplete attribute on an element node.
// Example: Autocomplete(El("input"), "email").
// An empty value leaves the node unchanged so callers can opt out cleanly.
func Autocomplete(n Node, value string) Node {
if value = trimmedNonEmpty(value); value != "" {
return Attr(n, "autocomplete", value)
}
return n
}
// node.go: AltText is a compatibility alias for Alt.
// Example: AltText(El("img"), "Product screenshot").
// Prefer Alt for new call sites so the canonical image helper stays predictable.
func AltText(n Node, text string) Node {
return Alt(n, text)
}
// node.go: Class sets the class attribute on an element node.
// Example: Class(El("div"), "card", "card--primary").
// Multiple class tokens are joined with spaces.
func Class(n Node, classes ...string) Node {
if value := joinNonEmpty(classes...); value != "" {
return Attr(n, "class", value)
}
return n
}
// node.go: AriaHidden sets the aria-hidden attribute on an element node.
// Example: AriaHidden(El("svg"), true).
func AriaHidden(n Node, hidden bool) Node {
if hidden {
return Attr(n, "aria-hidden", "true")
}
return n
}
// node.go: Hidden sets the HTML hidden attribute on an element node.
// Example: Hidden(El("section"), true).
// Hidden is a standard HTML visibility flag and omits the attribute when false.
func Hidden(n Node, hidden bool) Node {
if hidden {
return Attr(n, "hidden", "hidden")
}
return n
}
// node.go: Disabled sets the HTML disabled attribute on an element node.
// Example: Disabled(El("button"), true).
// Disabled follows standard HTML boolean attribute semantics and omits the
// attribute when false.
func Disabled(n Node, disabled bool) Node {
if disabled {
return Attr(n, "disabled", "disabled")
}
return n
}
// node.go: Checked sets the HTML checked attribute on an element node.
// Example: Checked(El("input"), true).
// Checked follows standard HTML boolean attribute semantics and omits the
// attribute when false.
func Checked(n Node, checked bool) Node {
if checked {
return Attr(n, "checked", "checked")
}
return n
}
// node.go: Required sets the HTML required attribute on an element node.
// Example: Required(El("input"), true).
// Required follows standard HTML boolean attribute semantics and omits the
// attribute when false.
func Required(n Node, required bool) Node {
if required {
return Attr(n, "required", "required")
}
return n
}
// node.go: ReadOnly sets the HTML readonly attribute on an element node.
// Example: ReadOnly(El("input"), true).
// ReadOnly follows standard HTML boolean attribute semantics and omits the
// attribute when false.
func ReadOnly(n Node, readonly bool) Node {
if readonly {
return Attr(n, "readonly", "readonly")
}
return n
}
// node.go: Selected sets the HTML selected attribute on an element node.
// Example: Selected(El("option"), true).
// Selected follows standard HTML boolean attribute semantics and omits the
// attribute when false.
func Selected(n Node, selected bool) Node {
if selected {
return Attr(n, "selected", "selected")
}
return n
}
// node.go: AriaExpanded sets the aria-expanded attribute on an element node.
// Example: AriaExpanded(El("button"), true).
func AriaExpanded(n Node, expanded bool) Node {
if expanded {
return Attr(n, "aria-expanded", "true")
}
return Attr(n, "aria-expanded", "false")
}
// node.go: AriaDisabled sets the aria-disabled attribute on an element node.
// Example: AriaDisabled(El("button"), true).
func AriaDisabled(n Node, disabled bool) Node {
if disabled {
return Attr(n, "aria-disabled", "true")
}
return Attr(n, "aria-disabled", "false")
}
// node.go: AriaModal sets the aria-modal attribute on an element node.
// Example: AriaModal(El("dialog"), true).
func AriaModal(n Node, modal bool) Node {
if modal {
return Attr(n, "aria-modal", "true")
}
return Attr(n, "aria-modal", "false")
}
// node.go: AriaChecked sets the aria-checked attribute on an element node.
// Example: AriaChecked(El("input"), true).
func AriaChecked(n Node, checked bool) Node {
if checked {
return Attr(n, "aria-checked", "true")
}
return Attr(n, "aria-checked", "false")
}
// node.go: AriaInvalid sets the aria-invalid attribute on an element node.
// Example: AriaInvalid(El("input"), true).
func AriaInvalid(n Node, invalid bool) Node {
if invalid {
return Attr(n, "aria-invalid", "true")
}
return Attr(n, "aria-invalid", "false")
}
// node.go: AriaRequired sets the aria-required attribute on an element node.
// Example: AriaRequired(El("input"), true).
func AriaRequired(n Node, required bool) Node {
if required {
return Attr(n, "aria-required", "true")
}
return Attr(n, "aria-required", "false")
}
// node.go: AriaReadOnly sets the aria-readonly attribute on an element node.
// Example: AriaReadOnly(El("input"), true).
func AriaReadOnly(n Node, readonly bool) Node {
if readonly {
return Attr(n, "aria-readonly", "true")
}
return Attr(n, "aria-readonly", "false")
}
// node.go: AriaPressed sets the aria-pressed attribute on an element node.
// Example: AriaPressed(El("button"), true).
func AriaPressed(n Node, pressed bool) Node {
if pressed {
return Attr(n, "aria-pressed", "true")
}
return Attr(n, "aria-pressed", "false")
}
// node.go: AriaSelected sets the aria-selected attribute on an element node.
// Example: AriaSelected(El("option"), true).
func AriaSelected(n Node, selected bool) Node {
if selected {
return Attr(n, "aria-selected", "true")
}
return Attr(n, "aria-selected", "false")
}
// node.go: TabIndex sets the tabindex attribute on an element node.
// Example: TabIndex(El("button"), 0).
func TabIndex(n Node, index int) Node {
return Attr(n, "tabindex", strconv.Itoa(index))
}
// node.go: AutoFocus sets the autofocus attribute on an element node.
// Example: AutoFocus(El("input")).
func AutoFocus(n Node) Node {
return Attr(n, "autofocus", "autofocus")
}
// node.go: ID sets the id attribute on an element node.
// Example: ID(El("section"), "main-content").
func ID(n Node, id string) Node {
if value := trimmedNonEmpty(id); value != "" {
return Attr(n, "id", value)
}
return n
}
// node.go: For sets the for attribute on an element node.
// Example: For(El("label"), "email-input").
func For(n Node, target string) Node {
if value := trimmedNonEmpty(target); value != "" {
return Attr(n, "for", value)
}
return n
}
func joinNonEmpty(parts ...string) string {
if len(parts) == 0 {
return ""
}
var filtered []string
for i := range parts {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
filtered = append(filtered, part)
}
if len(filtered) == 0 {
return ""
}
return strings.Join(filtered, " ")
}
func joinUniqueNonEmpty(parts ...string) string {
if len(parts) == 0 {
return ""
}
seen := make(map[string]struct{}, len(parts))
filtered := make([]string, 0, len(parts))
for i := range parts {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
filtered = append(filtered, part)
}
if len(filtered) == 0 {
return ""
}
return strings.Join(filtered, " ")
}
func trimmedNonEmpty(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return value
}
func (n *elNode) Render(ctx *Context) string {
if n == nil {
return ""
}
return n.renderWithPath(normaliseContext(ctx), "")
}
func (n *elNode) renderWithPath(ctx *Context, path string) string {
if n == nil {
return ""
}
var b strings.Builder
b.WriteByte('<')
b.WriteString(escapeHTML(n.tag))
// Sort attribute keys for deterministic output.
keys := slices.Collect(maps.Keys(n.attrs))
slices.Sort(keys)
for _, key := range keys {
b.WriteByte(' ')
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
b.WriteString(escapeAttr(n.attrs[key]))
b.WriteByte('"')
}
writeSortedAttrs(&b, n.attrs, nil)
b.WriteByte('>')
@ -121,7 +706,7 @@ func (n *elNode) Render(ctx *Context) string {
}
for i := range len(n.children) {
b.WriteString(n.children[i].Render(ctx))
b.WriteString(renderNodeWithPath(n.children[i], ctx, path))
}
b.WriteString("</")
@ -131,6 +716,24 @@ func (n *elNode) Render(ctx *Context) string {
return b.String()
}
func (n *elNode) cloneNode() Node {
if n == nil {
return (*elNode)(nil)
}
clone := *n
if len(n.children) > 0 {
clone.children = make([]Node, len(n.children))
for i := range n.children {
clone.children[i] = cloneNode(n.children[i])
}
}
if n.attrs != nil {
clone.attrs = maps.Clone(n.attrs)
}
return &clone
}
// --- escapeHTML ---
// escapeHTML escapes a string for safe inclusion in HTML text content.
@ -145,20 +748,19 @@ type textNode struct {
args []any
}
// Text creates a node that renders through the go-i18n grammar pipeline.
// node.go: Text creates a node that renders through the go-i18n grammar pipeline.
// Example: Text("page.title") renders translated text and escapes it for HTML.
// Output is HTML-escaped by default. Safe-by-default path.
func Text(key string, args ...any) Node {
return &textNode{key: key, args: args}
}
func (n *textNode) Render(ctx *Context) string {
var text string
if ctx != nil && ctx.service != nil {
text = ctx.service.T(n.key, n.args...)
} else {
text = i18n.T(n.key, n.args...)
if n == nil {
return ""
}
return escapeHTML(text)
ctx = normaliseContext(ctx)
return escapeHTML(translateText(ctx, n.key, n.args...))
}
// --- ifNode ---
@ -168,18 +770,34 @@ type ifNode struct {
node Node
}
// If renders child only when condition is true.
// node.go: If renders child only when condition is true.
// Example: If(func(*Context) bool { return true }, Raw("shown")).
func If(cond func(*Context) bool, node Node) Node {
return &ifNode{cond: cond, node: node}
}
func (n *ifNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
ctx = normaliseContext(ctx)
if n.cond(ctx) {
return n.node.Render(ctx)
return renderNodeWithPath(n.node, ctx, "")
}
return ""
}
func (n *ifNode) cloneNode() Node {
if n == nil {
return (*ifNode)(nil)
}
clone := *n
clone.node = cloneNode(n.node)
return &clone
}
// --- unlessNode ---
type unlessNode struct {
@ -187,18 +805,34 @@ type unlessNode struct {
node Node
}
// Unless renders child only when condition is false.
// node.go: Unless renders child only when condition is false.
// Example: Unless(func(*Context) bool { return true }, Raw("hidden")).
func Unless(cond func(*Context) bool, node Node) Node {
return &unlessNode{cond: cond, node: node}
}
func (n *unlessNode) Render(ctx *Context) string {
if n == nil || n.cond == nil || n.node == nil {
return ""
}
ctx = normaliseContext(ctx)
if !n.cond(ctx) {
return n.node.Render(ctx)
return renderNodeWithPath(n.node, ctx, "")
}
return ""
}
func (n *unlessNode) cloneNode() Node {
if n == nil {
return (*unlessNode)(nil)
}
clone := *n
clone.node = cloneNode(n.node)
return &clone
}
// --- entitledNode ---
type entitledNode struct {
@ -206,17 +840,34 @@ type entitledNode struct {
node Node
}
// Entitled renders child only when entitlement is granted. Absent, not hidden.
// If no entitlement function is set on the context, access is denied by default.
// node.go: Entitled renders child only when entitlement is granted.
// Example: Entitled("premium", Raw("paid feature")).
// Content is absent, not hidden. 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 ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
if n == nil || n.node == nil {
return ""
}
return n.node.Render(ctx)
ctx = normaliseContext(ctx)
if ctx.Entitlements == nil || !ctx.Entitlements(n.feature) {
return ""
}
return renderNodeWithPath(n.node, ctx, "")
}
func (n *entitledNode) cloneNode() Node {
if n == nil {
return (*entitledNode)(nil)
}
clone := *n
clone.node = cloneNode(n.node)
return &clone
}
// --- switchNode ---
@ -226,19 +877,40 @@ type switchNode struct {
cases map[string]Node
}
// Switch renders based on runtime selector value.
// node.go: Switch renders based on runtime selector value.
// Example: Switch(selector, map[string]Node{"desktop": Raw("wide")}).
func Switch(selector func(*Context) string, cases map[string]Node) Node {
return &switchNode{selector: selector, cases: cases}
}
func (n *switchNode) Render(ctx *Context) string {
if n == nil || n.selector == nil || n.cases == nil {
return ""
}
ctx = normaliseContext(ctx)
key := n.selector(ctx)
if node, ok := n.cases[key]; ok {
return node.Render(ctx)
return renderNodeWithPath(node, ctx, "")
}
return ""
}
func (n *switchNode) cloneNode() Node {
if n == nil {
return (*switchNode)(nil)
}
clone := *n
if n.cases != nil {
clone.cases = make(map[string]Node, len(n.cases))
for caseKey, caseNode := range n.cases {
clone.cases[caseKey] = cloneNode(caseNode)
}
}
return &clone
}
// --- eachNode ---
type eachNode[T any] struct {
@ -246,20 +918,59 @@ type eachNode[T any] struct {
fn func(T) Node
}
// Each iterates items and renders each via fn.
// node.go: Each iterates items and renders each via fn.
// Example: Each(items, func(item Item) Node { return El("li", Text(item.Name)) }).
func Each[T any](items []T, fn func(T) Node) Node {
return EachSeq(slices.Values(items), fn)
}
// EachSeq iterates an iter.Seq and renders each via fn.
// node.go: EachSeq iterates an iter.Seq and renders each via fn.
// Example: EachSeq(slices.Values(items), renderItem).
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
return &eachNode[T]{items: items, fn: fn}
}
func (n *eachNode[T]) Render(ctx *Context) string {
if n == nil || n.items == nil || n.fn == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
for item := range n.items {
b.WriteString(n.fn(item).Render(ctx))
b.WriteString(renderNodeWithPath(n.fn(item), ctx, ""))
}
return b.String()
}
func (n *eachNode[T]) renderWithPath(ctx *Context, path string) string {
if n == nil || n.items == nil || n.fn == nil {
return ""
}
var b strings.Builder
for item := range n.items {
b.WriteString(renderNodeWithPath(n.fn(item), ctx, path))
}
return b.String()
}
func (n *eachNode[T]) setAttr(key, value string) {
if n == nil || n.fn == nil {
return
}
prev := n.fn
n.fn = func(item T) Node {
return Attr(cloneNode(prev(item)), key, value)
}
}
func (n *eachNode[T]) cloneNode() Node {
if n == nil {
return (*eachNode[T])(nil)
}
clone := *n
return &clone
}

File diff suppressed because it is too large Load diff

64
path.go
View file

@ -2,22 +2,68 @@ package html
import "strings"
// ParseBlockID extracts the slot sequence from a data-block ID.
// "L-0-C-0" → ['L', 'C']
// path.go: ParseBlockID extracts the HLCRF slot sequence from a data-block ID.
// Example: ParseBlockID("L-0-C-0") returns []byte{'L', 'C'}.
func ParseBlockID(id string) []byte {
segments := ParseBlockPath(id)
if len(segments) == 0 {
return nil
}
slots := make([]byte, len(segments))
for i := range segments {
slots[i] = segments[i].Slot
}
return slots
}
// BlockPathSegment describes one slot occurrence in a data-block path.
// Example: BlockPathSegment{Slot: 'C', Index: 0} represents "C-0".
type BlockPathSegment struct {
Slot byte
Index int
}
// ParseBlockPath extracts the slot/index sequence from a data-block ID.
// Example: ParseBlockPath("L-0-C-1") returns []BlockPathSegment{{'L', 0}, {'C', 1}}.
func ParseBlockPath(id string) []BlockPathSegment {
if id == "" {
return nil
}
// Split on "-" and take every other element (the slot letters).
// Format: "X-0" or "X-0-Y-0-Z-0"
var slots []byte
// Split on "-" and require the exact structural pattern:
// slot, numeric index, slot, numeric index, ...
parts := strings.SplitSeq(id, "-")
segments := make([]BlockPathSegment, 0, 4)
i := 0
for part := range strings.SplitSeq(id, "-") {
if i%2 == 0 && len(part) == 1 {
slots = append(slots, part[0])
for part := range parts {
if i%2 == 0 {
if len(part) != 1 {
return nil
}
switch part[0] {
case 'H', 'L', 'C', 'R', 'F':
segments = append(segments, BlockPathSegment{Slot: part[0]})
default:
return nil
}
} else {
if part == "" {
return nil
}
index := 0
for j := range len(part) {
if part[j] < '0' || part[j] > '9' {
return nil
}
index = index*10 + int(part[j]-'0')
}
segments[len(segments)-1].Index = index
}
i++
}
return slots
if i == 0 || i%2 != 0 {
return nil
}
return segments
}

View file

@ -3,6 +3,8 @@ package html
import (
"strings"
"testing"
"slices"
)
func TestNestedLayout_PathChain(t *testing.T) {
@ -39,23 +41,130 @@ func TestNestedLayout_DeepNesting(t *testing.T) {
}
}
func TestNestedLayout_ThroughConditionalWrapper(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("wrapped"))
wrapped := If(func(*Context) bool { return true }, inner)
got := NewLayout("C").C(wrapped).Render(ctx)
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Fatalf("conditional wrapper should preserve nested block path, got:\n%s", got)
}
}
func TestNestedLayout_ThroughEntitledWrapper(t *testing.T) {
ctx := NewContext()
ctx.Entitlements = func(feature string) bool { return feature == "feature" }
inner := NewLayout("C").C(Raw("entitled"))
wrapped := Entitled("feature", inner)
got := NewLayout("C").C(wrapped).Render(ctx)
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Fatalf("entitled wrapper should preserve nested block path, got:\n%s", got)
}
}
func TestNestedLayout_ThroughSwitchWrapper(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("switch"))
wrapped := Switch(func(*Context) string { return "match" }, map[string]Node{
"match": inner,
})
got := NewLayout("C").C(wrapped).Render(ctx)
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Fatalf("switch wrapper should preserve nested block path, got:\n%s", got)
}
}
func TestNestedLayout_ThroughEachWrapper(t *testing.T) {
ctx := NewContext()
items := []int{1, 2}
node := Each(items, func(i int) Node {
return NewLayout("C").C(Raw(strings.Repeat("x", i)))
})
got := NewLayout("C").C(node).Render(ctx)
if count := strings.Count(got, `data-block="C-0-C-0"`); count != 2 {
t.Fatalf("each wrapper should preserve nested block path twice, got %d in:\n%s", count, got)
}
}
func TestNestedLayout_ThroughEachSeqWrapper(t *testing.T) {
ctx := NewContext()
node := EachSeq(slices.Values([]string{"a", "b"}), func(s string) Node {
return NewLayout("C").C(Raw(s))
})
got := NewLayout("C").C(node).Render(ctx)
if count := strings.Count(got, `data-block="C-0-C-0"`); count != 2 {
t.Fatalf("eachseq wrapper should preserve nested block path twice, got %d in:\n%s", count, got)
}
}
func TestNestedLayout_ThroughElementWrapper(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("wrapped"))
wrapped := El("section", inner)
got := NewLayout("C").C(wrapped).Render(ctx)
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Fatalf("element wrapper should preserve nested block path, got:\n%s", got)
}
}
func TestNestedLayout_ThroughResponsiveWrapper(t *testing.T) {
ctx := NewContext()
inner := NewLayout("C").C(Raw("wrapped"))
wrapped := NewResponsive().
Variant("desktop", inner)
got := NewLayout("C").C(wrapped).Render(ctx)
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Fatalf("responsive wrapper should preserve nested block path, got:\n%s", got)
}
}
func TestNestedLayout_NilChild(t *testing.T) {
ctx := NewContext()
got := NewLayout("C").C(nil, Raw("leaf")).Render(ctx)
if !strings.Contains(got, "leaf") {
t.Fatalf("layout with nil child should still render leaf content, got:\n%s", got)
}
if strings.Contains(got, "<nil>") {
t.Fatalf("layout with nil child should not render placeholder text, got:\n%s", got)
}
}
func TestBlockID(t *testing.T) {
tests := []struct {
path string
slot byte
want string
path string
slot byte
index 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-0"},
{"L-0-", 'C', 0, "L-0-C-0"},
{"C-0-C-0-", 'C', 0, "C-0-C-0-C-0"},
{"", 'F', 2, "F-2"},
}
for _, tt := range tests {
l := &Layout{path: tt.path}
got := l.blockID(tt.slot)
got := l.BlockID(tt.slot, tt.index)
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.index, got, tt.want)
}
}
}
@ -66,9 +175,15 @@ func TestParseBlockID(t *testing.T) {
want []byte
}{
{"L-0-C-0", []byte{'L', 'C'}},
{"C-0-C-1", []byte{'C', 'C'}},
{"H-0", []byte{'H'}},
{"C-0-C-0-C-0", []byte{'C', 'C', 'C'}},
{"X-0", nil},
{"H-0-X-0", nil},
{"", nil},
{"L-1-C-0", []byte{'L', 'C'}},
{"L-0-C", nil},
{"LL-0", nil},
}
for _, tt := range tests {
@ -84,3 +199,31 @@ func TestParseBlockID(t *testing.T) {
}
}
}
func TestParseBlockPath(t *testing.T) {
tests := []struct {
id string
want []BlockPathSegment
}{
{"L-0-C-0", []BlockPathSegment{{Slot: 'L', Index: 0}, {Slot: 'C', Index: 0}}},
{"C-12-C-3", []BlockPathSegment{{Slot: 'C', Index: 12}, {Slot: 'C', Index: 3}}},
{"H-0", []BlockPathSegment{{Slot: 'H', Index: 0}}},
{"", nil},
{"X-0", nil},
{"L-0-C", nil},
{"L-0-C-x", nil},
}
for _, tt := range tests {
got := ParseBlockPath(tt.id)
if len(got) != len(tt.want) {
t.Errorf("ParseBlockPath(%q) = %v, want %v", tt.id, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("ParseBlockPath(%q)[%d] = %#v, want %#v", tt.id, i, got[i], tt.want[i])
}
}
}
}

View file

@ -3,64 +3,74 @@
package html
import (
htmlstd "html"
"strings"
"unicode"
"dappco.re/go/core/i18n/reversal"
)
// StripTags removes HTML tags from rendered output, returning plain text.
// pipeline.go: StripTags removes HTML tags from rendered output, returning plain text.
// Example: StripTags("<p>Hello</p><p>world</p>") returns "Hello world".
// Tag boundaries are collapsed into single spaces; result is trimmed.
// Does not handle script/style element content (go-html does not generate these).
func StripTags(html string) string {
var b strings.Builder
inTag := false
prevSpace := true // starts true to trim leading space
pendingSpace := false
seenText := false
for _, r := range html {
if inTag {
if r == '>' {
inTag = false
if seenText {
pendingSpace = true
}
}
continue
}
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
if unicode.IsSpace(r) {
if seenText {
pendingSpace = true
}
continue
}
if !inTag {
if r == ' ' || r == '\t' || r == '\n' {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
} else {
b.WriteRune(r)
prevSpace = false
}
if pendingSpace {
b.WriteByte(' ')
pendingSpace = false
}
b.WriteRune(r)
seenText = true
}
return strings.TrimSpace(b.String())
return htmlstd.UnescapeString(b.String())
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
// pipeline.go: Imprint renders a node tree to HTML, strips tags, tokenises the text,
// and returns a GrammarImprint — the full render-reverse pipeline.
// Example: Imprint(NewLayout("C").C(Text("page.body")), NewContext()).
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
if ctx == nil {
ctx = NewContext()
}
rendered := node.Render(ctx)
ctx = normaliseContext(ctx)
rendered := Render(node, ctx)
text := StripTags(rendered)
tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text)
return reversal.NewImprint(tokens)
}
// CompareVariants runs the imprint pipeline on each responsive variant independently
// pipeline.go: CompareVariants runs the imprint pipeline on each responsive variant independently
// and returns pairwise similarity scores. Key format: "name1:name2".
// Example: CompareVariants(NewResponsive().Variant("desktop", NewLayout("C")), NewContext()).
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if ctx == nil {
ctx = NewContext()
ctx = normaliseContext(ctx)
if r == nil {
return map[string]float64{}
}
type named struct {
@ -77,7 +87,11 @@ func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
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, right := imprints[i].name, imprints[j].name
if right < left {
left, right = right, left
}
key := left + ":" + right
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
}
}

View file

@ -32,6 +32,14 @@ func TestStripTags_MultipleRegions(t *testing.T) {
}
}
func TestStripTags_BoundaryWhitespace(t *testing.T) {
got := StripTags(`<p></p><p>hello</p><p></p>`)
want := "hello"
if got != want {
t.Errorf("StripTags(boundary whitespace) = %q, want %q", got, want)
}
}
func TestStripTags_Empty(t *testing.T) {
got := StripTags("")
if got != "" {
@ -47,10 +55,10 @@ func TestStripTags_NoTags(t *testing.T) {
}
func TestStripTags_Entities(t *testing.T) {
got := StripTags(`&lt;script&gt;`)
want := "&lt;script&gt;"
got := StripTags(`<p>&amp; &lt;script&gt;</p>`)
want := "& <script>"
if got != want {
t.Errorf("StripTags should preserve entities, got %q, want %q", got, want)
t.Errorf("StripTags should unescape entities, got %q, want %q", got, want)
}
}
@ -74,6 +82,19 @@ func TestImprint_FromNode(t *testing.T) {
}
}
func TestImprint_NilNode(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
imp := Imprint(nil, NewContext())
if imp.TokenCount != 0 {
t.Fatalf("Imprint(nil, ctx) TokenCount = %d, want 0", imp.TokenCount)
}
if imp.UniqueVerbs != 0 {
t.Fatalf("Imprint(nil, ctx) UniqueVerbs = %d, want 0", imp.UniqueVerbs)
}
}
func TestImprint_SimilarPages(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
@ -128,3 +149,29 @@ func TestCompareVariants(t *testing.T) {
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
}
}
func TestCompareVariants_CanonicalKeyOrder(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
r := NewResponsive().
Variant("mobile", NewLayout("C").C(El("p", Text("Hello")))).
Variant("desktop", NewLayout("C").C(El("p", Text("Hello"))))
scores := CompareVariants(r, ctx)
if _, ok := scores["desktop:mobile"]; !ok {
t.Fatalf("CompareVariants should canonicalise pair keys, got %v", scores)
}
if _, ok := scores["mobile:desktop"]; ok {
t.Fatalf("CompareVariants should not emit reversed duplicate keys, got %v", scores)
}
}
func TestCompareVariants_NilResponsive(t *testing.T) {
scores := CompareVariants(nil, NewContext())
if len(scores) != 0 {
t.Fatalf("CompareVariants(nil, ctx) = %v, want empty map", scores)
}
}

View file

@ -1,9 +1,11 @@
package html
// Render is a convenience function that renders a node tree to HTML.
// render.go: Render is a convenience function that renders a node tree to HTML.
// Example: Render(NewLayout("C").C(Raw("body")), NewContext()).
func Render(node Node, ctx *Context) string {
if ctx == nil {
ctx = NewContext()
ctx = normaliseContext(ctx)
if node == nil {
return ""
}
return node.Render(ctx)
}

View file

@ -95,3 +95,10 @@ func TestRender_XSSPrevention(t *testing.T) {
t.Errorf("XSS prevention: expected escaped script tag, got:\n%s", got)
}
}
func TestRender_NilNode(t *testing.T) {
got := Render(nil, NewContext())
if got != "" {
t.Fatalf("Render(nil, ctx) = %q, want empty string", got)
}
}

View file

@ -1,11 +1,17 @@
package html
import "strings"
import (
"maps"
"strconv"
"strings"
)
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// responsive.go: Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Example: NewResponsive().Variant("desktop", NewLayout("C").C(Raw("main"))).
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
variants []responsiveVariant
attrs map[string]string
}
type responsiveVariant struct {
@ -13,26 +19,228 @@ type responsiveVariant struct {
layout *Layout
}
// NewResponsive creates a new multi-variant responsive compositor.
// responsive.go: NewResponsive creates a new multi-variant responsive compositor.
// Example: r := NewResponsive().
func NewResponsive() *Responsive {
return &Responsive{}
return &Responsive{
attrs: make(map[string]string),
}
}
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Clone returns a deep copy of the responsive compositor.
// Example: next := responsive.Clone().
func (r *Responsive) Clone() *Responsive {
if r == nil {
return nil
}
clone, ok := r.cloneNode().(*Responsive)
if !ok {
return nil
}
return clone
}
func (r *Responsive) setAttr(key, value string) {
if r == nil {
return
}
if r.attrs == nil {
r.attrs = make(map[string]string)
}
r.attrs[key] = value
}
// escapeCSSString escapes a string for safe use inside a double-quoted CSS
// attribute selector.
func escapeCSSString(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\', '"':
b.WriteByte('\\')
b.WriteRune(r)
case '\n':
b.WriteString(`\a `)
case '\r':
b.WriteString(`\d `)
case '\f':
b.WriteString(`\c `)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
b.WriteString(strings.ToLower(strconv.FormatInt(int64(r), 16)))
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}
// responsive.go: VariantSelector returns a CSS attribute selector for a named responsive variant.
// Example: VariantSelector("desktop") returns [data-variant="desktop"].
func VariantSelector(name string) string {
return `[data-variant="` + escapeCSSString(name) + `"]`
}
// responsive.go: ScopeVariant prefixes a selector so it only matches elements inside the
// named responsive variant.
// Example: ScopeVariant("desktop", ".nav").
func ScopeVariant(name, selector string) string {
scope := VariantSelector(name)
if selector == "" {
return scope
}
parts := splitSelectorList(selector)
scoped := make([]string, 0, len(parts))
for i := range parts {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
scoped = append(scoped, scope+" "+part)
}
if len(scoped) == 0 {
return scope
}
return strings.Join(scoped, ", ")
}
// splitSelectorList splits a CSS selector list on top-level commas only.
// Commas inside brackets, parentheses, braces, or quoted strings are preserved.
func splitSelectorList(selector string) []string {
if selector == "" {
return nil
}
parts := make([]string, 0, 1)
var b strings.Builder
var quote rune
escaped := false
depthParen := 0
depthBracket := 0
depthBrace := 0
for _, r := range selector {
if escaped {
b.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
b.WriteRune(r)
escaped = true
continue
}
switch {
case quote != 0:
b.WriteRune(r)
if r == quote {
quote = 0
}
case r == '"' || r == '\'':
quote = r
b.WriteRune(r)
case r == '(':
depthParen++
b.WriteRune(r)
case r == ')':
if depthParen > 0 {
depthParen--
}
b.WriteRune(r)
case r == '[':
depthBracket++
b.WriteRune(r)
case r == ']':
if depthBracket > 0 {
depthBracket--
}
b.WriteRune(r)
case r == '{':
depthBrace++
b.WriteRune(r)
case r == '}':
if depthBrace > 0 {
depthBrace--
}
b.WriteRune(r)
case r == ',' && depthParen == 0 && depthBracket == 0 && depthBrace == 0:
parts = append(parts, b.String())
b.Reset()
default:
b.WriteRune(r)
}
}
parts = append(parts, b.String())
return parts
}
// responsive.go: Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Example: r.Variant("mobile", NewLayout("C").C(Raw("body"))).
// Variants render in insertion order.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
if r == nil {
return nil
}
if layout != nil {
layout = layout.Clone()
}
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
return r
}
// Render produces HTML with each variant in a data-variant container.
// responsive.go: Render produces HTML with each variant in a data-variant container.
// Example: NewResponsive().Variant("desktop", NewLayout("C")).Render(NewContext()).
func (r *Responsive) Render(ctx *Context) string {
return r.renderWithPath(ctx, "")
}
func (r *Responsive) cloneNode() Node {
if r == nil {
return (*Responsive)(nil)
}
clone := *r
if r.attrs != nil {
clone.attrs = maps.Clone(r.attrs)
}
if r.variants != nil {
clone.variants = make([]responsiveVariant, len(r.variants))
for i := range r.variants {
clone.variants[i] = r.variants[i]
if r.variants[i].layout != nil {
if layout, ok := cloneNode(r.variants[i].layout).(*Layout); ok {
clone.variants[i].layout = layout
}
}
}
}
return &clone
}
func (r *Responsive) renderWithPath(ctx *Context, path string) string {
if r == nil {
return ""
}
ctx = normaliseContext(ctx)
var b strings.Builder
for _, v := range r.variants {
b.WriteString(`<div data-variant="`)
b.WriteString(`<div`)
writeSortedAttrs(&b, r.attrs, func(key string) bool {
return key == "data-variant"
})
b.WriteString(` data-variant="`)
b.WriteString(escapeAttr(v.name))
b.WriteString(`">`)
b.WriteString(v.layout.Render(ctx))
b.WriteString(renderNodeWithPath(v.layout, ctx, path))
b.WriteString(`</div>`)
}
return b.String()

View file

@ -54,6 +54,52 @@ func TestResponsive_VariantOrder(t *testing.T) {
}
}
func TestResponsive_CloneReturnsIndependentCopy(t *testing.T) {
original := NewResponsive().
Variant("desktop", NewLayout("C").C(Raw("desktop"))).
Variant("mobile", NewLayout("C").C(Raw("mobile")))
clone := original.Clone()
if clone == nil {
t.Fatal("Clone should return a responsive compositor")
}
if clone == original {
t.Fatal("Clone should return a distinct responsive instance")
}
clone.Variant("tablet", NewLayout("C").C(Raw("tablet")))
clone.setAttr("class", "cloned-responsive")
originalGot := original.Render(NewContext())
cloneGot := clone.Render(NewContext())
if strings.Contains(originalGot, "tablet") || strings.Contains(originalGot, "cloned-responsive") {
t.Fatalf("Clone should not mutate original responsive compositor, got:\n%s", originalGot)
}
if !strings.Contains(cloneGot, `class="cloned-responsive"`) {
t.Fatalf("Clone should preserve attributes on the copy, got:\n%s", cloneGot)
}
if !strings.Contains(cloneGot, `data-variant="tablet"`) {
t.Fatalf("Clone should preserve new variants on the copy, got:\n%s", cloneGot)
}
}
func TestResponsive_VariantClonesLayoutInput(t *testing.T) {
layout := NewLayout("C").C(Raw("original"))
responsive := NewResponsive().Variant("desktop", layout)
layout.C(Raw("mutated"))
got := responsive.Render(NewContext())
if !strings.Contains(got, "original") {
t.Fatalf("Variant should snapshot the layout at insertion time, got:\n%s", got)
}
if strings.Contains(got, "mutated") {
t.Fatalf("Variant should not share later layout mutations, got:\n%s", got)
}
}
func TestResponsive_NestedPaths(t *testing.T) {
ctx := NewContext()
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
@ -87,3 +133,167 @@ func TestResponsive_VariantsIndependent(t *testing.T) {
func TestResponsive_ImplementsNode(t *testing.T) {
var _ Node = NewResponsive()
}
func TestResponsive_RenderNilReceiver(t *testing.T) {
var r *Responsive
got := r.Render(NewContext())
if got != "" {
t.Fatalf("nil Responsive should render empty string, got %q", got)
}
}
func TestResponsive_BuilderNilReceiver(t *testing.T) {
var r *Responsive
if got := r.Variant("desktop", NewLayout("C")); got != nil {
t.Fatalf("nil Responsive.Variant() should return nil, got %v", got)
}
}
func TestResponsive_RenderNilContext(t *testing.T) {
r := NewResponsive().
Variant("desktop", NewLayout("C").C(Raw("main")))
got := r.Render(nil)
if !strings.Contains(got, `data-variant="desktop"`) {
t.Fatalf("Responsive.Render(nil) should still render the variant wrapper, got:\n%s", got)
}
if !strings.Contains(got, `data-block="C-0"`) {
t.Fatalf("Responsive.Render(nil) should still render the layout block, got:\n%s", got)
}
}
func TestResponsive_NilLayoutVariant(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", nil).
Variant("mobile", NewLayout("C").C(Raw("m")))
got := r.Render(ctx)
if !strings.Contains(got, `data-variant="desktop"`) {
t.Fatalf("nil layout variant should still render its wrapper, got:\n%s", got)
}
if strings.Contains(got, "<nil>") {
t.Fatalf("nil layout variant should not render placeholder text, got:\n%s", got)
}
if !strings.Contains(got, `data-variant="mobile"`) {
t.Fatalf("responsive should still render subsequent variants, got:\n%s", got)
}
}
func TestResponsive_Attributes(t *testing.T) {
ctx := NewContext()
r := Attr(NewResponsive().
Variant("desktop", NewLayout("C").C(Raw("main"))).
Variant("mobile", NewLayout("C").C(Raw("main"))),
"aria-label", "Responsive content",
)
r = Attr(r, "class", "responsive-shell")
got := r.Render(ctx)
if count := strings.Count(got, `aria-label="Responsive content"`); count != 2 {
t.Fatalf("responsive attrs should apply to each wrapper, got %d in:\n%s", count, got)
}
if count := strings.Count(got, `class="responsive-shell"`); count != 2 {
t.Fatalf("responsive class should apply to each wrapper, got %d in:\n%s", count, got)
}
if !strings.Contains(got, `aria-label="Responsive content" class="responsive-shell" data-variant="desktop"`) {
t.Fatalf("responsive wrapper attrs should be sorted and preserved, got:\n%s", got)
}
}
func TestResponsive_ReservedVariantAttributeIsIgnored(t *testing.T) {
ctx := NewContext()
r := Attr(NewResponsive().
Variant("desktop", NewLayout("C").C(Raw("main"))),
"data-variant", "override",
)
got := r.Render(ctx)
if count := strings.Count(got, `data-variant=`); count != 1 {
t.Fatalf("responsive wrapper should emit exactly one data-variant attribute, got %d in:\n%s", count, got)
}
if !strings.Contains(got, `data-variant="desktop"`) {
t.Fatalf("responsive wrapper should preserve its own variant name, got:\n%s", got)
}
if strings.Contains(got, `data-variant="override"`) {
t.Fatalf("responsive wrapper should ignore reserved data-variant attrs, got:\n%s", got)
}
}
func TestVariantSelector(t *testing.T) {
tests := []struct {
name string
variant string
want string
}{
{name: "plain", variant: "desktop", want: `[data-variant="desktop"]`},
{name: "escaped", variant: `desk"top\` + "\n" + `line`, want: `[data-variant="desk\"top\\\a line"]`},
{name: "control char", variant: "tab\tname", want: `[data-variant="tab\9 name"]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := VariantSelector(tt.variant)
if got != tt.want {
t.Fatalf("VariantSelector(%q) = %q, want %q", tt.variant, got, tt.want)
}
})
}
}
func TestScopeVariant(t *testing.T) {
tests := []struct {
name string
variant string
selector string
want string
}{
{name: "scope", variant: "desktop", selector: ".nav", want: `[data-variant="desktop"] .nav`},
{name: "empty selector", variant: "mobile", selector: "", want: `[data-variant="mobile"]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScopeVariant(tt.variant, tt.selector)
if got != tt.want {
t.Fatalf("ScopeVariant(%q, %q) = %q, want %q", tt.variant, tt.selector, got, tt.want)
}
})
}
}
func TestScopeVariant_MultipleSelectors(t *testing.T) {
got := ScopeVariant("desktop", ".nav, .sidebar")
want := `[data-variant="desktop"] .nav, [data-variant="desktop"] .sidebar`
if got != want {
t.Fatalf("ScopeVariant with selector list = %q, want %q", got, want)
}
}
func TestScopeVariant_IgnoresEmptySelectorSegments(t *testing.T) {
got := ScopeVariant("desktop", ".nav, , .sidebar,")
want := `[data-variant="desktop"] .nav, [data-variant="desktop"] .sidebar`
if got != want {
t.Fatalf("ScopeVariant should skip empty selector segments = %q, want %q", got, want)
}
}
func TestScopeVariant_PreservesNestedCommas(t *testing.T) {
got := ScopeVariant("desktop", `:is(.nav, .sidebar), .footer`)
want := `[data-variant="desktop"] :is(.nav, .sidebar), [data-variant="desktop"] .footer`
if got != want {
t.Fatalf("ScopeVariant should preserve nested commas = %q, want %q", got, want)
}
}
func TestScopeVariant_PreservesEscapedSelectorCharacters(t *testing.T) {
got := ScopeVariant("desktop", `.nav\,primary, [data-state="open\,expanded"]`)
want := `[data-variant="desktop"] .nav\,primary, [data-variant="desktop"] [data-state="open\,expanded"]`
if got != want {
t.Fatalf("ScopeVariant should preserve escaped selector characters = %q, want %q", got, want)
}
}

11
text_translate.go Normal file
View file

@ -0,0 +1,11 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateText(ctx *Context, key string, args ...any) string {
if ctx != nil && ctx.service != nil {
return ctx.service.T(key, args...)
}
return translateDefault(key, args...)
}

11
text_translate_default.go Normal file
View file

@ -0,0 +1,11 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import i18n "dappco.re/go/core/i18n"
func translateDefault(key string, args ...any) string {
return i18n.T(key, args...)
}

9
text_translate_js.go Normal file
View file

@ -0,0 +1,9 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateDefault(key string, _ ...any) string {
return key
}

View file

@ -0,0 +1,28 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import i18n "dappco.re/go/core/i18n"
func cloneTranslator(svc Translator, locale string) Translator {
if svc == nil {
return nil
}
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
if clone := cloner.Clone(); clone != nil {
applyLocaleToService(clone, locale)
return clone
}
}
if current, ok := svc.(*i18n.Service); ok && current != nil {
clone := &i18n.Service{}
applyLocaleToService(clone, locale)
return clone
}
return svc
}

25
translator_clone_js.go Normal file
View file

@ -0,0 +1,25 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
func cloneTranslator(svc Translator, locale string) Translator {
if svc == nil {
return nil
}
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
if clone := cloner.Clone(); clone != nil {
applyLocaleToService(clone, locale)
return clone
}
}
if current, ok := svc.(*defaultTranslator); ok && current != nil {
clone := *current
return &clone
}
return svc
}