Releases: jackielii/structpages
Release list
v0.6.3 — path-based element ids
Highlights
Path-based ID / IDTarget. Element ids are now derived from a page's full field-name path from the root, not just the leaf mount name. This fixes id collisions between distinct pages that previously collapsed to the same id — most notably two same-named types from different packages, or the same field name mounted under different parents. Each id is a pure function of that node's own path, so it's stable: it changes only when that node is renamed or moved, never because of an unrelated sibling edit.
Standalone functions are package-prefixed. ID(ctx, UserWidget) now returns "<package>-user-widget", so two same-named function components in different packages stay distinct.
WithMaxIDLength option. Tunes the character budget (default 40) before an id degrades from the readable full-path form (admin-users-user-list) to the compact leaf-only form (user-list, plus a stable hash suffix when the leaf name is shared).
Ref ambiguity is now an error. A qualified Ref("Widget.List") that matches more than one mount reports an ambiguity error (listing the routes) instead of silently resolving to the first match.
⚠️ Behavior change
Nested-page ids change shape — they now include their ancestor prefix (e.g. detail-page → admin-users-detail-page). Apps generate both the id attribute and the hx-target through ID/IDTarget, so HTMX stays consistent automatically, but any hardcoded id strings in CSS/JS/templates referencing a nested component must be updated. Top-level page ids are unchanged.
Full Changelog: v0.6.2...v0.6.3
tools/lint/v0.1.11 — -tags flag
Adds a -tags flag to structpages-lint:
structpages-lint -tags devtools ./...
Loads packages under the given build tags so that URLFor / ID call sites in always-compiled code that reference routes mounted only behind a build tag (e.g. a dev-tools-only page group) resolve against the tree that actually mounts them — eliminating false-positive "cannot resolve" diagnostics for tag-gated routes.
Full Changelog: tools/lint/v0.1.10...tools/lint/v0.1.11
tools/lint/v0.1.10 — per-binary urlfor scope
Scopes the URLFor ambiguity analysis per main binary. A page type reachable from one command no longer triggers false-positive ambiguity reports against an unrelated binary in the same module.
Full Changelog: tools/lint/v0.1.9...tools/lint/v0.1.10
v0.6.0 — mount-aware ID/IDTarget + []any chain form
Summary
ID / IDTarget gain mount-aware semantics. Four fixes to existing primitives — no new public API surface.
What's new
Self-render uses the current mount
ID(ctx, p.M) / IDTarget(ctx, p.M) now consult the current request's page node. The same struct mounted under multiple parents with different field names emits per-mount ids correctly.
type root struct {
AdminDash dashboardPage `route:"/admin"`
UserDash dashboardPage `route:"/user"`
}
// Before: both mounts emit "#admin-dash-header" (first match wins).
// After: admin emits "#admin-dash-header", user emits "#user-dash-header".Cross-page ambiguity error
When the receiver type is mounted multiple times and the call has no matching current-page context:
- Identical kebab ids (entryPage pattern: three mounts all named
EntryDetail) → return silently. - Divergent kebab ids → error listing the available mounts and three disambiguation primitives.
[]any chain form for ID / IDTarget
Mirrors URLFor's chain shape, with the trailing element holding the method spec:
IDTarget(ctx, []any{adminRoot{}, dashboardPage{}, "Header"}) // chain + string
IDTarget(ctx, []any{adminRoot{}, dashboardPage.Header}) // chain + method exprWhen the trailing method expression's receiver type matches the prior chain step's type, the implicit step collapses.
Disambiguation primitives
Three coherent options when ambiguity arises:
| Want | Use |
|---|---|
| per-mount id, self-render | method expression — Fix #1 makes this reliable |
| type-stable id (overlay slot) | standalone function func MySlot() templ.Component { ... } |
| target specific mount, type-safe | []any{Parent{}, Leaf{}, "Method"} chain |
| target across package, can't import | Ref("Parent.Field.Method") |
Benchmarks
New BenchmarkURLGenerationV05 and BenchmarkIDGenerationV06 suites under bench/ cover the v0.5.0 and v0.6.0 surfaces. Baselines saved to bench/before-main.txt and bench/after-v06.txt.
Tooling
tools/lint/v0.1.6 ships alongside with matching analyzer support — see https://github.com/jackielii/structpages/releases/tag/tools%2Flint%2Fv0.1.6
Full diff
tools/lint/v0.1.7 — params check honours chain trailing fragments
Summary
Fixes a false-positive [params] diagnostic on URLFor calls that compose path and query placeholders via the []any chain form.
What was wrong
URLFor(ctx,
[]any{Page{}, \"?preset={preset}\"},
map[string]any{\"slug\": \"x\", \"preset\": \"weekly\"})The lint analyzer parsed placeholders only from node.FullRoute (the page's route path) and ignored placeholders supplied by the trailing string fragments of the chain. preset looked unknown, so the call emitted a false-positive [params] diagnostic and users had to add // structpages:lint:ignore params.
What changed
resolvePageArgAndFragmentreturns the concatenated trailing fragments alongside the chain leaf.checkParamMapmerges them into the pattern before extracting placeholders.- Error messages now show the full effective pattern (path + fragment) and the known-keys list includes fragment placeholders.
Install
go install github.com/jackielii/structpages/tools/lint/cmd/structpages-lint@v0.1.7For his-project
The // structpages:lint:ignore params in modules/receptionist/pages/analytics_urls.go is no longer needed after upgrading.
Full diff
tools/lint/v0.1.6 — ID-context Ref + chain form analyzer
Summary
Matches structpages v0.6.0 semantic changes plus a lint↔runtime fix that's been outstanding since the analyzer landed.
What's new
Lint mirrors runtime for ID-context qualified Ref
The runtime's idForRef walks the whole page tree by Name when resolving a qualified Ref like Ref("EntryDetail.Overlays"). The lint analyzer was anchoring only at root or root.Children — so valid ID-context Refs whose anchor sits deeper in the tree errored at lint time even though the runtime accepted them. The asymmetry forced suppressions in cross-package slot-target code.
findRefAnchorswitches behavior onisIDContext: whole-tree walk for ID, root + children for URL.checkRefConversiononly emits when both URL and ID contexts fail (a real typo). Per-context failures surface at the call site instead.- URLFor's call site no longer silently swallows Ref errors — it emits URL-context errors that the permissive conversion check intentionally lets through.
[]any chain form for ID / IDTarget
End-to-end validation of the new []any{Parent{}, Leaf{}, "Method"} and []any{Parent{}, Leaf{}.Method} shapes — mirroring the URLFor chain analyzer.
Install
go install github.com/jackielii/structpages/tools/lint/cmd/structpages-lint@v0.1.6Full diff
v0.3.0
What's Changed
- feat(lint): static analyzer for URLFor / ID / IDTarget / Ref by @jackielii in #10
Full Changelog: v0.2.0...v0.3.0
v0.2.0 — strict URLFor + chain form + Ref qualified path
Closes #8.
URLFor(ctx, SomeType{}) previously returned the first match silently when the same page type was mounted under multiple parents — production traffic going to the wrong page, no error, no warning. This release fixes that and adds the type-safe disambiguation primitive.
Highlights
Strict URLFor — no opt-out. Bare type lookups that match multiple nodes now error with every match listed. There is no `WithLenientURLFor` escape hatch; silent first-match is always wrong, so disambiguating at the call site is mandatory.
`[]any{...}` chain form. Inside the composition slice, leading typed values now form a chain through the page tree (each typed value descends into a child of that type). Strings still concat literally as URL fragments. Typed values after a string fragment are rejected.
```go
// Before: ambiguous, silently returned first match
url, _ := structpages.URLFor(ctx, entryPage{}, map[string]any{"slug": "button"})
// After: chain disambiguates by anchoring on the unique parent
url, _ := structpages.URLFor(ctx,
[]any{componentsRoot{}, entryPage{}},
map[string]any{"slug": "button"})
// → "/components/button"
```
`Ref` qualified path. `Ref("Parent.Field")` and `Ref("Grand.Parent.Field")` walk down by `PageNode.Name`. Existing `Ref("Name")` and `Ref("/route")` forms unchanged. This is the cross-package fallback for callers that can't import the typed page.
Recommended call shape: `URLFor(ctx, page, params)` with `map[string]any` for params.
Breaking changes
- Bare `URLFor(ctx, T{})` where `T` is mounted under multiple parents now errors. Migrate to `[]any{parent, T{}}` chain form or `Ref("Parent.Field")`.
- `[]any{TypedA, TypedB}` previously concatenated each one's FullRoute independently. It now forms a chain through the tree. No known callers in this repo or examples relied on the old semantics; if you did, switch to `[]any{TypedA, "/literal-path"}` or compose the routes manually.
Validation patterns
The new `examples/url-validation/` directory ships the full pattern for keeping URLs from drifting silently in production:
- `urls.go` — typed helper per URL family
- `validate.go` — `validateURLs(sp)` exercising every helper at boot; `main.go` calls it after `Mount` so a broken deploy refuses to start serving
- `integration_test.go` — renders every page and asserts URLs in the body; runs in CI
`skills/structpages/SKILL.md` §3 rewritten with the form table, chain semantics, and the validation pattern.
Migration cookbook
If you hit the strict-mode error in your app:
- Bare lookup of a uniquely-mounted type still works — most call sites need no change.
- Same type under multiple parents — switch to `[]any{parentType{}, leafType{}}`.
- Can't import the target page (cross-package) — switch to `structpages.Ref("Parent.Field")`.
- Validate every URL at boot — copy the `validateURLs` pattern from `examples/url-validation/validate.go`.
Full PR discussion: #9
v0.1.10
Features
-
WithURLPrefix(prefix string)option: tells structpages it is being served behind a path prefix that's stripped before requests reach the registered routes (e.g.,http.StripPrefixor a reverse proxy). The prefix is prepended to every URL returned byURLFor— both*StructPages.URLForand the request-contextURLFor(ctx, ...)— without affecting route registration.inner := http.NewServeMux() sp, _ := structpages.Mount(inner, pages{}, "/", "App", structpages.WithURLPrefix("/admin")) outer := http.NewServeMux() outer.Handle("/admin/", http.StripPrefix("/admin", inner)) // sp.URLFor(home{}) → "/admin" // sp.URLFor(users{}) → "/admin/users"
Tests
TestMountAtSubpathlocks in the existing behavior of mounting via Mount's route arg (e.g.,Mount(mux, pages, "/admin", "App")).TestMountUnderOuterMuxcovers both integration shapes: outer mux withStripPrefix+WithURLPrefix, and outer mux without stripping where the inner Mount registers at the prefix directly.
v0.1.9 — htmx 4 support, new examples, and Claude Code skill
New
HTMXv4RenderTarget(htmx.go) — opt-inTargetSelectorfor htmx 4 frontends. Handles htmx 4's reshaped headers:HX-Targetnow arrives as"<tag>#<id>"(or bare"<tag>"); the selector prefers the id when present, falls back to the tag, then applies the existing component-matching rules.HX-Request-Type: fullis honored as a hard hint to renderPage(covers<body>swaps andhx-select).HX-Sourceis htmx 4's trigger header — it identifies the trigger, not the swap target, so it is intentionally not used for component routing.- Wire it via
WithTargetSelector(structpages.HTMXv4RenderTarget). The defaultHTMXRenderTargetis unchanged and remains correct for htmx 1.x/2.x. - See: https://four.htmx.org/reference/#headers
Examples
examples/blog— comprehensive blog example with module-based component layout (atoms/molecules/layout), demonstrating Props, partial rendering, andURLForend-to-end (#5).examples/html-template— new example showing structpages with the stdlibhtml/templateengine (no Templ), including layout/partial composition and bindingurlForper request (#4, #6, #7).
Documentation & tooling
- New
skills/structpages/Claude Code skill (SKILL.md,reference.md,examples.md) plus a.claude-plugin/plugin.jsonmanifest, so library users can install structpages as a Claude Code plugin. - Docs realigned with current source; htmx and
IDFordocs corrected.
Internal
- Minor refactor in
formatPathSegments(url_for.go); behavior unchanged. golangci-lintcleanups; CI lint fix.
Upgrade
go get -u github.com/jackielii/structpages@v0.1.9
No breaking changes. HTMXRenderTarget users do not need to migrate; adopt HTMXv4RenderTarget only when serving an htmx 4 frontend.
Full Changelog: v0.1.8...v0.1.9