example(html-template): atomic-design layout + Props pattern + no-Clone urlFor#6
Merged
Merged
Conversation
… data
Add a request-scoped data wrapper so templates can resolve URLs / IDs /
HTMX targets via the dot rather than a ctx-bound FuncMap, and a small
helper for passing multiple inputs to partials.
type View[T any] struct {
Ctx context.Context
Data T
}
func NewView[T any](ctx context.Context, data T) View[T]
func (v View[T]) URL(name string, args ...any) (string, error)
func (v View[T]) ID(name string) (string, error)
func (v View[T]) Target(name string) (string, error)
func (v View[T]) Sub(d any) View[any]
func Args(kv ...any) (map[string]any, error)
In templates: `{{ .URL "x" }}`, `{{ .Data.Title }}`, and partial calls
via either `{{ template "ui/molecules/card" (.Sub .Data.Card) }}` (for
organisms that want .URL/.ID inside) or `(args "Title" "x" "Body" "y")`
(for atoms/molecules taking ad-hoc fields).
This avoids the per-request `Clone()` + `Funcs(ctx)` dance that the old
Funcs-only API required: the same parsed *template.Template handles
every request, ctx flows through data.
Funcs is kept (and clarified in the package doc) for users who prefer
the func-call form or need pure ctx-less helpers like `humanize`.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6 +/- ##
==========================================
+ Coverage 96.65% 96.78% +0.12%
==========================================
Files 13 12 -1
Lines 1197 1183 -14
==========================================
- Hits 1157 1145 -12
+ Misses 26 24 -2
Partials 14 14 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Per review feedback: htmltemplate is a helper library, not a framework
that dictates a per-request data shape. Remove View[T] / NewView / Sub
and rebuild around standalone helpers that accept ctx as the first
argument.
func URLFor(ctx, name, args...) (string, error)
func ID(ctx, name) (string, error)
func IDTarget(ctx, name) (string, error)
func Args(kv...) (map[string]any, error)
func Funcs() template.FuncMap // bundles the four under conventional names
The FuncMap is registered ONCE at parse time; no per-request Clone is
needed because helpers don't close over a specific ctx — templates pass
ctx in explicitly: `{{ urlFor .Ctx "x" }}`.
How ctx ends up on the dot is up to the caller. The example uses a
view{Ctx, Data} struct as the template data; users can pick any shape
that suits.
Templates and example updated to the new pattern. tpl.Render is still
no-Clone (5 lines): wraps user data in view{ctx, data} and executes the
shared parsed template.
Centralize data loading in a single Props() method on the post page. structpages calls Props once per request and passes the return value as an argument to whichever component method is dispatched (Page, Main, or Comments), so each method receives the same props without re-loading. Mirrors the Props pattern used in examples/blog. The doc on Props mentions the RenderTarget-aware variant as a follow-on for skipping work when only a partial is being rendered.
Per review feedback: the helpers were thin wrappers + a kv→map builder.
Shipping them as a library inflates the surface area for what is
ultimately ~20 lines of glue any user can write themselves. Drop the
package; the example demonstrates the pattern by inlining the two
funcs it actually uses (urlFor and args) right next to the tpl wrapper.
structpages itself stays render-engine agnostic. The lesson the
example teaches is the registration pattern, not a library API:
- Helpers take ctx as first argument so the FuncMap registers ONCE
at parse time (no Clone-rebind per request).
- Templates pass ctx through the dot: `{{ urlFor .Ctx "x" }}`.
- The dot shape (a `view{Ctx, Data}` struct here) is user-chosen;
nothing prescribes it.
The layout (and the ui / post shared partials) are the same for every
page, so making them parameters implied a flexibility that didn't
exist. Bake them into parseSet; only the body file varies per call.
parseSet("pages/index.html")
parseSet("post/page.html")
…Clone
Each Render now Clones the page's base template and binds urlFor as a
ctx-bound closure before Execute. Templates call `{{ urlFor "x" }}`
without threading ctx, and the page data is passed as the dot directly
(no view{Ctx, Data} wrapper).
The trade-off is one Clone allocation per render. In exchange:
- templates lose `.Ctx` everywhere they call urlFor
- templates lose `.Data` everywhere they read page state
- partials don't have to thread ctx through args calls
{{ urlFor "post" }} (was: urlFor .Ctx "post")
{{ .Title }} (was: .Data.Title)
{{ template "post/comments-list" .Comments }} (was: (args "Ctx" $.Ctx "Data" .Data.Comments))
A urlForPlaceholder is registered at parse time so the parser accepts
references; it is replaced on every Clone in Render. If it ever fires
it returns an error pointing at the missing rebind — fail-loud instead
of silent empty URLs.
Parse templates inside main, after Mount returns the StructPages
instance. urlFor closes over sp.URLFor (which doesn't need a request
ctx), so the FuncMap is bound once and the same parsed *template.Template
serves every request — no Clone, no per-render rebinding, no
urlForPlaceholder.
Render shrinks to a lookup + Execute:
func (p tpl) Render(_ context.Context, w io.Writer) error {
t, ok := pageTmpls[p.page]
if !ok { return fmt.Errorf("unknown page %q", p.page) }
return t.ExecuteTemplate(w, p.entry, p.data)
}
The trade-off: urlFor doesn't have access to per-request URL params
extracted by structpages middleware, so this pattern only works for
routes whose URLs don't need request-bound params (top-level nav, the
case in this example). Routes like /users/{id} that want to generate
their own URL using the current id would still need ctx-bound funcs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Pure example PR. Demonstrates how to scale an
html/template-based structpages app:examples/blog/./postpage: a singleProps()method centralizes data loading;Page/Main/Commentscomponent methods receive the loaded props.*template.Templatehandles every request.No library changes. The two helpers (
urlFor,args) live right next totplin the example so the lesson is the pattern, not an API to import.Atomic-design layout
Slash-namespaced template names match the directory tree. The new
/postroute demonstrates the full pattern: layout + 2 molecule cards (built withargs) + acomments-listorganism that HTMX-swaps independently.Props pattern
structpages calls
Propsonce per request, then dispatches to the matched component method passing the props as an argument.tpl.Render — no Clone
view{Ctx, Data}is the example's chosen template-dot shape; nothing prescribes it. Templates call{{ urlFor .Ctx "Product" }}.Test plan
go test ./...,go vet ./...,golangci-lint run(via pre-commit hook)examples/html-template/:/post→ layout + atoms/molecules + organism inlineHX-Target: main) → just the body (Main(props))HX-Target: section#comments) → just the comments-list partial (Comments(props))HX-Request-Type: full→ full layoutReviewer notes