Replace internal task tracking (TODO.md, FINDINGS.md) with structured documentation in docs/. Trim CLAUDE.md to agent instructions only. Co-Authored-By: Virgil <virgil@lethean.io>
8.1 KiB
Development Guide
Prerequisites
- Go 1.25 or later (Go workspace required).
go-i18nrepository cloned alongside this one:../go-i18nrelative to the repository root. Thego.modreplacedirective points there.go-inferencealso resolved viareplacedirective at../go-inference. It is an indirect dependency pulled in bygo-i18n.testifyis the only external test dependency; it is fetched by the Go module system.
No additional tools are required for server-side development. WASM builds require a standard Go installation with GOOS=js GOARCH=wasm cross-compilation support, which is included in all official Go distributions.
Directory Layout
go-html/
├── node.go Node interface and all node types
├── layout.go HLCRF compositor
├── pipeline.go StripTags, Imprint, CompareVariants (!js only)
├── responsive.go Multi-variant breakpoint wrapper
├── context.go Rendering context
├── render.go Render() convenience function
├── path.go ParseBlockID() for data-block path decoding
├── codegen/
│ └── codegen.go Web Component JS generation (server-side)
├── cmd/
│ ├── codegen/
│ │ └── main.go Build-time CLI (stdin JSON → stdout JS)
│ └── wasm/
│ ├── main.go WASM entry point (js+wasm build only)
│ ├── register.go buildComponentJS helper (!js only)
│ └── size_test.go WASM binary size gate test (!js only)
└── docs/
└── plans/ Phase design documents (historical)
Running Tests
# All tests
go test ./...
# Single test by name
go test -run TestWASMBinarySize_Good ./cmd/wasm/
# Skip slow WASM build test
go test -short ./...
# Tests with verbose output
go test -v ./...
Tests use testify assert and require helpers. Test names follow Go's standard TestFunctionName convention. Subtests use t.Run() with descriptive names.
The WASM size gate test (TestWASMBinarySize_Good) builds the WASM binary as a subprocess and is therefore slow. It is skipped automatically under -short. It is also guarded with //go:build !js so it cannot run under GOARCH=wasm.
Benchmarks
# All benchmarks
go test -bench . ./...
# Specific benchmark
go test -bench BenchmarkRender_FullPage ./...
# With memory allocations
go test -bench . -benchmem ./...
# Fixed iteration count
go test -bench . -benchtime=5s ./...
Benchmarks are organised by operation:
| Group | Variants |
|---|---|
BenchmarkRender_* |
Depth 1, 3, 5, 7 trees; full page |
BenchmarkLayout_* |
Content-only, HCF, HLCRF, nested, many children |
BenchmarkEach_* |
10, 100, 1000 items |
BenchmarkResponsive_* |
Three-variant compositor |
BenchmarkStripTags_* |
Short and long HTML inputs |
BenchmarkImprint_* |
Small and large page trees |
BenchmarkCompareVariants_* |
Two and three variant comparison |
WASM Build
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o gohtml.wasm ./cmd/wasm/
Strip flags (-s -w) are required. Without them the binary is approximately 50% larger.
The Makefile target make wasm performs the build and measures the gzip size:
make wasm
The Makefile enforces a 1 MB gzip limit (WASM_GZ_LIMIT = 1048576). The build fails if this limit is exceeded.
To verify the size manually:
gzip -c -9 gohtml.wasm | wc -c
Current measured output: 2.90 MB raw, 842 KB gzip.
Codegen CLI
The codegen CLI reads a JSON slot map from stdin and writes a Web Component JS bundle to stdout. It is a build-time tool, not intended for runtime use.
# Generate components for a two-slot layout
echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
| go run ./cmd/codegen/ \
> components.js
The JSON keys are HLCRF slot letters (H, L, C, R, F). The values are custom element tag names (must contain a hyphen). Duplicate tag values are deduplicated.
To test the CLI:
go test ./cmd/codegen/
Static Analysis
go vet ./...
The codebase passes go vet with no warnings.
Coding Standards
Language
UK English throughout: colour, organisation, centre, behaviour, licence (noun), serialise. American spellings are not used.
Types
All exported and unexported functions carry full parameter and return type annotations. The any alias is used in preference to interface{}.
Error handling
Errors are wrapped with context using fmt.Errorf("pkg.Function: %w", err). The codegen package prefixes all errors with codegen:.
HTML safety
- Use
Text()for any user-supplied or translated content. It escapes HTML. - Use
Raw()only for content you control or have sanitised upstream. - Never construct HTML by string concatenation in application code.
Determinism
Output must be deterministic. Attributes are sorted before rendering. map iteration in codegen.GenerateBundle() may produce non-deterministic class order across runs — this is acceptable because Web Component registration order does not affect correctness.
Build tags
Files excluded from WASM use //go:build !js as the first line, before the package declaration. Files compiled only under WASM use //go:build js && wasm. Do not use the older // +build syntax.
Licence
All files carry the EUPL-1.2 SPDX identifier:
// SPDX-Licence-Identifier: EUPL-1.2
Commit format
Conventional commits with lowercase type and optional scope:
feat(codegen): add TypeScript type definition generation
fix(wasm): correct slot injection for empty strings
test: add edge case for Unicode surrogate pairs
docs: update architecture with pipeline diagram
Commits include a co-author trailer:
Co-Authored-By: Virgil <virgil@lethean.io>
Test Patterns
Standard unit test
func TestElNode_Render(t *testing.T) {
ctx := NewContext()
node := El("div", Raw("content"))
got := node.Render(ctx)
want := "<div>content</div>"
if got != want {
t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want)
}
}
Table-driven subtest
func TestStripTags(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"empty", "", ""},
{"plain", "hello", "hello"},
{"single tag", "<p>hello</p>", "hello"},
{"nested", "<div><p>a</p><p>b</p></div>", "a b"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := StripTags(tc.input)
if got != tc.want {
t.Errorf("StripTags(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
Integration test with i18n
func TestIntegration_RenderThenReverse(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
imp := Imprint(page, ctx)
if imp.UniqueVerbs == 0 {
t.Error("reversal found no verbs in rendered page")
}
}
Integration tests that exercise the full pipeline (Imprint, CompareVariants) must initialise the i18n default service before calling Text nodes. The bench_test.go init() function does this for benchmarks; individual integration tests must do so explicitly.
Known Limitations
NewLayout("XYZ")silently produces empty output when given unrecognised slot letters. There is no warning or error. Valid slot letters areH,L,C,R,F.Responsive.Variant()accepts only*Layout, not arbitraryNodevalues. Arbitrary subtrees must be wrapped in a single-slot layout.Context.serviceis private. Custom i18n adapter injection requiresNewContextWithService(). There is no way to set or swap the service after construction.cmd/wasm/main.gohas no integration test for the JS exports. Thesize_test.gofile tests binary size only; it does not exerciserenderToStringbehaviour.