go-html/docs/development.md

258 lines
8.1 KiB
Markdown
Raw Normal View History

# 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
```bash
# 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
```bash
# 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
```bash
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:
```bash
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:
```bash
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.
```bash
# 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:
```bash
go test ./cmd/codegen/
```
## Static Analysis
```bash
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:
```go
// 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
```go
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
```go
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
```go
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.