Skip to content

example(html-template): atomic-design layout + Props pattern + no-Clone urlFor#6

Merged
jackielii merged 8 commits into
mainfrom
feat/htmltemplate-view-and-atomic
May 7, 2026
Merged

example(html-template): atomic-design layout + Props pattern + no-Clone urlFor#6
jackielii merged 8 commits into
mainfrom
feat/htmltemplate-view-and-atomic

Conversation

@jackielii
Copy link
Copy Markdown
Owner

@jackielii jackielii commented May 7, 2026

Summary

Pure example PR. Demonstrates how to scale an html/template-based structpages app:

  1. Atomic-design directory layout with slash-namespaced template names — paralleling templ's examples/blog/.
  2. Props pattern for the new /post page: a single Props() method centralizes data loading; Page / Main / Comments component methods receive the loaded props.
  3. No-Clone helper pattern wired up in user code — template funcs take ctx as their first argument, so the FuncMap registers once at parse time and the same parsed *template.Template handles every request.

No library changes. The two helpers (urlFor, args) live right next to tpl in the example so the lesson is the pattern, not an API to import.

Atomic-design layout

templates/
  layout/public.html              # {{ define "layout/public" }}
  ui/atoms/button.html            # {{ define "ui/atoms/button" }}
  ui/molecules/card.html          # {{ define "ui/molecules/card" }}
  post/comments-list.html         # organism, HTMX-targetable
  post/page.html                  # {{ define "body" }} for /post
  pages/{index,product,team,contact}.html

Slash-namespaced template names match the directory tree. The new /post route demonstrates the full pattern: layout + 2 molecule cards (built with args) + a comments-list organism that HTMX-swaps independently.

Props pattern

func (post) Props() postProps { … }                       // single load point
func (post) Page(props postProps) tpl     { … }
func (post) Main(props postProps) tpl     { … }
func (post) Comments(props postProps) tpl { … }

structpages calls Props once per request, then dispatches to the matched component method passing the props as an argument.

tpl.Render — no Clone

func (p tpl) Render(ctx 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, view{Ctx: ctx, Data: p.data})
}

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)
  • Manual smoke of examples/html-template/:
    • full GET /post → layout + atoms/molecules + organism inline
    • HTMX nav swap (HX-Target: main) → just the body (Main(props))
    • HTMX organism swap (HX-Target: section#comments) → just the comments-list partial (Comments(props))
    • HX-Request-Type: full → full layout

Reviewer notes

  • The branch history reflects iteration through several library-API ideas (View[T], then helpers-only, then nothing); net diff is example-only. Squash on merge if the back-and-forth is noise.

… 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
Copy link
Copy Markdown

codecov Bot commented May 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.78%. Comparing base (449e74d) to head (55285f1).
⚠️ Report is 1 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jackielii added 4 commits May 7, 2026 13:58
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.
@jackielii jackielii changed the title feat(htmltemplate): View[T] (no Clone) + atomic-design example layout example(html-template): atomic-design layout + Props pattern + no-Clone urlFor May 7, 2026
jackielii added 3 commits May 7, 2026 16:18
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.
@jackielii jackielii merged commit 8b1a1da into main May 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant