go-html/docs/development.md
Snider 53edd80476 docs: graduate TODO/FINDINGS into production documentation
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>
2026-02-20 15:01:55 +00:00

8.1 KiB

Development Guide

Prerequisites

  • Go 1.25 or later (Go workspace required).
  • go-i18n repository cloned alongside this one: ../go-i18n relative to the repository root. The go.mod replace directive points there.
  • go-inference also resolved via replace directive at ../go-inference. It is an indirect dependency pulled in by go-i18n.
  • testify is 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 are H, L, C, R, F.
  • Responsive.Variant() accepts only *Layout, not arbitrary Node values. Arbitrary subtrees must be wrapped in a single-slot layout.
  • Context.service is private. Custom i18n adapter injection requires NewContextWithService(). There is no way to set or swap the service after construction.
  • cmd/wasm/main.go has no integration test for the JS exports. The size_test.go file tests binary size only; it does not exercise renderToString behaviour.