diff --git a/.core/go.yaml b/.core/go.yaml
new file mode 100644
index 0000000..cde4a66
--- /dev/null
+++ b/.core/go.yaml
@@ -0,0 +1,13 @@
+# .core/go.yaml — Go subtree marker for the Lethean canon.
+# Presence of .core/ + go/ marks this repo as a managed canonical repo.
+# Audit-sweep + dispatch tooling discover via this marker.
+version: 1
+
+project:
+ name: go-html
+ type: go
+
+go:
+ module: dappco.re/go/html
+ subtree: go
+ go_version: "1.26.2"
diff --git a/.gitignore b/.gitignore
index 509a6b1..315abcb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
.idea/
.vscode/
*.log
-.core/
dist/
+.core/workspace/
+.core/vm/
+.core/cache/
+.core/runtime/
+.core/*.duckdb*
+.core/coderabbit-findings.txt
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..4153cd3
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,287 @@
+ EUROPEAN UNION PUBLIC LICENCE v. 1.2
+ EUPL © the European Union 2007, 2016
+
+This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
+below) which is provided under the terms of this Licence. Any use of the Work,
+other than as authorised under this Licence is prohibited (to the extent such
+use is covered by a right of the copyright holder of the Work).
+
+The Work is provided under the terms of this Licence when the Licensor (as
+defined below) has placed the following notice immediately following the
+copyright notice for the Work:
+
+ Licensed under the EUPL
+
+or has expressed by any other means his willingness to license under the EUPL.
+
+1. Definitions
+
+In this Licence, the following terms have the following meaning:
+
+- ‘The Licence’: this Licence.
+
+- ‘The Original Work’: the work or software distributed or communicated by the
+ Licensor under this Licence, available as Source Code and also as Executable
+ Code as the case may be.
+
+- ‘Derivative Works’: the works or software that could be created by the
+ Licensee, based upon the Original Work or modifications thereof. This Licence
+ does not define the extent of modification or dependence on the Original Work
+ required in order to classify a work as a Derivative Work; this extent is
+ determined by copyright law applicable in the country mentioned in Article 15.
+
+- ‘The Work’: the Original Work or its Derivative Works.
+
+- ‘The Source Code’: the human-readable form of the Work which is the most
+ convenient for people to study and modify.
+
+- ‘The Executable Code’: any code which has generally been compiled and which is
+ meant to be interpreted by a computer as a program.
+
+- ‘The Licensor’: the natural or legal person that distributes or communicates
+ the Work under the Licence.
+
+- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
+ Licence, or otherwise contributes to the creation of a Derivative Work.
+
+- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
+ the Work under the terms of the Licence.
+
+- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
+ renting, distributing, communicating, transmitting, or otherwise making
+ available, online or offline, copies of the Work or providing access to its
+ essential functionalities at the disposal of any other natural or legal
+ person.
+
+2. Scope of the rights granted by the Licence
+
+The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
+sublicensable licence to do the following, for the duration of copyright vested
+in the Original Work:
+
+- use the Work in any circumstance and for all usage,
+- reproduce the Work,
+- modify the Work, and make Derivative Works based upon the Work,
+- communicate to the public, including the right to make available or display
+ the Work or copies thereof to the public and perform publicly, as the case may
+ be, the Work,
+- distribute the Work or copies thereof,
+- lend and rent the Work or copies thereof,
+- sublicense rights in the Work or copies thereof.
+
+Those rights can be exercised on any media, supports and formats, whether now
+known or later invented, as far as the applicable law permits so.
+
+In the countries where moral rights apply, the Licensor waives his right to
+exercise his moral right to the extent allowed by law in order to make effective
+the licence of the economic rights here above listed.
+
+The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
+any patents held by the Licensor, to the extent necessary to make use of the
+rights granted on the Work under this Licence.
+
+3. Communication of the Source Code
+
+The Licensor may provide the Work either in its Source Code form, or as
+Executable Code. If the Work is provided as Executable Code, the Licensor
+provides in addition a machine-readable copy of the Source Code of the Work
+along with each copy of the Work that the Licensor distributes or indicates, in
+a notice following the copyright notice attached to the Work, a repository where
+the Source Code is easily and freely accessible for as long as the Licensor
+continues to distribute or communicate the Work.
+
+4. Limitations on copyright
+
+Nothing in this Licence is intended to deprive the Licensee of the benefits from
+any exception or limitation to the exclusive rights of the rights owners in the
+Work, of the exhaustion of those rights or of other applicable limitations
+thereto.
+
+5. Obligations of the Licensee
+
+The grant of the rights mentioned above is subject to some restrictions and
+obligations imposed on the Licensee. Those obligations are the following:
+
+Attribution right: The Licensee shall keep intact all copyright, patent or
+trademarks notices and all notices that refer to the Licence and to the
+disclaimer of warranties. The Licensee must include a copy of such notices and a
+copy of the Licence with every copy of the Work he/she distributes or
+communicates. The Licensee must cause any Derivative Work to carry prominent
+notices stating that the Work has been modified and the date of modification.
+
+Copyleft clause: If the Licensee distributes or communicates copies of the
+Original Works or Derivative Works, this Distribution or Communication will be
+done under the terms of this Licence or of a later version of this Licence
+unless the Original Work is expressly distributed only under this version of the
+Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
+(becoming Licensor) cannot offer or impose any additional terms or conditions on
+the Work or Derivative Work that alter or restrict the terms of the Licence.
+
+Compatibility clause: If the Licensee Distributes or Communicates Derivative
+Works or copies thereof based upon both the Work and another work licensed under
+a Compatible Licence, this Distribution or Communication can be done under the
+terms of this Compatible Licence. For the sake of this clause, ‘Compatible
+Licence’ refers to the licences listed in the appendix attached to this Licence.
+Should the Licensee's obligations under the Compatible Licence conflict with
+his/her obligations under this Licence, the obligations of the Compatible
+Licence shall prevail.
+
+Provision of Source Code: When distributing or communicating copies of the Work,
+the Licensee will provide a machine-readable copy of the Source Code or indicate
+a repository where this Source will be easily and freely available for as long
+as the Licensee continues to distribute or communicate the Work.
+
+Legal Protection: This Licence does not grant permission to use the trade names,
+trademarks, service marks, or names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the copyright notice.
+
+6. Chain of Authorship
+
+The original Licensor warrants that the copyright in the Original Work granted
+hereunder is owned by him/her or licensed to him/her and that he/she has the
+power and authority to grant the Licence.
+
+Each Contributor warrants that the copyright in the modifications he/she brings
+to the Work are owned by him/her or licensed to him/her and that he/she has the
+power and authority to grant the Licence.
+
+Each time You accept the Licence, the original Licensor and subsequent
+Contributors grant You a licence to their contributions to the Work, under the
+terms of this Licence.
+
+7. Disclaimer of Warranty
+
+The Work is a work in progress, which is continuously improved by numerous
+Contributors. It is not a finished work and may therefore contain defects or
+‘bugs’ inherent to this type of development.
+
+For the above reason, the Work is provided under the Licence on an ‘as is’ basis
+and without warranties of any kind concerning the Work, including without
+limitation merchantability, fitness for a particular purpose, absence of defects
+or errors, accuracy, non-infringement of intellectual property rights other than
+copyright as stated in Article 6 of this Licence.
+
+This disclaimer of warranty is an essential part of the Licence and a condition
+for the grant of any rights to the Work.
+
+8. Disclaimer of Liability
+
+Except in the cases of wilful misconduct or damages directly caused to natural
+persons, the Licensor will in no event be liable for any direct or indirect,
+material or moral, damages of any kind, arising out of the Licence or of the use
+of the Work, including without limitation, damages for loss of goodwill, work
+stoppage, computer failure or malfunction, loss of data or any commercial
+damage, even if the Licensor has been advised of the possibility of such damage.
+However, the Licensor will be liable under statutory product liability laws as
+far such laws apply to the Work.
+
+9. Additional agreements
+
+While distributing the Work, You may choose to conclude an additional agreement,
+defining obligations or services consistent with this Licence. However, if
+accepting obligations, You may act only on your own behalf and on your sole
+responsibility, not on behalf of the original Licensor or any other Contributor,
+and only if You agree to indemnify, defend, and hold each Contributor harmless
+for any liability incurred by, or claims asserted against such Contributor by
+the fact You have accepted any warranty or additional liability.
+
+10. Acceptance of the Licence
+
+The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
+placed under the bottom of a window displaying the text of this Licence or by
+affirming consent in any other similar way, in accordance with the rules of
+applicable law. Clicking on that icon indicates your clear and irrevocable
+acceptance of this Licence and all of its terms and conditions.
+
+Similarly, you irrevocably accept this Licence and all of its terms and
+conditions by exercising any rights granted to You by Article 2 of this Licence,
+such as the use of the Work, the creation by You of a Derivative Work or the
+Distribution or Communication by You of the Work or copies thereof.
+
+11. Information to the public
+
+In case of any Distribution or Communication of the Work by means of electronic
+communication by You (for example, by offering to download the Work from a
+remote location) the distribution channel or media (for example, a website) must
+at least provide to the public the information requested by the applicable law
+regarding the Licensor, the Licence and the way it may be accessible, concluded,
+stored and reproduced by the Licensee.
+
+12. Termination of the Licence
+
+The Licence and the rights granted hereunder will terminate automatically upon
+any breach by the Licensee of the terms of the Licence.
+
+Such a termination will not terminate the licences of any person who has
+received the Work from the Licensee under the Licence, provided such persons
+remain in full compliance with the Licence.
+
+13. Miscellaneous
+
+Without prejudice of Article 9 above, the Licence represents the complete
+agreement between the Parties as to the Work.
+
+If any provision of the Licence is invalid or unenforceable under applicable
+law, this will not affect the validity or enforceability of the Licence as a
+whole. Such provision will be construed or reformed so as necessary to make it
+valid and enforceable.
+
+The European Commission may publish other linguistic versions or new versions of
+this Licence or updated versions of the Appendix, so far this is required and
+reasonable, without reducing the scope of the rights granted by the Licence. New
+versions of the Licence will be published with a unique version number.
+
+All linguistic versions of this Licence, approved by the European Commission,
+have identical value. Parties can take advantage of the linguistic version of
+their choice.
+
+14. Jurisdiction
+
+Without prejudice to specific agreement between parties,
+
+- any litigation resulting from the interpretation of this License, arising
+ between the European Union institutions, bodies, offices or agencies, as a
+ Licensor, and any Licensee, will be subject to the jurisdiction of the Court
+ of Justice of the European Union, as laid down in article 272 of the Treaty on
+ the Functioning of the European Union,
+
+- any litigation arising between other parties and resulting from the
+ interpretation of this License, will be subject to the exclusive jurisdiction
+ of the competent court where the Licensor resides or conducts its primary
+ business.
+
+15. Applicable Law
+
+Without prejudice to specific agreement between parties,
+
+- this Licence shall be governed by the law of the European Union Member State
+ where the Licensor has his seat, resides or has his registered office,
+
+- this licence shall be governed by Belgian law if the Licensor has no seat,
+ residence or registered office inside a European Union Member State.
+
+Appendix
+
+‘Compatible Licences’ according to Article 5 EUPL are:
+
+- GNU General Public License (GPL) v. 2, v. 3
+- GNU Affero General Public License (AGPL) v. 3
+- Open Software License (OSL) v. 2.1, v. 3.0
+- Eclipse Public License (EPL) v. 1.0
+- CeCILL v. 2.0, v. 2.1
+- Mozilla Public Licence (MPL) v. 2
+- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
+- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
+ works other than software
+- European Union Public Licence (EUPL) v. 1.1, v. 1.2
+- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
+ Reciprocity (LiLiQ-R+).
+
+The European Commission may update this Appendix to later versions of the above
+licences without producing a new version of the EUPL, as long as they provide
+the rights granted in Article 2 of this Licence and protect the covered Source
+Code from exclusive appropriation.
+
+All other changes or additions to this Appendix require the production of a new
+EUPL version.
diff --git a/SESSION-BRIEF.md b/SESSION-BRIEF.md
deleted file mode 100644
index 82fb5fb..0000000
--- a/SESSION-BRIEF.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Session Brief: core/go-html
-
-**Repo**: `forge.lthn.ai/core/go-html` (clone at `/tmp/core-go-html`)
-**Module**: `forge.lthn.ai/core/go-html`
-**Status**: Current tests pass; WASM build is within budget and codegen emits JS plus TypeScript defs
-**Wiki**: https://forge.lthn.ai/core/go-html/wiki (6 pages)
-
-## What This Is
-
-HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composable node trees with:
-- **Node interface**: El, Text, Raw, If, Unless, Each[T], Switch, Entitled
-- **HLCRF Layout**: Header/Left/Content/Right/Footer with ARIA roles
-- **Responsive**: Multi-variant breakpoint rendering
-- **Pipeline**: Render → strip tags → tokenise via go-i18n/reversal → GrammarImprint
-- **WASM target**: `cmd/wasm/` exposes `renderToString()` to JS
-- **Codegen**: Web Component classes with closed Shadow DOM plus `.d.ts` generation
-
-## Current State
-
-| Area | Status |
-|------|--------|
-| Core (node, layout, responsive, pipeline) | SOLID — all tested, clean API |
-| Tests | Passing |
-| go vet | Clean |
-| TODOs/FIXMEs | None |
-| WASM build | PASS — within the 1 MB gzip gate |
-| Codegen | Working — generates WC classes and `.d.ts` definitions |
-
-## Dependencies
-
-- `forge.lthn.ai/core/go-i18n` (replace directive → `../go-i18n`)
-- `github.com/stretchr/testify` v1.11.1
-- `golang.org/x/text` v0.33.0
-
-## Priority Work
-
-No active blockers recorded here. See `docs/history.md` for the remaining design choices and deferred ideas that were captured during earlier implementation phases.
-
-## File Map
-
-```
-/tmp/core-go-html/
-├── node.go (254) + node_test.go (206)
-├── layout.go (119) + layout_test.go (116)
-├── pipeline.go (83) + pipeline_test.go (128)
-├── responsive.go (39) + responsive_test.go (89)
-├── context.go (27)
-├── render.go (9) + render_test.go (97)
-├── path.go (22) + path_test.go (86)
-├── integration_test.go (52)
-├── cmd/wasm/
-│ ├── main.go (78) — WASM entry point
-│ ├── register.go (18) + register_test.go (24)
-├── codegen/
-│ ├── codegen.go (90) + codegen_test.go (54)
-├── go.mod
-└── Makefile
-```
-
-## Conventions
-
-- UK English (colour, organisation)
-- `declare(strict_types=1)` equivalent: all types annotated
-- Tests: testify assert/require
-- Licence: EUPL-1.2
diff --git a/go/cmd/codegen/cli_test.go b/go/cmd/codegen/cli_test.go
new file mode 100644
index 0000000..2c100bd
--- /dev/null
+++ b/go/cmd/codegen/cli_test.go
@@ -0,0 +1,395 @@
+//go:build !js
+
+package main
+
+import (
+ "testing"
+ "time"
+
+ core "dappco.re/go"
+)
+
+// TestInputPathFromOptions_Good — explicit -input wins over the stdin default.
+func TestInputPathFromOptions_Good(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "input", Value: "/tmp/slots.json"})
+ if got := inputPathFromOptions(opts); got != "/tmp/slots.json" {
+ t.Fatalf("want /tmp/slots.json, got %q", got)
+ }
+}
+
+// TestInputPathFromOptions_Ugly — absent -input falls back to the stdin path.
+func TestInputPathFromOptions_Ugly(t *testing.T) {
+ opts := core.NewOptions()
+ if got := inputPathFromOptions(opts); got != defaultInputPath {
+ t.Fatalf("want %q, got %q", defaultInputPath, got)
+ }
+}
+
+// TestOutputPathFromOptions_Good — explicit -output wins over the stdout default.
+func TestOutputPathFromOptions_Good(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "output", Value: "/tmp/out.js"})
+ if got := outputPathFromOptions(opts); got != "/tmp/out.js" {
+ t.Fatalf("want /tmp/out.js, got %q", got)
+ }
+}
+
+// TestOutputPathFromOptions_Ugly — absent -output falls back to the stdout path.
+func TestOutputPathFromOptions_Ugly(t *testing.T) {
+ opts := core.NewOptions()
+ if got := outputPathFromOptions(opts); got != defaultOutputPath {
+ t.Fatalf("want %q, got %q", defaultOutputPath, got)
+ }
+}
+
+// TestCodegenCommandFlags_Good — the shared flag set carries the documented defaults.
+func TestCodegenCommandFlags_Good(t *testing.T) {
+ flags := codegenCommandFlags()
+ if flags.Bool("types") {
+ t.Fatal("expected types default false")
+ }
+ if got := flags.String("input"); got != "" {
+ t.Fatalf("expected empty input default, got %q", got)
+ }
+ if got := flags.String("output"); got != "" {
+ t.Fatalf("expected empty output default, got %q", got)
+ }
+ if got := flags.String("poll"); got != defaultPollInterval.String() {
+ t.Fatalf("expected poll default %q, got %q", defaultPollInterval.String(), got)
+ }
+}
+
+// TestPollIntervalFromOptions_Good — a valid duration string parses through.
+func TestPollIntervalFromOptions_Good(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "poll", Value: "500ms"})
+ result := pollIntervalFromOptions(opts)
+ if !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ got, _ := result.Value.(time.Duration)
+ if got != 500*time.Millisecond {
+ t.Fatalf("want 500ms, got %v", got)
+ }
+}
+
+// TestPollIntervalFromOptions_Ugly — empty poll falls back to the default interval.
+func TestPollIntervalFromOptions_Ugly(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "poll", Value: ""})
+ result := pollIntervalFromOptions(opts)
+ if !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ got, _ := result.Value.(time.Duration)
+ if got != defaultPollInterval {
+ t.Fatalf("want %v, got %v", defaultPollInterval, got)
+ }
+}
+
+// TestPollIntervalFromOptions_Bad — an unparseable duration is a failed result.
+func TestPollIntervalFromOptions_Bad(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "poll", Value: "not-a-duration"})
+ result := pollIntervalFromOptions(opts)
+ if result.OK {
+ t.Fatal("expected error result, got OK")
+ }
+ if !core.Contains(result.Error(), "invalid poll interval") {
+ t.Fatalf("expected error to contain %q, got %v", "invalid poll interval", result.Error())
+ }
+}
+
+// TestRunGenerateCommand_Good — generate writes a bundle to an explicit output file.
+func TestRunGenerateCommand_Good(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := core.Path(dir, "slots.json")
+ outputPath := core.Path(dir, "bundle.js")
+ if err := writeTextFile(inputPath, `{"H":"nav-bar"}`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: inputPath},
+ core.Option{Key: "output", Value: outputPath},
+ )
+ if result := runGenerateCommand(opts, false); !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+
+ got, err := readTextFile(outputPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !core.Contains(got, "NavBar") {
+ t.Fatalf("expected bundle to contain NavBar, got %q", got)
+ }
+}
+
+// TestRunGenerateCommand_Bad — invalid JSON in the input file surfaces an error.
+func TestRunGenerateCommand_Bad(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := core.Path(dir, "slots.json")
+ outputPath := core.Path(dir, "bundle.js")
+ if err := writeTextFile(inputPath, `not json`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: inputPath},
+ core.Option{Key: "output", Value: outputPath},
+ )
+ if result := runGenerateCommand(opts, false); result.OK {
+ t.Fatal("expected error result, got OK")
+ }
+}
+
+// TestRunGenerateCommand_TypesGood — types mode emits TypeScript declarations.
+func TestRunGenerateCommand_TypesGood(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := core.Path(dir, "slots.json")
+ outputPath := core.Path(dir, "bundle.d.ts")
+ if err := writeTextFile(inputPath, `{"H":"nav-bar"}`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: inputPath},
+ core.Option{Key: "output", Value: outputPath},
+ )
+ if result := runGenerateCommand(opts, true); !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+
+ got, err := readTextFile(outputPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !core.Contains(got, "declare global") {
+ t.Fatalf("expected declarations, got %q", got)
+ }
+}
+
+// TestRunWatchCommand_Bad — watch without -input is rejected before polling.
+func TestRunWatchCommand_Bad(t *testing.T) {
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: ""},
+ core.Option{Key: "output", Value: ""},
+ core.Option{Key: "poll", Value: "5ms"},
+ )
+ result := runWatchCommand(nil, opts, false)
+ if result.OK {
+ t.Fatal("expected error result, got OK")
+ }
+ if !core.Contains(result.Error(), "watch mode requires -input") {
+ t.Fatalf("expected error to contain %q, got %v", "watch mode requires -input", result.Error())
+ }
+}
+
+// TestRunWatchCommand_BadPoll — an unparseable poll interval short-circuits.
+func TestRunWatchCommand_BadPoll(t *testing.T) {
+ opts := core.NewOptions(core.Option{Key: "poll", Value: "nope"})
+ result := runWatchCommand(nil, opts, false)
+ if result.OK {
+ t.Fatal("expected error result, got OK")
+ }
+ if !core.Contains(result.Error(), "invalid poll interval") {
+ t.Fatalf("expected error to contain %q, got %v", "invalid poll interval", result.Error())
+ }
+}
+
+// TestNewCodegenApp_Good — the app registers every documented command path.
+func TestNewCodegenApp_Good(t *testing.T) {
+ c := newCodegenApp()
+ if c == nil {
+ t.Fatal("expected non-nil core app")
+ }
+ if c.Cli() == nil {
+ t.Fatal("expected a CLI to be present")
+ }
+}
+
+// TestRegisterCodegenCommands_Good — registration is idempotent on a bare core.
+func TestRegisterCodegenCommands_Good(t *testing.T) {
+ c := core.New(core.WithOption("name", "codegen"))
+ registerCodegenCommands(c)
+ if c.Cli() == nil {
+ t.Fatal("expected a CLI after registration")
+ }
+}
+
+// TestGenerateCommandAction_Good — the registered "generate" action runs end-to-end.
+func TestGenerateCommandAction_Good(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := core.Path(dir, "slots.json")
+ outputPath := core.Path(dir, "bundle.js")
+ if err := writeTextFile(inputPath, `{"H":"nav-bar"}`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ c := newCodegenApp()
+ cmd, ok := c.Command("generate").Value.(*core.Command)
+ if !ok || cmd.Action == nil {
+ t.Fatal("expected a generate command with an action")
+ }
+
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: inputPath},
+ core.Option{Key: "output", Value: outputPath},
+ )
+ if result := cmd.Action(opts); !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ got, err := readTextFile(outputPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !core.Contains(got, "NavBar") {
+ t.Fatalf("expected bundle to contain NavBar, got %q", got)
+ }
+}
+
+// TestTypesCommandAction_Good — the registered "types" action forces declarations.
+func TestTypesCommandAction_Good(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := core.Path(dir, "slots.json")
+ outputPath := core.Path(dir, "bundle.d.ts")
+ if err := writeTextFile(inputPath, `{"H":"nav-bar"}`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ c := newCodegenApp()
+ cmd, ok := c.Command("types").Value.(*core.Command)
+ if !ok || cmd.Action == nil {
+ t.Fatal("expected a types command with an action")
+ }
+
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: inputPath},
+ core.Option{Key: "output", Value: outputPath},
+ )
+ if result := cmd.Action(opts); !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ got, err := readTextFile(outputPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !core.Contains(got, "declare global") {
+ t.Fatalf("expected declarations, got %q", got)
+ }
+}
+
+// TestWatchCommandAction_Bad — the registered "watch" action rejects missing -input.
+func TestWatchCommandAction_Bad(t *testing.T) {
+ c := newCodegenApp()
+ cmd, ok := c.Command("watch").Value.(*core.Command)
+ if !ok || cmd.Action == nil {
+ t.Fatal("expected a watch command with an action")
+ }
+ opts := core.NewOptions(
+ core.Option{Key: "input", Value: ""},
+ core.Option{Key: "output", Value: ""},
+ )
+ if result := cmd.Action(opts); result.OK {
+ t.Fatal("expected error result, got OK")
+ }
+}
+
+// TestReadLocalFile_Bad — a missing named file is a failed result.
+func TestReadLocalFile_Bad(t *testing.T) {
+ dir := t.TempDir()
+ missing := core.Path(dir, "does-not-exist.json")
+ result := readLocalFile(missing)
+ if result.OK {
+ t.Fatal("expected error result for missing file, got OK")
+ }
+}
+
+// TestReadLocalFile_Good — a named file reads back its bytes.
+func TestReadLocalFile_Good(t *testing.T) {
+ dir := t.TempDir()
+ path := core.Path(dir, "slots.json")
+ if err := writeTextFile(path, `{"H":"nav-bar"}`); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ result := readLocalFile(path)
+ if !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ data, _ := result.Value.([]byte)
+ if !core.Contains(string(data), "nav-bar") {
+ t.Fatalf("expected file contents, got %q", string(data))
+ }
+}
+
+// TestWriteLocalFile_Good — a named file is written with the content.
+func TestWriteLocalFile_Good(t *testing.T) {
+ dir := t.TempDir()
+ path := core.Path(dir, "out.js")
+ if result := writeLocalFile(path, "hello"); !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ got, err := readTextFile(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "hello" {
+ t.Fatalf("want hello, got %q", got)
+ }
+}
+
+// TestWriteLocalFile_Bad — writing under a path whose parent is a file fails.
+func TestWriteLocalFile_Bad(t *testing.T) {
+ dir := t.TempDir()
+ parent := core.Path(dir, "afile")
+ if err := writeTextFile(parent, "x"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // parent is a regular file, so treating it as a directory must fail.
+ nested := core.Path(parent, "child.js")
+ if result := writeLocalFile(nested, "data"); result.OK {
+ t.Fatal("expected error result writing under a file, got OK")
+ }
+}
+
+// TestResultFromError_Good — a nil error becomes an OK result.
+func TestResultFromError_Good(t *testing.T) {
+ if result := resultFromError(nil); !result.OK {
+ t.Fatalf("expected OK, got %v", result.Error())
+ }
+}
+
+// TestResultFromError_Bad — a non-nil error becomes a failed result.
+func TestResultFromError_Bad(t *testing.T) {
+ if result := resultFromError(core.E("codegen.test", "boom", nil)); result.OK {
+ t.Fatal("expected failed result, got OK")
+ }
+}
+
+// TestResultError_Good — an OK input result stays OK regardless of op/msg.
+func TestResultError_Good(t *testing.T) {
+ if result := resultError("codegen.test", "wrap", core.Ok(nil)); !result.OK {
+ t.Fatalf("expected OK passthrough, got %v", result.Error())
+ }
+}
+
+// TestResultError_Bad — a failed input result is wrapped with op + msg.
+func TestResultError_Bad(t *testing.T) {
+ in := core.Fail(core.E("inner", "cause", nil))
+ result := resultError("codegen.test", "wrap", in)
+ if result.OK {
+ t.Fatal("expected failed result, got OK")
+ }
+ if !core.Contains(result.Error(), "wrap") {
+ t.Fatalf("expected wrapped message, got %v", result.Error())
+ }
+}
+
+// TestResultError_Ugly — a failed result with a non-error Value still wraps cleanly.
+func TestResultError_Ugly(t *testing.T) {
+ in := core.Result{Value: "not-an-error", OK: false}
+ result := resultError("codegen.test", "wrap", in)
+ if result.OK {
+ t.Fatal("expected failed result, got OK")
+ }
+ if !core.Contains(result.Error(), "wrap") {
+ t.Fatalf("expected wrapped message, got %v", result.Error())
+ }
+}
diff --git a/go/cmd/wasm/render_layout_test.go b/go/cmd/wasm/render_layout_test.go
index f2e0bd6..f50f926 100644
--- a/go/cmd/wasm/render_layout_test.go
+++ b/go/cmd/wasm/render_layout_test.go
@@ -13,3 +13,50 @@ func TestRenderLayout_EmptyStringSlotGood(t *testing.T) {
t.Fatalf("renderLayout with empty slot = %q, want %q", got, want)
}
}
+
+func TestRenderLayout_EmptyVariantBad(t *testing.T) {
+ if got := renderLayout("", "en-GB", map[string]string{"C": "x"}); got != "" {
+ t.Fatalf("renderLayout with empty variant = %q, want empty string", got)
+ }
+}
+
+func TestRenderLayout_AllSlotsGood(t *testing.T) {
+ got := renderLayout("HLCRF", "en-GB", map[string]string{
+ "H": "head",
+ "L": "left",
+ "C": "centre",
+ "R": "right",
+ "F": "foot",
+ })
+ for _, want := range []string{"head", "left", "centre", "right", "foot"} {
+ if !contains(got, want) {
+ t.Fatalf("renderLayout output %q missing %q", got, want)
+ }
+ }
+}
+
+func TestRenderLayout_UnknownSlotKeyUgly(t *testing.T) {
+ // "X" is not in the recognised slot list and must be ignored without panic.
+ got := renderLayout("C", "", map[string]string{"X": "ignored", "C": "kept"})
+ if !contains(got, "kept") {
+ t.Fatalf("renderLayout output %q missing recognised slot content", got)
+ }
+ if contains(got, "ignored") {
+ t.Fatalf("renderLayout output %q must not include unknown slot content", got)
+ }
+}
+
+func TestRenderLayout_NoLocaleGood(t *testing.T) {
+ if got := renderLayout("C", "", map[string]string{"C": "body"}); !contains(got, "body") {
+ t.Fatalf("renderLayout with empty locale = %q, want it to contain %q", got, "body")
+ }
+}
+
+func contains(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return len(sub) == 0
+}
diff --git a/go/cmd/wasm/size_test.go b/go/cmd/wasm/size_test.go
index 3d50dde..885905f 100644
--- a/go/cmd/wasm/size_test.go
+++ b/go/cmd/wasm/size_test.go
@@ -9,6 +9,7 @@ import (
"testing"
core "dappco.re/go"
+ corepkg "dappco.re/go/core"
coreio "dappco.re/go/io"
process "dappco.re/go/process"
)
@@ -23,6 +24,10 @@ func TestCmdWasm_WASMBinarySizeGood(t *testing.T) {
t.Skip("skipping WASM build test in short mode")
}
+ if err := process.Init(corepkg.New()); err != nil {
+ t.Fatalf("process.Init: %v", err)
+ }
+
dir := t.TempDir()
out := core.Path(dir, "gohtml.wasm")
diff --git a/go/codegen/escape_test.go b/go/codegen/escape_test.go
new file mode 100644
index 0000000..fba0f78
--- /dev/null
+++ b/go/codegen/escape_test.go
@@ -0,0 +1,142 @@
+//go:build !js
+
+package codegen
+
+import (
+ "testing"
+
+ core "dappco.re/go"
+)
+
+// TestEscapeJSStringLiteral_NamedEscapesGood — backslash, quote and the named
+// C-style control escapes each map to their two-character JS sequence.
+func TestEscapeJSStringLiteral_NamedEscapesGood(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"backslash", "\\", "\\\\"},
+ {"quote", "\"", "\\\""},
+ {"backspace", "\b", "\\b"},
+ {"formfeed", "\f", "\\f"},
+ {"newline", "\n", "\\n"},
+ {"carriage", "\r", "\\r"},
+ {"tab", "\t", "\\t"},
+ {"plain", "abc", "abc"},
+ }
+ for _, tc := range cases {
+ if got := escapeJSStringLiteral(tc.in); got != tc.want {
+ t.Fatalf("%s: escapeJSStringLiteral(%q) = %q, want %q", tc.name, tc.in, got, tc.want)
+ }
+ }
+}
+
+// TestEscapeJSStringLiteral_LineSeparatorsGood — U+2028 and U+2029 break JS
+// string literals if left raw, so they must be unicode-escaped.
+func TestEscapeJSStringLiteral_LineSeparatorsGood(t *testing.T) {
+ if got := escapeJSStringLiteral(string(rune(0x2028))); got != "\\u2028" {
+ t.Fatalf("U+2028 escaped to %q, want %q", got, "\\u2028")
+ }
+ if got := escapeJSStringLiteral(string(rune(0x2029))); got != "\\u2029" {
+ t.Fatalf("U+2029 escaped to %q, want %q", got, "\\u2029")
+ }
+}
+
+// TestEscapeJSStringLiteral_ControlCharUgly — a sub-0x20 control character that
+// has no named escape falls through to a \uXXXX sequence.
+func TestEscapeJSStringLiteral_ControlCharUgly(t *testing.T) {
+ // U+0001 (start of heading) has no named escape.
+ if got := escapeJSStringLiteral("\x01"); got != "\\u0001" {
+ t.Fatalf("U+0001 escaped to %q, want %q", got, "\\u0001")
+ }
+ // U+001F (unit separator), upper bound of the control range.
+ if got := escapeJSStringLiteral("\x1f"); got != "\\u001F" {
+ t.Fatalf("U+001F escaped to %q, want %q", got, "\\u001F")
+ }
+}
+
+// TestEscapeJSStringLiteral_SurrogatePairUgly — a rune above the BMP is encoded
+// as a UTF-16 surrogate pair of \uXXXX escapes.
+func TestEscapeJSStringLiteral_SurrogatePairUgly(t *testing.T) {
+ // U+1F600 GRINNING FACE -> surrogate pair D83D DE00.
+ if got := escapeJSStringLiteral("\U0001F600"); got != "\\uD83D\\uDE00" {
+ t.Fatalf("emoji escaped to %q, want %q", got, "\\uD83D\\uDE00")
+ }
+}
+
+// TestEscapeJSStringLiteral_EmptyUgly — empty input yields empty output.
+func TestEscapeJSStringLiteral_EmptyUgly(t *testing.T) {
+ if got := escapeJSStringLiteral(""); got != "" {
+ t.Fatalf("empty input escaped to %q, want empty", got)
+ }
+}
+
+// TestIsValidCustomElementTag_ReservedBad — SVG/MathML reserved names are rejected
+// even though they are lowercase and hyphenated.
+func TestIsValidCustomElementTag_ReservedBad(t *testing.T) {
+ asserted := false
+ for _, reserved := range []string{"font-face", "missing-glyph", "color-profile", "annotation-xml"} {
+ if _, known := reservedCustomElementNames[reserved]; !known {
+ continue // only assert against names this build actually reserves
+ }
+ asserted = true
+ if result := GenerateClass(reserved, "C"); result.OK {
+ t.Fatalf("expected reserved tag %q to be rejected", reserved)
+ }
+ }
+ if !asserted {
+ t.Skip("no reserved custom-element names present in this build")
+ }
+}
+
+// TestIsValidCustomElementTag_NonLetterStartBad — a tag whose first rune is not a
+// lowercase ASCII letter is invalid.
+func TestIsValidCustomElementTag_NonLetterStartBad(t *testing.T) {
+ if result := GenerateClass("1-bad", "C"); result.OK {
+ t.Fatal("expected digit-leading tag to be rejected")
+ }
+}
+
+// TestIsValidCustomElementTag_NoHyphenBad — a tag without a hyphen is invalid.
+func TestIsValidCustomElementTag_NoHyphenBad(t *testing.T) {
+ if result := GenerateClass("nohyphen", "C"); result.OK {
+ t.Fatal("expected hyphenless tag to be rejected")
+ }
+}
+
+// TestIsValidCustomElementTag_UppercaseBad — an uppercase letter anywhere is invalid.
+func TestIsValidCustomElementTag_UppercaseBad(t *testing.T) {
+ if result := GenerateClass("nav-Bar", "C"); result.OK {
+ t.Fatal("expected tag with uppercase letter to be rejected")
+ }
+}
+
+// TestIsValidCustomElementTag_InvalidUTF8Bad — a tag containing invalid UTF-8 is
+// rejected before any rune inspection.
+func TestIsValidCustomElementTag_InvalidUTF8Bad(t *testing.T) {
+ // 0xFF is never a valid UTF-8 byte.
+ if result := GenerateClass("nav-\xff", "C"); result.OK {
+ t.Fatal("expected tag with invalid UTF-8 to be rejected")
+ }
+}
+
+// TestIsValidCustomElementTag_WhitespaceBad — embedded whitespace is invalid.
+func TestIsValidCustomElementTag_WhitespaceBad(t *testing.T) {
+ if result := GenerateClass("bad tag", "C"); result.OK {
+ t.Fatal("expected whitespace-containing tag to be rejected")
+ }
+}
+
+// TestGenerateClass_SlotWithControlCharGood — control characters in the slot name
+// survive escaping into a valid, parseable class literal.
+func TestGenerateClass_SlotWithControlCharGood(t *testing.T) {
+ result := GenerateClass("photo-grid", "C\n\"")
+ if !result.OK {
+ t.Fatalf("unexpected error: %v", result.Error())
+ }
+ js, _ := result.Value.(string)
+ if !core.Contains(js, "\\n") || !core.Contains(js, "\\\"") {
+ t.Fatalf("expected slot escapes in output, got %q", js)
+ }
+}
diff --git a/go/each_preserve_test.go b/go/each_preserve_test.go
new file mode 100644
index 0000000..1c6b758
--- /dev/null
+++ b/go/each_preserve_test.go
@@ -0,0 +1,73 @@
+package html
+
+import "testing"
+
+// eachPreserveControlFlowGood renders a single-item Each inside a layout slot so
+// that nodePreservesLayoutPath is consulted for the control-flow child wrapping
+// a nested layout. A non-empty parent path plus a single item is the precise
+// shape that triggers the preserve check on the child.
+func eachPreserveControlFlowGood(t *testing.T, child Node, ctx *Context, wantInner bool) {
+ t.Helper()
+ page := NewLayout("C").C(
+ Each([]Node{child}, func(n Node) Node { return n }),
+ )
+ got := page.Render(ctx)
+ if wantInner && !containsText(got, "inner") {
+ t.Fatalf("expected nested content rendered, got:\n%s", got)
+ }
+ if !wantInner && containsText(got, "inner") {
+ t.Fatalf("did not expect nested content, got:\n%s", got)
+ }
+}
+
+// TestNodePreservesLayoutPath_IfGood — an If wrapping a nested layout preserves
+// the layout path inside a single-item Each.
+func TestNodePreservesLayoutPath_IfGood(t *testing.T) {
+ child := If(func(*Context) bool { return true }, NewLayout("C").C(Raw("inner")))
+ eachPreserveControlFlowGood(t, child, NewContext(), true)
+}
+
+// TestNodePreservesLayoutPath_UnlessGood — an Unless(false) wrapping a nested
+// layout preserves the layout path.
+func TestNodePreservesLayoutPath_UnlessGood(t *testing.T) {
+ child := Unless(func(*Context) bool { return false }, NewLayout("C").C(Raw("inner")))
+ eachPreserveControlFlowGood(t, child, NewContext(), true)
+}
+
+// TestNodePreservesLayoutPath_EntitledGood — a granted Entitled wrapping a nested
+// layout preserves the layout path.
+func TestNodePreservesLayoutPath_EntitledGood(t *testing.T) {
+ ctx := NewContext()
+ ctx.Entitlements = func(feature string) bool { return feature == "pro" }
+ child := Entitled("pro", NewLayout("C").C(Raw("inner")))
+ eachPreserveControlFlowGood(t, child, ctx, true)
+}
+
+// TestNodePreservesLayoutPath_SwitchGood — a matched Switch wrapping a nested
+// layout preserves the layout path.
+func TestNodePreservesLayoutPath_SwitchGood(t *testing.T) {
+ child := Switch(
+ func(*Context) string { return "en" },
+ map[string]Node{"en": NewLayout("C").C(Raw("inner"))},
+ )
+ eachPreserveControlFlowGood(t, child, NewContext(), true)
+}
+
+// TestNodePreservesLayoutPath_LeafChildUgly — a plain leaf child does not preserve
+// the layout path (default branch) but still renders.
+func TestNodePreservesLayoutPath_LeafChildUgly(t *testing.T) {
+ page := NewLayout("C").C(
+ Each([]Node{Raw("inner")}, func(n Node) Node { return n }),
+ )
+ got := page.Render(NewContext())
+ if !containsText(got, "inner") {
+ t.Fatalf("leaf child should still render, got:\n%s", got)
+ }
+}
+
+// TestNodePreservesLayoutPath_ControlFlowFalseUgly — a control-flow node whose
+// condition fails does not preserve the path and renders nothing.
+func TestNodePreservesLayoutPath_ControlFlowFalseUgly(t *testing.T) {
+ child := If(func(*Context) bool { return false }, NewLayout("C").C(Raw("inner")))
+ eachPreserveControlFlowGood(t, child, NewContext(), false)
+}
diff --git a/go/entitled_edge_test.go b/go/entitled_edge_test.go
new file mode 100644
index 0000000..4bce309
--- /dev/null
+++ b/go/entitled_edge_test.go
@@ -0,0 +1,86 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+import (
+ "testing"
+
+ core "dappco.re/go"
+)
+
+// TestEntitled_EmptyFeatureBad — a 2-arg call with an empty feature collapses to
+// the empty sentinel.
+func TestEntitled_EmptyFeatureBad(t *testing.T) {
+ if got := Entitled("", Text("x")).Render(NewContext()); got != "" {
+ t.Fatalf("empty feature should render empty, got %q", got)
+ }
+}
+
+// TestEntitled_NonStringFeatureBad — a 2-arg call whose first arg is not a string
+// collapses to the empty sentinel.
+func TestEntitled_NonStringFeatureBad(t *testing.T) {
+ if got := Entitled(42, Text("x")).Render(NewContext()); got != "" {
+ t.Fatalf("non-string feature should render empty, got %q", got)
+ }
+}
+
+// TestEntitled_NonNodeSecondArgBad — a 2-arg call whose second arg is not a Node
+// collapses to the empty sentinel.
+func TestEntitled_NonNodeSecondArgBad(t *testing.T) {
+ if got := Entitled("pro", "not-a-node").Render(NewContext()); got != "" {
+ t.Fatalf("non-node second arg should render empty, got %q", got)
+ }
+}
+
+// TestEntitled_ThreeArgEmptyFeatureBad — a 3-arg call with an empty feature
+// collapses to the empty sentinel before consulting the checker.
+func TestEntitled_ThreeArgEmptyFeatureBad(t *testing.T) {
+ got := Entitled(stubEntitlementChecker{"": true}, "", Text("x")).Render(NewContext())
+ if got != "" {
+ t.Fatalf("empty feature should render empty, got %q", got)
+ }
+}
+
+// TestEntitled_ThreeArgNonNodeBad — a 3-arg call whose node arg is not a Node
+// collapses to the empty sentinel.
+func TestEntitled_ThreeArgNonNodeBad(t *testing.T) {
+ got := Entitled(stubEntitlementChecker{"pro": true}, "pro", 99).Render(NewContext())
+ if got != "" {
+ t.Fatalf("non-node third arg should render empty, got %q", got)
+ }
+}
+
+// TestEntitled_WrongArityUgly — any other argument count collapses to the empty
+// sentinel.
+func TestEntitled_WrongArityUgly(t *testing.T) {
+ if got := Entitled("only-one").Render(NewContext()); got != "" {
+ t.Fatalf("single arg should render empty, got %q", got)
+ }
+ if got := Entitled().Render(NewContext()); got != "" {
+ t.Fatalf("no args should render empty, got %q", got)
+ }
+}
+
+// TestEmptyNode_RenderWithLayoutPathGood — an empty sentinel inside a layout slot
+// renders as nothing and exercises its layout-path renderer.
+func TestEmptyNode_RenderWithLayoutPathGood(t *testing.T) {
+ // A denied entitlement yields an emptyNode; placing it in a layout slot
+ // drives renderWithLayoutPath + isNilHTMLNode on the sentinel.
+ denied := Entitled(stubEntitlementChecker{"pro": false}, "pro", Text("secret"))
+ layout := NewLayout("C").C(denied)
+
+ got := layout.Render(NewContext())
+ if got == "" {
+ t.Fatal("layout with a denied slot child should still render its container")
+ }
+ if core.Contains(got, "secret") {
+ t.Fatalf("denied content must not appear, got %q", got)
+ }
+}
+
+// TestEmptyNode_IsNilHTMLNodeGood — the empty sentinel reports itself as nil.
+func TestEmptyNode_IsNilHTMLNodeGood(t *testing.T) {
+ if !(emptyNode{}).isNilHTMLNode() {
+ t.Fatal("emptyNode should report isNilHTMLNode() == true")
+ }
+}
diff --git a/go/go.mod b/go/go.mod
index 2273de6..3a08e1c 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -3,6 +3,7 @@ module dappco.re/go/html
go 1.26.2
require (
+ dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/i18n v0.8.0-alpha.1
dappco.re/go/io v0.8.0-alpha.1
dappco.re/go/process v0.8.0-alpha.1
@@ -10,7 +11,6 @@ require (
)
require (
- dappco.re/go/core v0.8.0-alpha.1 // indirect
dappco.re/go/log v0.8.0-alpha.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
@@ -43,7 +43,7 @@ require (
)
require (
- dappco.re/go v0.9.0
+ dappco.re/go v0.10.4
dappco.re/go/inference v0.8.0-alpha.1 // indirect
golang.org/x/text v0.36.0 // indirect
)
diff --git a/go/go.sum b/go/go.sum
index 514efd3..fd3d0ae 100644
--- a/go/go.sum
+++ b/go/go.sum
@@ -1,5 +1,5 @@
-dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0=
-dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ=
+dappco.re/go v0.10.4 h1:vir5AK8AkHbTxhPUT0et6Tc0P8i/i+gLInM0LRLt1EU=
+dappco.re/go v0.10.4/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ=
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/i18n v0.8.0-alpha.1 h1:9LI/PrF41XeQu69eOaBTz3LMrXTJ08O2f1EEATq9k5A=
diff --git a/go/grammar_imprint_test.go b/go/grammar_imprint_test.go
new file mode 100644
index 0000000..8408c2e
--- /dev/null
+++ b/go/grammar_imprint_test.go
@@ -0,0 +1,144 @@
+package html
+
+import (
+ "slices"
+ "testing"
+)
+
+// TestGrammarImprint_ResponsiveGood — a Responsive walks into its first non-nil
+// layout variant and stamps that subtree.
+func TestGrammarImprint_ResponsiveGood(t *testing.T) {
+ r := NewResponsive().
+ Variant("desktop", NewLayout("C").C(El("p", Text("body"))))
+
+ got := (&GrammarImprint{}).Imprint(r, Context{})
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for responsive subtree")
+ }
+}
+
+// TestGrammarImprint_ResponsiveEmptyUgly — a Responsive with no usable variant
+// falls back to a structural stamp of the container itself.
+func TestGrammarImprint_ResponsiveEmptyUgly(t *testing.T) {
+ r := NewResponsive() // no variants
+
+ got := (&GrammarImprint{}).Imprint(r, Context{})
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for empty responsive container")
+ }
+}
+
+// TestGrammarImprint_IfTrueGood — a satisfied If condition stamps its child.
+func TestGrammarImprint_IfTrueGood(t *testing.T) {
+ node := If(func(*Context) bool { return true }, El("p", Text("shown")))
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for satisfied If")
+ }
+ if slices.Contains(got.Tags, "empty") {
+ t.Fatalf("satisfied If should not be tagged empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_IfFalseBad — an unsatisfied If yields an empty stamp.
+func TestGrammarImprint_IfFalseBad(t *testing.T) {
+ node := If(func(*Context) bool { return false }, El("p", Text("hidden")))
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if !slices.Contains(got.Tags, "empty") {
+ t.Fatalf("unsatisfied If should be tagged empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_UnlessTrueGood — an Unless whose condition is false stamps
+// its child.
+func TestGrammarImprint_UnlessTrueGood(t *testing.T) {
+ node := Unless(func(*Context) bool { return false }, El("p", Text("shown")))
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if slices.Contains(got.Tags, "empty") {
+ t.Fatalf("Unless(false) should render its child, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_UnlessFalseBad — an Unless whose condition is true is empty.
+func TestGrammarImprint_UnlessFalseBad(t *testing.T) {
+ node := Unless(func(*Context) bool { return true }, El("p", Text("hidden")))
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if !slices.Contains(got.Tags, "empty") {
+ t.Fatalf("Unless(true) should be tagged empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_EntitledGrantedGood — a granted entitlement stamps the child.
+func TestGrammarImprint_EntitledGrantedGood(t *testing.T) {
+ node := Entitled("pro", El("p", Text("members only")))
+ ctx := Context{Entitlements: func(feature string) bool { return feature == "pro" }}
+
+ got := (&GrammarImprint{}).Imprint(node, ctx)
+ if slices.Contains(got.Tags, "empty") {
+ t.Fatalf("granted entitlement should render its child, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_EntitledDeniedBad — a denied entitlement yields an empty stamp.
+func TestGrammarImprint_EntitledDeniedBad(t *testing.T) {
+ node := Entitled("pro", El("p", Text("members only")))
+ ctx := Context{Entitlements: func(string) bool { return false }}
+
+ got := (&GrammarImprint{}).Imprint(node, ctx)
+ if !slices.Contains(got.Tags, "empty") {
+ t.Fatalf("denied entitlement should be tagged empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_EntitledNoCheckerBad — an entitled node with no checker on
+// the context is treated as denied.
+func TestGrammarImprint_EntitledNoCheckerBad(t *testing.T) {
+ node := Entitled("pro", El("p", Text("members only")))
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if !slices.Contains(got.Tags, "empty") {
+ t.Fatalf("entitlement without a checker should be empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_SwitchHitGood — a Switch whose selector matches a case
+// stamps that case's child.
+func TestGrammarImprint_SwitchHitGood(t *testing.T) {
+ node := Switch(
+ func(*Context) string { return "en" },
+ map[string]Node{"en": El("p", Text("hello"))},
+ )
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if slices.Contains(got.Tags, "empty") {
+ t.Fatalf("matched switch case should render its child, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_SwitchMissBad — a Switch with no matching case is empty.
+func TestGrammarImprint_SwitchMissBad(t *testing.T) {
+ node := Switch(
+ func(*Context) string { return "fr" },
+ map[string]Node{"en": El("p", Text("hello"))},
+ )
+
+ got := (&GrammarImprint{}).Imprint(node, Context{})
+ if !slices.Contains(got.Tags, "empty") {
+ t.Fatalf("unmatched switch should be tagged empty, got %v", got.Tags)
+ }
+}
+
+// TestGrammarImprint_LayoutEmptySlotUgly — a layout whose only slot is present
+// but empty produces a layout-slot stamp.
+func TestGrammarImprint_LayoutEmptySlotUgly(t *testing.T) {
+ layout := NewLayout("C").C() // slot declared, no children
+
+ got := (&GrammarImprint{}).Imprint(layout, Context{})
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for layout with empty slot")
+ }
+}
diff --git a/go/grammar_stamp_test.go b/go/grammar_stamp_test.go
new file mode 100644
index 0000000..f707d85
--- /dev/null
+++ b/go/grammar_stamp_test.go
@@ -0,0 +1,112 @@
+package html
+
+import (
+ "slices"
+ "testing"
+)
+
+// stampTop imprints node such that node itself is stamped (rather than its
+// children), driving structuralChildCount / structuralEmpty /
+// structuralNodeType for that node type. It wraps node in a satisfied If and
+// caps maxDepth at 1, so the walker descends one level (into node) and then
+// hits the depth limit, stamping node in place.
+func stampTop(node Node, ctx *Context) Stamp {
+ wrapped := If(func(*Context) bool { return true }, node)
+ return (&GrammarImprint{maxDepth: 1}).Imprint(wrapped, deref(ctx))
+}
+
+func deref(ctx *Context) Context {
+ if ctx == nil {
+ return Context{}
+ }
+ return *ctx
+}
+
+// TestStructuralStamp_SwitchChildCountGood — stamping a switchNode counts its
+// non-nil cases via countMapNodes.
+func TestStructuralStamp_SwitchChildCountGood(t *testing.T) {
+ node := Switch(
+ func(*Context) string { return "en" },
+ map[string]Node{"en": El("p", Text("x")), "fr": El("p", Text("y"))},
+ )
+ got := stampTop(node, nil)
+ // Two non-nil cases -> a branch with a non-zero hash.
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped switch")
+ }
+ if slices.Contains(got.Tags, "leaf") {
+ t.Fatalf("switch with cases should not be a leaf, got %v", got.Tags)
+ }
+}
+
+// TestStructuralStamp_EachChildCountGood — stamping an Each counts its
+// materialised items via structuralEachChildCount.
+func TestStructuralStamp_EachChildCountGood(t *testing.T) {
+ node := Each([]string{"a", "b", "c"}, func(s string) Node { return Text(s) })
+ got := stampTop(node, nil)
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped each")
+ }
+}
+
+// TestStructuralStamp_EachEmptyUgly — an empty Each stamps as a leaf.
+func TestStructuralStamp_EachEmptyUgly(t *testing.T) {
+ node := Each([]string{}, func(s string) Node { return Text(s) })
+ got := stampTop(node, nil)
+ // A depth-limited stamp is always a truncated branch; the point of this
+ // case is to drive structuralEachChildCount over an empty item slice.
+ if !slices.Contains(got.Tags, "truncated") {
+ t.Fatalf("depth-limited each should stamp as truncated, got %v", got.Tags)
+ }
+}
+
+// TestStructuralStamp_IfChildCountGood — stamping a satisfied If counts one child.
+func TestStructuralStamp_IfChildCountGood(t *testing.T) {
+ node := If(func(*Context) bool { return true }, El("p", Text("x")))
+ got := stampTop(node, nil)
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped satisfied If")
+ }
+}
+
+// TestStructuralStamp_IfFalseEmptyBad — a stamped unsatisfied If is structurally
+// empty (zero children).
+func TestStructuralStamp_IfFalseEmptyBad(t *testing.T) {
+ node := If(func(*Context) bool { return false }, El("p", Text("x")))
+ got := stampTop(node, nil)
+ if got.Hash == 0 {
+ t.Fatal("expected a non-zero hash even for an empty-If stamp")
+ }
+}
+
+// TestStructuralStamp_UnlessChildCountGood — a stamped Unless(false) counts one child.
+func TestStructuralStamp_UnlessChildCountGood(t *testing.T) {
+ node := Unless(func(*Context) bool { return false }, El("p", Text("x")))
+ got := stampTop(node, nil)
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped Unless(false)")
+ }
+}
+
+// TestStructuralStamp_EntitledChildCountGood — a stamped granted Entitled counts
+// one child.
+func TestStructuralStamp_EntitledChildCountGood(t *testing.T) {
+ node := Entitled("pro", El("p", Text("x")))
+ ctx := &Context{Entitlements: func(feature string) bool { return feature == "pro" }}
+ got := stampTop(node, ctx)
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped granted Entitled")
+ }
+}
+
+// TestStructuralStamp_ResponsiveChildCountGood — a stamped Responsive counts its
+// non-nil variant layouts.
+func TestStructuralStamp_ResponsiveChildCountGood(t *testing.T) {
+ node := NewResponsive().
+ Variant("desktop", NewLayout("C").C(Raw("x"))).
+ Variant("mobile", NewLayout("C").C(Raw("y")))
+ got := stampTop(node, nil)
+ if got.Hash == 0 {
+ t.Fatal("expected non-zero hash for stamped responsive")
+ }
+}
diff --git a/go/layout_path_test.go b/go/layout_path_test.go
new file mode 100644
index 0000000..12f0c6f
--- /dev/null
+++ b/go/layout_path_test.go
@@ -0,0 +1,118 @@
+package html
+
+import "testing"
+
+// TestRenderLayoutPath_IfWrappingNestedLayoutGood — a satisfied If wrapping a
+// nested Layout preserves the parent's layout path and renders the child.
+func TestRenderLayoutPath_IfWrappingNestedLayoutGood(t *testing.T) {
+ page := NewLayout("C").C(
+ If(func(*Context) bool { return true }, NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(NewContext())
+ if !containsText(got, "inner") {
+ t.Fatalf("satisfied If should render its nested layout, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_IfFalseBad — an unsatisfied If renders nothing into the slot.
+func TestRenderLayoutPath_IfFalseBad(t *testing.T) {
+ page := NewLayout("C").C(
+ If(func(*Context) bool { return false }, NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(NewContext())
+ if containsText(got, "inner") {
+ t.Fatalf("unsatisfied If must not render its child, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_UnlessWrappingNestedLayoutGood — an Unless whose condition
+// is false renders its nested layout.
+func TestRenderLayoutPath_UnlessWrappingNestedLayoutGood(t *testing.T) {
+ page := NewLayout("C").C(
+ Unless(func(*Context) bool { return false }, NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(NewContext())
+ if !containsText(got, "inner") {
+ t.Fatalf("Unless(false) should render its nested layout, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_UnlessTrueBad — an Unless whose condition is true is empty.
+func TestRenderLayoutPath_UnlessTrueBad(t *testing.T) {
+ page := NewLayout("C").C(
+ Unless(func(*Context) bool { return true }, NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(NewContext())
+ if containsText(got, "inner") {
+ t.Fatalf("Unless(true) must not render its child, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_EntitledGrantedGood — a granted entitlement wrapping a
+// nested layout preserves the path and renders.
+func TestRenderLayoutPath_EntitledGrantedGood(t *testing.T) {
+ ctx := NewContext()
+ ctx.Entitlements = func(feature string) bool { return feature == "pro" }
+ page := NewLayout("C").C(
+ Entitled("pro", NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(ctx)
+ if !containsText(got, "inner") {
+ t.Fatalf("granted entitlement should render its nested layout, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_EntitledDeniedBad — a denied entitlement renders nothing.
+func TestRenderLayoutPath_EntitledDeniedBad(t *testing.T) {
+ ctx := NewContext()
+ ctx.Entitlements = func(string) bool { return false }
+ page := NewLayout("C").C(
+ Entitled("pro", NewLayout("C").C(Raw("inner"))),
+ )
+ got := page.Render(ctx)
+ if containsText(got, "inner") {
+ t.Fatalf("denied entitlement must not render its child, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_SwitchHitGood — a Switch whose selector matches renders
+// the matched nested layout.
+func TestRenderLayoutPath_SwitchHitGood(t *testing.T) {
+ page := NewLayout("C").C(
+ Switch(
+ func(*Context) string { return "en" },
+ map[string]Node{"en": NewLayout("C").C(Raw("inner"))},
+ ),
+ )
+ got := page.Render(NewContext())
+ if !containsText(got, "inner") {
+ t.Fatalf("matched switch should render its nested layout, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_SwitchMissBad — an unmatched Switch renders nothing.
+func TestRenderLayoutPath_SwitchMissBad(t *testing.T) {
+ page := NewLayout("C").C(
+ Switch(
+ func(*Context) string { return "fr" },
+ map[string]Node{"en": NewLayout("C").C(Raw("inner"))},
+ ),
+ )
+ got := page.Render(NewContext())
+ if containsText(got, "inner") {
+ t.Fatalf("unmatched switch must not render a child, got:\n%s", got)
+ }
+}
+
+// TestRenderLayoutPath_ControlFlowWrappingLeafGood — control-flow nodes wrapping a
+// plain leaf (not a nested layout) still render, exercising the
+// path-not-preserved branch of nodePreservesLayoutPath.
+func TestRenderLayoutPath_ControlFlowWrappingLeafGood(t *testing.T) {
+ page := NewLayout("C").C(
+ If(func(*Context) bool { return true }, Raw("leaf")),
+ )
+ got := page.Render(NewContext())
+ if !containsText(got, "leaf") {
+ t.Fatalf("If wrapping a leaf should render it, got:\n%s", got)
+ }
+}
diff --git a/go/pkg/api/handlers_test.go b/go/pkg/api/handlers_test.go
index 471b79e..5caa265 100644
--- a/go/pkg/api/handlers_test.go
+++ b/go/pkg/api/handlers_test.go
@@ -82,6 +82,148 @@ func TestGrammarCheckRoute_Bad(t *testing.T) {
}
}
+func TestRenderRoute_EmptyTemplateBad(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/render", `{"template":""}`)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /render empty template status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+ if !core.Contains(rec.Body.String(), "template is required") {
+ t.Fatalf("expected %q, got %s", "template is required", rec.Body.String())
+ }
+}
+
+func TestRenderRoute_LocaleGood(t *testing.T) {
+ router := testRouter()
+ rec := postJSON(t, router, "/v1/html/render", `{"template":"
hi
"), + Style: "p{color:red}", + Mode: "closed", + } + out := sc.RenderClass() + if !core.Contains(out, "") { + t.Fatalf("RenderClass output missing prepended style, got:\n%s", out) + } + if !core.Contains(out, `"closed"`) { + t.Fatalf("RenderClass output missing closed mode, got:\n%s", out) + } +} diff --git a/go/text_translate_args_test.go b/go/text_translate_args_test.go new file mode 100644 index 0000000..b126213 --- /dev/null +++ b/go/text_translate_args_test.go @@ -0,0 +1,139 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package html + +import "testing" + +// TestCountInt_NumericTypesGood — every supported numeric type normalises to int. +func TestCountInt_NumericTypesGood(t *testing.T) { + cases := []struct { + name string + in any + want int + }{ + {"int", int(3), 3}, + {"int8", int8(4), 4}, + {"int16", int16(5), 5}, + {"int32", int32(6), 6}, + {"int64", int64(7), 7}, + {"uint", uint(8), 8}, + {"uint8", uint8(9), 9}, + {"uint16", uint16(10), 10}, + {"uint32", uint32(11), 11}, + {"uint64", uint64(12), 12}, + {"float32", float32(13.9), 13}, + {"float64", float64(14.9), 14}, + {"string", "15", 15}, + {"string-padded", " 16 ", 16}, + } + for _, tc := range cases { + got, ok := countInt(tc.in) + if !ok { + t.Fatalf("%s: countInt(%v) ok=false, want true", tc.name, tc.in) + } + if got != tc.want { + t.Fatalf("%s: countInt(%v) = %d, want %d", tc.name, tc.in, got, tc.want) + } + } +} + +// TestCountInt_NonNumericBad — unparseable or unsupported values report ok=false. +func TestCountInt_NonNumericBad(t *testing.T) { + cases := []struct { + name string + in any + }{ + {"empty-string", ""}, + {"whitespace-string", " "}, + {"non-numeric-string", "abc"}, + {"bool", true}, + {"nil", nil}, + {"struct", struct{}{}}, + } + for _, tc := range cases { + if got, ok := countInt(tc.in); ok { + t.Fatalf("%s: countInt(%v) = (%d, true), want ok=false", tc.name, tc.in, got) + } + } +} + +// TestTranslationArgs_NilContextBad — a nil context returns the args untouched. +func TestTranslationArgs_NilContextBad(t *testing.T) { + args := []any{"x"} + if got := translationArgs(nil, "i18n.count.items", args); len(got) != 1 { + t.Fatalf("nil context should pass args through, got %v", got) + } +} + +// TestTranslationArgs_NonCountKeyBad — a non-count key returns the args untouched. +func TestTranslationArgs_NonCountKeyBad(t *testing.T) { + ctx := &Context{Data: map[string]any{"Count": 3}} + args := []any{"x"} + got := translationArgs(ctx, "i18n.label.title", args) + if len(got) != 1 { + t.Fatalf("non-count key should pass args through, got %v", got) + } +} + +// TestTranslationArgs_NoCountInContextBad — a count key with no count in context +// returns the args untouched. +func TestTranslationArgs_NoCountInContextBad(t *testing.T) { + ctx := &Context{Data: map[string]any{"other": 1}} + args := []any{"x"} + got := translationArgs(ctx, "i18n.count.items", args) + if len(got) != 1 { + t.Fatalf("missing count should pass args through, got %v", got) + } +} + +// TestTranslationArgs_InjectsCountGood — a count key with an empty arg list +// injects the context count as the first argument. +func TestTranslationArgs_InjectsCountGood(t *testing.T) { + ctx := &Context{Data: map[string]any{"Count": 5}} + got := translationArgs(ctx, "i18n.count.items", nil) + if len(got) != 1 || got[0] != 5 { + t.Fatalf("expected injected count [5], got %v", got) + } +} + +// TestTranslationArgs_PrependsCountGood — a non-count-like leading arg gets the +// count prepended. +func TestTranslationArgs_PrependsCountGood(t *testing.T) { + ctx := &Context{Data: map[string]any{"Count": 5}} + got := translationArgs(ctx, "i18n.count.items", []any{"label"}) + if len(got) != 2 || got[0] != 5 || got[1] != "label" { + t.Fatalf("expected [5 label], got %v", got) + } +} + +// TestTranslationArgs_LeadingCountKeptGood — a count-like leading arg is kept and +// the context count is not injected. +func TestTranslationArgs_LeadingCountKeptGood(t *testing.T) { + ctx := &Context{Data: map[string]any{"Count": 5}} + got := translationArgs(ctx, "i18n.count.items", []any{9}) + if len(got) != 1 || got[0] != 9 { + t.Fatalf("expected leading count kept [9], got %v", got) + } +} + +// TestContextCount_FromMetadataGood — when Data has no count, Metadata is tried. +func TestContextCount_FromMetadataGood(t *testing.T) { + ctx := &Context{Metadata: map[string]any{"count": 7}} + if n, ok := contextCount(ctx); !ok || n != 7 { + t.Fatalf("contextCount from metadata = (%d, %v), want (7, true)", n, ok) + } +} + +// TestContextCount_NilContextBad — a nil context reports no count. +func TestContextCount_NilContextBad(t *testing.T) { + if n, ok := contextCount(nil); ok { + t.Fatalf("contextCount(nil) = (%d, true), want ok=false", n) + } +} + +// TestTrimTextSpace_AllWhitespaceUgly — a string of only whitespace trims to empty. +func TestTrimTextSpace_AllWhitespaceUgly(t *testing.T) { + if got := trimTextSpace(" \t\n\r\v\f"); got != "" { + t.Fatalf("trimTextSpace(whitespace) = %q, want empty", got) + } +}