From c4e2ede5b601da5871fe376beeee48635e0e5aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andri=20=C3=93skarsson?= Date: Wed, 24 Jun 2026 21:39:50 +0200 Subject: [PATCH] docs: document attribute forwarding (attrs, twJoin/twMerge, WithClassMerger) Documents the attribute-forwarding feature: a Forwarding attributes section in components.md (gastro.Attrs, the attrs func, escaping/bypass, bool attrs, class merge, WithClassMerger, the typo-becomes-attribute tradeoff), an Attribute Forwarding section in helpers.md covering attrs/twJoin/twMerge, and WithClassMerger in the README option list. Refs #37. --- README.md | 5 ++- docs/components.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++ docs/helpers.md | 36 ++++++++++++++++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d40c1d7..dee9efb 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,10 @@ auto-generated route with a Go handler, `WithMiddleware(pattern, fn)` for wrapping routes (subtree wildcards via `"/admin/{path...}"`), `WithRequestFuncs(binder)` for **request-aware template helpers** (i18n translators, CSRF tokens, CSP nonces — see [Helpers → Request-aware -helpers](docs/helpers.md#request-aware-helpers-withrequestfuncs)), and +helpers](docs/helpers.md#request-aware-helpers-withrequestfuncs)), +`WithClassMerger(fn)` for plugging a Tailwind-aware class merger behind the +`attrs`/`twMerge` helpers (see [Components → Forwarding +attributes](docs/components.md#forwarding-attributes)), and `WithErrorHandler(fn)` for custom render-error responses (logging, error tracking, branded 500 pages). See [Pages & Routing](docs/pages.md) for the full API and [Error Handling](docs/error-handling.md) for the diff --git a/docs/components.md b/docs/components.md index 266e3e5..c2770e8 100644 --- a/docs/components.md +++ b/docs/components.md @@ -255,6 +255,93 @@ The parent passes children by wrapping content in the component tags: Children are rendered in the **parent's** data context, so they can reference the parent's template data. Only one `{{ .Children }}` is supported per component. +## Forwarding attributes + +A component that wraps a native element — a `Button`, `Input`, `Link` — +often needs to pass through arbitrary HTML attributes (`type`, `href`, +`data-*`, `aria-*`, Datastar's `data-on:*`) without declaring a typed prop +for each. Declare a field of type `gastro.Attrs` and it collects every +dict key that does **not** match a declared prop: + +```gastro +--- components/button.gastro +type Props struct { + Label string + Attrs gastro.Attrs +} + +Label := gastro.Props().Label +Attrs := gastro.Props().Attrs +--- + +``` + +Call it forwarding any attributes alongside the typed `Label`: + +```gastro +{{ Button (dict + "Label" "Save" + "type" "submit" + "class" "px-2" + "data-on:click" (safeJS "@post('/save')") +) }} +``` + +`Label` binds to the typed field; `type`, `class`, and `data-on:click` +fall through into `.Attrs` and are emitted by the `attrs` func. + +### The `attrs` func + +`{{ attrs .Attrs (dict ...base) }}` renders the bag as HTML attributes, +taking an optional second dict of **base defaults**: + +- Values are HTML-escaped by default. A value already marked safe + (`safeJS`, `safeAttr`, …) is emitted verbatim — the same contract as + those helpers. That is how Datastar expressions pass through unescaped. +- A `bool` value renders as a bare attribute when `true` (`disabled`) and + is omitted when `false`. +- Keys are emitted in sorted order; names outside `[A-Za-z0-9_:.-]` are + skipped, so a forwarded key can never break out of the tag. +- Base keys are defaults the caller can override — except `class`, which + is **merged** (below). Above, `type` defaults to `"button"` but the + caller's `"submit"` wins. + +### Merging classes (`twJoin` / `twMerge`) + +The base `class` and the forwarded `class` are combined through a **class +merger** rather than overwritten. The built-in merger only concatenates: + +``` +attrs .Attrs (dict "class" "btn px-4") + caller class "px-2" + → class="btn px-4 px-2" +``` + +Plain concatenation does **not** resolve Tailwind conflicts — `px-4` and +`px-2` both survive, and which wins is decided by the generated CSS order, +not the attribute. For real conflict resolution, plug a Tailwind-aware +merger via `WithClassMerger` in your `main.go`: + +```go +import twmerge "github.com/Oudwins/tailwind-merge-go" // lives in YOUR module + +router := gastro.New(gastro.WithClassMerger(twmerge.Merge)) +// → class="btn px-2" (px-4 dropped) +``` + +The dependency stays in your module — gastro core ships only the plain +`twJoin` merger and never imports a Tailwind library. The same merger +backs the `twMerge` template func; `twJoin` always plain-joins. See +[Template Helpers](helpers.md#attribute-forwarding). + +### Typos become attributes + +Because a `gastro.Attrs` field accepts arbitrary keys, the +[compile-time prop validation](#compile-time-prop-validation) below is +relaxed for that component: an unknown key is a forwarded attribute, not a +typo. A misspelled `Lable` silently becomes a `lable="…"` attribute rather +than a build error. Components without an `Attrs` field keep the strict +typo guard. + ## Compile-time prop validation Gastro statically validates the literal string keys you pass to `(dict ...)` diff --git a/docs/helpers.md b/docs/helpers.md index 1a51054..893aeb7 100644 --- a/docs/helpers.md +++ b/docs/helpers.md @@ -1,6 +1,6 @@ # Template Helpers -Gastro provides 21 built-in template functions available in all templates without registration. You can also add custom helpers. +Gastro provides 24 built-in template functions available in all templates without registration. You can also add custom helpers. ## String Functions @@ -110,6 +110,40 @@ frontmatter; these helpers let templates ask the question directly. | `hasKey` | Reports whether `key` is present in `m`. Works on any map (string-keyed, int-keyed, `map[any]bool`). Returns false for non-maps rather than panicking. | | `set` | Builds a `map[any]bool` from the given items. Combine with `hasKey` for efficient repeated membership tests. Unhashable items (slices, maps, funcs) are skipped silently. | +## Attribute Forwarding + +These funcs render a component's `gastro.Attrs` bag and merge Tailwind +class lists. See [Components → Forwarding attributes](components.md#forwarding-attributes) +for the full pattern. + +```go +// Render a bag of forwarded attributes, with base defaults. +