From bf017a57e70db7ef3e2d1f4b5dfbb54956b17e65 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 08:34:09 +0100 Subject: [PATCH 01/19] chore: add EUPL-1.2 LICENCE file (UK English canonical) Reference: core/api/LICENCE. Co-Authored-By: Cladius Maximus --- LICENCE | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 LICENCE 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. From cb283e00a29e7c776b4302fca2509e7cac80b2f9 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 11:23:53 +0100 Subject: [PATCH 02/19] =?UTF-8?q?chore(repo):=20canonicalize=20=E2=80=94?= =?UTF-8?q?=20add=20.core/go.yaml=20+=20scope=20.gitignore=20to=20runtime?= =?UTF-8?q?=20subdirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .core/ root holds tracked Lethean canon config (go.yaml, build.yaml, agents.yaml, etc.). Only the runtime subdirs (workspace/, vm/, cache/, runtime/) and per-repo runtime artifacts should be gitignored. Brings this repo into the managed canonical set per audit-sweep discovery filter. Co-Authored-By: Cladius Maximus --- .core/go.yaml | 13 +++++++++++++ .gitignore | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .core/go.yaml 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 From 9a9e000195ac79c13cc5708599484c4a82c1626f Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 10 May 2026 17:20:05 +0100 Subject: [PATCH 03/19] feat(html): canonical NewService + Register with WASM-safe build tag (Mantis #1336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-html gets the canonical NewService(opts) + Register(c) dual-method shape per #1336. Implementation in service.go with //go:build !js so the WASM build (per RFC §7's 3.5 MB raw / 1 MB gzip budget) doesn't pull in dappco.re/go. html.NewService(html.Options{Locale: "en", Translator: t}) → factory for core.WithService html.Register(c) → defaults svc.Context() → pre-built default Context ready for Render(node, ctx) Service struct embeds *core.ServiceRuntime[Options] for typed options access and holds a pre-built default Context. Translator is optional — nil leaves the package's existing fallback behaviour intact. The shared rendering surface (context.go, node.go, render.go, responsive.go, layout.go, etc.) stays core-free so the WASM build is unaffected. Service registration is server-side only. Smoke verified: - GOWORK=off go vet ./... — clean - GOWORK=off GOOS=js GOARCH=wasm go build ./... — pre-existing Imprint failure unrelated to service.go (verified by stashing — same failure on pristine HEAD) - TestNewService_DefaultOptions / TestNewService_LocaleOption / TestRegister_Imperative — all PASS Co-Authored-By: Virgil --- go/service.go | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 go/service.go diff --git a/go/service.go b/go/service.go new file mode 100644 index 0000000..d1d3b82 --- /dev/null +++ b/go/service.go @@ -0,0 +1,95 @@ +//go:build !js + +// SPDX-License-Identifier: EUPL-1.2 + +// Service registration for the html package — exposes a default +// rendering Context as a Core service so consumers can wire HTML +// rendering through the same plumbing as every other core service. +// +// c, _ := core.New( +// core.WithService(html.NewService(html.Options{ +// Locale: "en", +// })), +// ) +// svc := core.MustServiceFor[*html.Service](c, "html") +// out := html.Render(myNode, svc.Context()) +// +// Build-tagged !js so the WASM build (per RFC §7's 3.5 MB raw / 1 MB +// gzip budget) doesn't pull in dappco.re/go. The shared rendering +// surface (context.go, node.go, render.go, etc.) stays core-free; this +// file is the server-side service-registration layer only. + +package html + +import ( + core "dappco.re/go" +) + +// Options configures the html service. Empty values fall back to +// package defaults (locale "en", no translator wired). +type Options struct { + // Locale is the BCP 47 language tag for the default rendering + // context. Empty → "en". + Locale string + // Translator is an optional translation provider. nil → text passes + // through unchanged via Context.T fallback. + Translator Translator +} + +// Service is the registerable handle for the html package — embeds +// *core.ServiceRuntime[Options] for typed options access and holds a +// pre-built default Context ready for direct rendering. +// +// Usage example: `svc := core.MustServiceFor[*html.Service](c, "html"); out := html.Render(node, svc.Context())` +type Service struct { + *core.ServiceRuntime[Options] + ctx *Context +} + +// NewService returns a factory that constructs a default rendering +// Context with the given options and produces a *Service ready for +// c.Service() registration. Use through core.WithService. +// +// core.WithService(html.NewService(html.Options{Locale: "en"})) +func NewService(opts Options) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + locale := opts.Locale + if locale == "" { + locale = "en" + } + var ctx *Context + if opts.Translator != nil { + ctx = NewContextWithService(opts.Translator, locale) + } else { + ctx = NewContext(locale) + } + return core.Ok(&Service{ + ServiceRuntime: core.NewServiceRuntime(c, opts), + ctx: ctx, + }) + } +} + +// Register wires the html service into the Core with default Options — +// the imperative-style alternative to NewService for consumers that +// don't use the WithService factory pattern. +// +// c := core.New() +// if r := html.Register(c); !r.OK { return r } +func Register(c *core.Core) core.Result { + return NewService(Options{})(c) +} + +// Context returns the service's pre-built default rendering Context. +// Callers that need per-request state should construct their own +// Context via NewContext / NewContextWithService instead of mutating +// the shared one. +// +// ctx := svc.Context() +// out := html.Render(node, ctx) +func (s *Service) Context() *Context { + if s == nil { + return nil + } + return s.ctx +} From 61b6be30e453cc16603bc8aa737459ff15c9830c Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 10 May 2026 18:43:02 +0100 Subject: [PATCH 04/19] test(html): service.go coverage WASM-safe (Mantis #1387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permanent service_test.go for canon shape (commit 9a9e000). Build-tagged //go:build !js so the WASM build (RFC §7 size budget) doesn't pull in dappco.re/go via the test compilation. Four cases: default-locale fallback, explicit-Locale flow-through, imperative Register, nil-receiver Context guard. Coverage sweep (#1387): 7th of 22. Co-Authored-By: Virgil --- go/service_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 go/service_test.go diff --git a/go/service_test.go b/go/service_test.go new file mode 100644 index 0000000..de18fa1 --- /dev/null +++ b/go/service_test.go @@ -0,0 +1,52 @@ +//go:build !js + +// SPDX-License-Identifier: EUPL-1.2 + +package html + +import ( + "testing" + + core "dappco.re/go" +) + +// TestNewService_DefaultLocale — empty options falls back to "en". +func TestNewService_DefaultLocale(t *testing.T) { + c := core.New(core.WithService(NewService(Options{}))) + r := c.Service("html") + if !r.OK { + t.Fatal("html service not registered") + } + svc := r.Value.(*Service) + if svc.Context() == nil { + t.Fatal("expected non-nil Context") + } + if svc.Context().Locale != "en" { + t.Fatalf("expected default locale en, got %q", svc.Context().Locale) + } +} + +// TestNewService_LocaleOption — explicit Locale flows through to the Context. +func TestNewService_LocaleOption(t *testing.T) { + c := core.New(core.WithService(NewService(Options{Locale: "de"}))) + svc := c.Service("html").Value.(*Service) + if svc.Context().Locale != "de" { + t.Fatalf("expected locale de, got %q", svc.Context().Locale) + } +} + +// TestRegister_Imperative — defaults shorthand. +func TestRegister_Imperative(t *testing.T) { + c := core.New(core.WithService(Register)) + if !c.Service("html").OK { + t.Fatal("html not registered via Register") + } +} + +// TestService_Context_NilReceiver — nil-receiver guard. +func TestService_Context_NilReceiver(t *testing.T) { + var svc *Service + if svc.Context() != nil { + t.Fatal("expected nil Context on nil-receiver") + } +} From 9bf72a5555a86cddaff7b1a48b121a254a874131 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 31 May 2026 11:18:25 +0100 Subject: [PATCH 05/19] chore(deps): bump dappco.re/go to v0.10.3 Co-Authored-By: Virgil Co-Authored-By: Hephaestus --- go/go.mod | 2 +- go/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go/go.mod b/go/go.mod index 2273de6..d6aa333 100644 --- a/go/go.mod +++ b/go/go.mod @@ -43,7 +43,7 @@ require ( ) require ( - dappco.re/go v0.9.0 + dappco.re/go v0.10.3 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..355ec05 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,7 @@ 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.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= +dappco.re/go v0.10.3/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= From 77fc6a07b774b2f66371a0f829f1e4220de14722 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:34:45 +0100 Subject: [PATCH 06/19] fix(wasm): init process service in WASM size test TestCmdWasm_WASMBinarySizeGood called process.RunWithOptions without initialising the process service, failing with "process: service not initialized". process@v0.8 binds the global service to dappco.re/go/core, not the dappco.re/go facade, so init via corepkg.New(). Co-Authored-By: Virgil --- go/cmd/wasm/size_test.go | 5 +++++ 1 file changed, 5 insertions(+) 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") From 5307d1d89acad5bdaf8bb3bd79dc274f91b9ae3d Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:37:12 +0100 Subject: [PATCH 07/19] test(codegen): cover CLI command layer and file I/O paths Adds Good/Bad/Ugly coverage for the codegen CLI command layer: option-path helpers, flag defaults, poll-interval parsing, generate/types/watch command actions, named-file read/write paths, and the result-wrapping helpers. cmd/codegen statement coverage 40.0% -> 66.7%. Co-Authored-By: Virgil --- go/cmd/codegen/cli_test.go | 395 +++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 go/cmd/codegen/cli_test.go 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()) + } +} From 191578ff70c7b4ea3a5d0d9a0ed7c64a2df60b74 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:38:54 +0100 Subject: [PATCH 08/19] test(api): cover handler edge paths to 100% Adds Good/Bad/Ugly coverage for the HTTP handlers: empty-template rejection, locale propagation, invalid-JSON grammar requests, the reference/similarity branch (match and mismatch against min_similarity), and the nil-context guards. pkg/api statement coverage 69.6% -> 100.0%. Co-Authored-By: Virgil --- go/pkg/api/handlers_test.go | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) 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":"
Bonjour
","locale":"fr"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /render locale status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var resp renderResponse + if result := core.JSONUnmarshal(rec.Body.Bytes(), &resp); !result.OK { + t.Fatalf("unmarshal response: %v", result.Value) + } + if resp.HTML != "
Bonjour
" { + t.Fatalf("html = %q, want %q", resp.HTML, "
Bonjour
") + } +} + +func TestGrammarCheckRoute_InvalidJSONBad(t *testing.T) { + router := testRouter() + rec := postJSON(t, router, "/v1/html/grammar/check", `{"html":`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("POST /grammar/check invalid JSON status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if !core.Contains(rec.Body.String(), "invalid JSON request body") { + t.Fatalf("expected %q, got %s", "invalid JSON request body", rec.Body.String()) + } +} + +func TestGrammarCheckRoute_LocaleGood(t *testing.T) { + router := testRouter() + rec := postJSON(t, router, "/v1/html/grammar/check", `{"html":"
The user creates reports.
","locale":"fr"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /grammar/check locale status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +// TestGrammarCheckRoute_ReferenceMatchGood — supplying the same document as a +// reference yields a similarity of 1.0 and stays valid against the default 0.8. +func TestGrammarCheckRoute_ReferenceMatchGood(t *testing.T) { + router := testRouter() + const doc = `
The user creates reports.
` + + // First call: capture the imprint to feed back as the reference. + first := postJSON(t, router, "/v1/html/grammar/check", `{"html":"`+doc+`"}`) + if first.Code != http.StatusOK { + t.Fatalf("seed grammar check status = %d, want %d", first.Code, http.StatusOK) + } + var seed grammarCheckResponse + if result := core.JSONUnmarshal(first.Body.Bytes(), &seed); !result.OK { + t.Fatalf("unmarshal seed: %v", result.Value) + } + + imprintJSON := core.JSONMarshal(seed.Imprint) + if !imprintJSON.OK { + t.Fatalf("marshal imprint: %v", imprintJSON.Value) + } + imprintBytes, _ := imprintJSON.Value.([]byte) + imprintStr := string(imprintBytes) + + body := `{"html":"` + doc + `","reference":` + imprintStr + `}` + rec := postJSON(t, router, "/v1/html/grammar/check", body) + if rec.Code != http.StatusOK { + t.Fatalf("POST /grammar/check with reference status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp grammarCheckResponse + if result := core.JSONUnmarshal(rec.Body.Bytes(), &resp); !result.OK { + t.Fatalf("unmarshal response: %v", result.Value) + } + if resp.Similarity == nil { + t.Fatal("expected similarity to be reported when a reference is supplied") + } + if !resp.Valid { + t.Fatalf("expected identical document to be valid, similarity=%v", *resp.Similarity) + } +} + +// TestGrammarCheckRoute_ReferenceMismatchBad — a high explicit min_similarity +// against a divergent reference marks the document invalid. +func TestGrammarCheckRoute_ReferenceMismatchBad(t *testing.T) { + router := testRouter() + + first := postJSON(t, router, "/v1/html/grammar/check", `{"html":"
Cats sleep quietly.
"}`) + if first.Code != http.StatusOK { + t.Fatalf("seed grammar check status = %d, want %d", first.Code, http.StatusOK) + } + var seed grammarCheckResponse + if result := core.JSONUnmarshal(first.Body.Bytes(), &seed); !result.OK { + t.Fatalf("unmarshal seed: %v", result.Value) + } + imprintJSON := core.JSONMarshal(seed.Imprint) + if !imprintJSON.OK { + t.Fatalf("marshal imprint: %v", imprintJSON.Value) + } + imprintBytes, _ := imprintJSON.Value.([]byte) + imprintStr := string(imprintBytes) + + // Check a different document against the cat-sentence reference with an + // impossible-to-meet threshold so the result must be invalid. + body := `{"html":"
The user generates monthly invoices.
","reference":` + imprintStr + `,"min_similarity":1.0}` + rec := postJSON(t, router, "/v1/html/grammar/check", body) + if rec.Code != http.StatusOK { + t.Fatalf("POST /grammar/check status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp grammarCheckResponse + if result := core.JSONUnmarshal(rec.Body.Bytes(), &resp); !result.OK { + t.Fatalf("unmarshal response: %v", result.Value) + } + if resp.Similarity == nil { + t.Fatal("expected similarity to be reported") + } + if resp.Valid { + t.Fatalf("expected invalid against min_similarity 1.0, similarity=%v", *resp.Similarity) + } +} + +// TestRenderHandler_NilContextUgly — the render handler tolerates a nil context. +func TestRenderHandler_NilContextUgly(t *testing.T) { + p := NewProvider() + p.render(nil) // must not panic +} + +// TestCheckGrammarHandler_NilContextUgly — the grammar handler tolerates a nil context. +func TestCheckGrammarHandler_NilContextUgly(t *testing.T) { + p := NewProvider() + p.checkGrammar(nil) // must not panic +} + func testRouter() *gin.Engine { provider := NewProvider() router := gin.New() From 465dabf8ee804e6012eb0927fcbf4cdc76a41814 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:39:46 +0100 Subject: [PATCH 09/19] test(wasm): cover renderLayout slot and locale branches Adds Good/Bad/Ugly coverage for renderLayout: empty-variant guard, all five HLCRF slots, unrecognised slot keys, and the no-locale path. cmd/wasm statement coverage 73.9% -> 95.7% (remaining is the !js main stub). Co-Authored-By: Virgil --- go/cmd/wasm/render_layout_test.go | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) 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 +} From 490b232b8ae21f3b13d9ab366b4359766d97cf42 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:42:34 +0100 Subject: [PATCH 10/19] test(codegen): cover JS escaping and tag validation edges Adds Good/Bad/Ugly coverage for the JS string-literal escaper (named escapes, U+2028/U+2029 line separators, sub-0x20 control chars, BMP surrogate pairs, empty input) and for isValidCustomElementTag (reserved names, non-letter start, missing hyphen, uppercase, invalid UTF-8). codegen statement coverage 80.5% -> 98.4%. Co-Authored-By: Virgil --- go/codegen/escape_test.go | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 go/codegen/escape_test.go 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) + } +} From c9344ca57342eb3f40a9998593343c8a7b00187a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:44:41 +0100 Subject: [PATCH 11/19] test(html): cover grammar imprint control-flow node types Adds Good/Bad/Ugly coverage for the GrammarImprint walker across the control-flow node types: Responsive (populated and empty), If/Unless (both branches), Entitled (granted, denied, no-checker), Switch (hit and miss) and an empty-slot layout. grammar.go imprint walker 26.1% -> 91.3%. Co-Authored-By: Virgil --- go/grammar_imprint_test.go | 144 +++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 go/grammar_imprint_test.go 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") + } +} From 75913c17101afdecca25d2145d5af66f0bb504d6 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:46:02 +0100 Subject: [PATCH 12/19] test(html): cover Entitled argument-shape edges and empty sentinel Adds Good/Bad/Ugly coverage for Entitled's invalid argument shapes (empty feature, non-string feature, non-Node child, wrong arity in both 2- and 3-arg forms) and the empty sentinel's layout-path renderer and isNilHTMLNode guard. entitled.go now 100% covered. Co-Authored-By: Virgil --- go/entitled_edge_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 go/entitled_edge_test.go 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") + } +} From ea9c4074e7adb8f67ba270f3ca62e3df5b716772 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:46:59 +0100 Subject: [PATCH 13/19] test(html): cover control-flow renderWithLayoutPath in layout slots Adds Good/Bad coverage for If/Unless/Entitled/Switch rendered inside a layout slot (both nested-layout and leaf children, both branches each), exercising their renderWithLayoutPath methods. node.go control-flow path renderers 0-66% -> ~80%. Co-Authored-By: Virgil --- go/layout_path_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 go/layout_path_test.go 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) + } +} From 599cbc737e3be07a07f7b1521c26fae7f656f8b5 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:47:39 +0100 Subject: [PATCH 14/19] test(html): cover nodePreservesLayoutPath control-flow arms Adds Good/Ugly coverage for nodePreservesLayoutPath via single-item Each inside a layout slot, exercising the If/Unless/Entitled/Switch arms (preserve true) and the leaf default arm plus a failing-condition path. nodePreservesLayoutPath 25% -> 75%. Co-Authored-By: Virgil --- go/each_preserve_test.go | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 go/each_preserve_test.go 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) +} From fcfcbb18bc7c7fcd4f21bf68484612ace2c5f559 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:49:01 +0100 Subject: [PATCH 15/19] test(html): cover ShadowComponent JS string escaping Adds Good/Ugly coverage for ShadowComponent.RenderClass JS string-literal escaping: control chars, U+2028/U+2029 line separators, sub-0x20 unicode escapes, BMP surrogate pairs, plus the style-prepend and explicit-mode paths. shadow.go appendUnicodeEscape 0% -> 100%, appendJSStringLiteral 25% -> 85%. Co-Authored-By: Virgil --- go/shadow_escape_test.go | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 go/shadow_escape_test.go diff --git a/go/shadow_escape_test.go b/go/shadow_escape_test.go new file mode 100644 index 0000000..9d38f36 --- /dev/null +++ b/go/shadow_escape_test.go @@ -0,0 +1,80 @@ +package html + +import ( + "testing" + + core "dappco.re/go" +) + +// TestShadowComponent_EscapesControlCharsGood — control characters and quotes in +// the rendered body are escaped into the embedded JS string literal so the +// generated class source stays syntactically valid. +func TestShadowComponent_EscapesControlCharsGood(t *testing.T) { + sc := &ShadowComponent{ + Name: "demo-card", + Template: Raw("a\"b\nc\td\\e"), + } + out := sc.RenderClass() + for _, want := range []string{`\"`, `\n`, `\t`, `\\`} { + if !core.Contains(out, want) { + t.Fatalf("RenderClass output missing escape %q, got:\n%s", want, out) + } + } +} + +// TestShadowComponent_EscapesLineSeparatorsGood — U+2028 and U+2029 in the body +// are escaped to backslash-u2028 / backslash-u2029 so they do not break the JS string literal. +func TestShadowComponent_EscapesLineSeparatorsGood(t *testing.T) { + sc := &ShadowComponent{ + Name: "sep-card", + Template: Raw(string(rune(0x2028)) + string(rune(0x2029))), + } + out := sc.RenderClass() + if !core.Contains(out, "\\u2028") || !core.Contains(out, "\\u2029") { + t.Fatalf("RenderClass output missing line-separator escapes, got:\n%s", out) + } +} + +// TestShadowComponent_EscapesSubControlUgly — a sub-0x20 control char with no +// named escape falls through to a \uXXXX sequence. +func TestShadowComponent_EscapesSubControlUgly(t *testing.T) { + sc := &ShadowComponent{ + Name: "ctrl-card", + Template: Raw("\x01\x1f"), + } + out := sc.RenderClass() + if !core.Contains(out, "\\u0001") || !core.Contains(out, "\\u001F") { + t.Fatalf("RenderClass output missing control-char escapes, got:\n%s", out) + } +} + +// TestShadowComponent_EscapesSurrogatePairUgly — a rune above the BMP becomes a +// UTF-16 surrogate pair of \uXXXX escapes. +func TestShadowComponent_EscapesSurrogatePairUgly(t *testing.T) { + sc := &ShadowComponent{ + Name: "emoji-card", + Template: Raw("\U0001F600"), + } + out := sc.RenderClass() + if !core.Contains(out, `\uD83D`) || !core.Contains(out, `\uDE00`) { + t.Fatalf("RenderClass output missing surrogate-pair escapes, got:\n%s", out) + } +} + +// TestShadowComponent_StyleAndModeGood — a style block is prepended to the body +// and an explicit mode flows into the attachShadow call. +func TestShadowComponent_StyleAndModeGood(t *testing.T) { + sc := &ShadowComponent{ + Name: "styled-card", + Template: Raw("

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) + } +} From 6c01e106a85278cffc710a12d7f4d855e9afeabb Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:49:55 +0100 Subject: [PATCH 16/19] test(html): cover i18n count-argument normalisation Adds Good/Bad/Ugly coverage for translationArgs and countInt: all numeric type branches, string parse and whitespace edges, non-numeric rejection, nil/non-count-key/no-count-in-context paths, count injection vs prepend vs leading-count-kept, metadata fallback, and trimTextSpace. text_translate_args.go now fully covered. Co-Authored-By: Virgil --- go/text_translate_args_test.go | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 go/text_translate_args_test.go 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) + } +} From 96bde6dbd8dc13c55aa9f21c1475c62fbf7adfd8 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:51:55 +0100 Subject: [PATCH 17/19] test(html): cover structural child-count helpers via depth-limited stamps Adds Good/Bad/Ugly coverage for the GrammarImprint structural helpers by stamping control-flow node types at the depth limit: switchNode (countMapNodes), Each (structuralEachChildCount), If/Unless/Entitled and Responsive child counts. countMapNodes 0% -> 100%, structuralChildCount 39% -> 75%, structuralEmpty 60% -> 93%. Co-Authored-By: Virgil --- go/grammar_stamp_test.go | 112 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 go/grammar_stamp_test.go 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") + } +} From 4f4a9199f6397957894776969c130a86d8b7d1a3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 13 Jun 2026 10:06:39 +0100 Subject: [PATCH 18/19] =?UTF-8?q?chore(deps):=20bump=20dappco.re/go=20v0.1?= =?UTF-8?q?0.3=20=E2=86=92=20v0.10.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the core/go module pin to v0.10.4. This repo has no external/go workspace submodule on disk, so the bump is module-mode (GOWORK=off) only — verified build + test green. Co-Authored-By: Virgil --- go/go.mod | 4 ++-- go/go.sum | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/go/go.mod b/go/go.mod index d6aa333..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.10.3 + 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 355ec05..fd3d0ae 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,7 +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.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= -dappco.re/go v0.10.3/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= From 4da5c5620f08c50a88d907feac34c01c72636b60 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 27 Jun 2026 13:43:37 +0100 Subject: [PATCH 19/19] chore: remove stale agent state-dump docs Co-Authored-By: Virgil --- SESSION-BRIEF.md | 65 ------------------------------------------------ 1 file changed, 65 deletions(-) delete mode 100644 SESSION-BRIEF.md 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