linkwell

package module
v0.2.26 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 7, 2026 License: MIT Imports: 8 Imported by: 0

README

linkwell

image

Go Reference

linkwell

WHERE ARE THE LINKS, KEVIN?

-- The PENTAVERB

A Go library for HATEOAS-style hypermedia controls, link relations (RFC 8288), and navigation primitives. Designed for server-rendered HTML apps using HTMX, but the data types are framework-agnostic.

Your JSON endpoint returns data. Your HTML page returns data AND WHAT TO DO WITH IT. linkwell is the library that makes "what to do with it" a first-class concern.

The Problem

Big Brain Developer say "I have built a micro-frontend architecture with seventeen independently deployable SPAs, each with its own state management solution, communicating through a custom event bus with schema validation."

Grug say "what it do"

Big Brain Developer say "it renders a table of users."

-- The Recorded Sayings of Layman Grug

Every server-rendered app reinvents the same things: nav bars with active states, breadcrumbs stitched together by hand, pagination controls, filter forms, modal configs, action buttons with confirmation dialogs. Each handler builds its own structs. Each project copies the last one's approach and changes just enough to break it.

// Hardcoded nav, scattered across handlers
type NavItem struct {
	Label, Href string
	Active      bool
}
nav := []NavItem{
	{"Users", "/admin/users", path == "/admin/users"},
	{"Roles", "/admin/roles", path == "/admin/roles"},
	{"Settings", "/admin/settings", path == "/admin/settings"},
}

// Breadcrumbs built by hand, per handler
crumbs := []Breadcrumb{
	{Label: "Home", Href: "/"},
	{Label: "Admin", Href: "/admin"},
	{Label: "Users", Href: ""},
}

// Pagination, filters, sort columns, modals, error controls --
// all custom structs, repeated in every project.

You have constructed an ENORMOUS and MAGNIFICENT cathedral of boilerplate for the sole purpose of avoiding a shared vocabulary for hypermedia controls.

The Fix

"before enlightenment: fetch JSON, parse JSON, validate JSON, transform JSON, store JSON in client state, derive view from client state, diff virtual DOM, reconcile DOM."

"and after enlightenment?"

"hx-get"

-- Layman Grug

Declare your link graph once. Query it at request time. Let the server drive the state.

// Register once at startup
linkwell.Hub("/admin", "Admin",
	linkwell.Rel("/admin/users", "Users"),
	linkwell.Rel("/admin/roles", "Roles"),
	linkwell.Rel("/admin/settings", "Settings"),
)

// At request time
crumbs := linkwell.BreadcrumbsFromLinks("/admin/users")
related := linkwell.RelatedLinksFor("/admin/users")
controls := linkwell.ResourceActions(linkwell.ResourceActionCfg{
	EditURL: "/admin/users/42/edit", DeleteURL: "/admin/users/42",
	Target: "#content", ConfirmMsg: "Delete this user?",
})

linkwell provides:

  • A link registry for declaring relationships between pages (related, parent/child, hub-and-spoke)
  • Breadcrumb generation from link graphs or URL paths
  • Hypermedia controls -- pure-data descriptors for buttons, actions, and navigation affordances
  • Tabs for in-page tabbed navigation with HTMX lazy-loading
  • Filter, table, and pagination primitives for data-heavy views
  • Modal configuration with preset button sets
  • Toast notifications for success/info/warning/error feedback
  • Stepper for multi-step wizard flows with auto-generated navigation controls
  • Navigation types with active-state computation
  • Error controls dispatched by HTTP status code
  • Graph validation for catching link registration bugs at test time
  • Sitemap generation from the link registry

No rendering logic. No framework coupling. Just data structures that your templates consume. The server decides what actions are available, and the controls carry that decision to the HTML.

Install

go get github.com/catgoose/linkwell

Import with an alias if you prefer:

import hypermedia "github.com/catgoose/linkwell"

The server sends a representation. The representation contains links and forms. The client follows them. THAT IS THE ENTIRE INTERACTION MODEL.

-- The Wisdom of the Uniform Interface

The link registry maintains an in-memory graph of relationships between pages. Register links at startup (typically in route initialization), then query them at request time.

Link registers a directional relationship. For rel="related", the inverse is automatically created (symmetric).

// Inventory links to Warehouses
linkwell.Link("/inventory", "related", "/warehouses", "Warehouses")
// The inverse "/warehouses" -> "/inventory" is auto-registered

// One-way parent link
linkwell.Link("/users/42", "up", "/users", "Users")
Ring (Symmetric Group)

Ring connects every member to every other member with rel="related". Use for peer pages that should cross-link.

linkwell.Ring("Logistics",
	linkwell.Rel("/inventory", "Inventory"),
	linkwell.Rel("/warehouses", "Warehouses"),
	linkwell.Rel("/shipments", "Shipments"),
)
// Each page now links to the other two with Group="Logistics"
Hub (Star Topology)

Hub connects a center page to spokes. Spokes link back to the center with rel="up" but do not link to each other.

linkwell.Hub("/admin", "Admin",
	linkwell.Rel("/admin/users", "Users"),
	linkwell.Rel("/admin/roles", "Roles"),
	linkwell.Rel("/admin/settings", "Settings"),
)

Query all hubs for site map rendering:

hubs := linkwell.Hubs() // []HubEntry sorted by path
for _, hub := range hubs {
	fmt.Println(hub.Title, hub.Path)
	for _, spoke := range hub.Spokes {
		fmt.Println("  ", spoke.Title, spoke.Href)
	}
}

LinksFor walks parent path segments when the exact path has no registered links, so /admin/apps/1 inherits links from /admin/apps.

// All links for a path (walks parent paths if needed)
links := linkwell.LinksFor("/inventory")

// Only specific relation types
related := linkwell.LinksFor("/inventory", "related")
parents := linkwell.LinksFor("/admin/users", "up")

// Related links excluding self (for context bars)
peers := linkwell.RelatedLinksFor("/inventory")

// Full registry snapshot (for admin/debug)
all := linkwell.AllLinks()

Format links as a standard Link HTTP header. Titles are properly escaped per RFC 7230 quoted-string rules.

links := linkwell.LinksFor("/inventory")
header := linkwell.LinkHeader(links)
// </warehouses>; rel="related"; title="Warehouses", ...

Named constants cover common IANA relation types: RelRelated, RelUp, RelSelf, RelAlternate, RelCanonical, RelFirst, RelLast, RelNext, RelPrev, RelCollection, RelItem.

Title From Path

A resource is any concept important enough to be named. And naming is the first and most dangerous act of computing.

-- The Wisdom of the Uniform Interface

TitleFromPath derives a human-readable title from the last segment of a URL path. Hyphens become spaces and each word is title-cased. Useful for auto-generating labels from route paths when no explicit title is registered.

linkwell.TitleFromPath("/demo/inventory")     // "Inventory"
linkwell.TitleFromPath("/admin/error-traces")  // "Error Traces"

This is the same logic used internally by BreadcrumbsFromPath to label segments.

Load and remove links at runtime (e.g., from a database):

linkwell.LoadStoredLink("/projects/42", linkwell.LinkRelation{
	Rel: "related", Href: "/teams/7", Title: "Backend Team",
})

linkwell.RemoveLink("/projects/42", "/teams/7", "related")

Breadcrumbs

The links are RIGHT THERE. In the HTML. They have been there this whole time. You have been stepping over them to get to your OpenAPI generator.

-- The Wisdom of the Uniform Interface

Walk the rel="up" chain from a path to build a breadcrumb trail. Requires links registered via Hub or Link with rel="up". Registered titles are preserved -- if a spoke was registered with a custom title, that title appears in the breadcrumb instead of the path-derived label.

// Given: Hub("/admin", "Admin", Rel("/admin/users", "User Directory"))
crumbs := linkwell.BreadcrumbsFromLinks("/admin/users")
// [{Label:"Home" Href:"/"}, {Label:"Admin" Href:"/admin"}, {Label:"User Directory" Href:""}]

The terminal breadcrumb has an empty Href (current page, rendered as text).

From URL Path

Generate breadcrumbs from URL segments. Each segment is title-cased (hyphens become spaces, first letter of each word capitalised). Override labels by segment index.

crumbs := linkwell.BreadcrumbsFromPath("/users/42/edit", map[int]string{
	1: "Jane Doe", // override "42" with a name
})
// [{Label:"Home" Href:"/"}, {Label:"Users" Href:"/users"},
//  {Label:"Jane Doe" Href:"/users/42"}, {Label:"Edit" Href:""}]
Bitmask Breadcrumbs

For pages reachable from multiple parents, use a ?from= bitmask to preserve navigation context.

// Register origins at startup
linkwell.RegisterFrom(linkwell.FromDashboard, linkwell.Breadcrumb{
	Label: "Dashboard", Href: "/dashboard",
})

// In handler: parse the ?from= param and resolve breadcrumbs
mask := linkwell.ParseFromParam(c.QueryParam("from"))
crumbs := linkwell.ResolveFromMask(mask)
// Home is always included at bit 0

// Forward context to outbound links
href := linkwell.FromNav("/users/42", c.QueryParam("from"))
// "/users/42?from=3" if mask was 3

Controls

Hypertext is the simultaneous presentation of information and controls such that the information BECOMES THE AFFORDANCE through which choices are obtained and actions are selected.

-- The Wisdom of the Uniform Interface

A Control is a pure-data descriptor for a hypermedia affordance (button, link, action). Templates consume controls to render the appropriate HTML element -- the control itself has no rendering logic.

The <button> with an hx-delete says "here is something you can destroy, and here is exactly how to destroy it, and here is where the confirmation dialog will appear, and none of this required a README." linkwell makes your server produce those controls as data, so your templates just render them.

Factory Functions
// HTMX retry button
ctrl := linkwell.RetryButton("Retry", linkwell.HxMethodGet, "/api/data", "#content")

// Danger button with confirmation dialog
ctrl := linkwell.ConfirmAction("Delete", linkwell.HxMethodDelete, "/users/42", "#user-list", "Delete this user?")

// Browser back button (no server round-trip)
ctrl := linkwell.BackButton("Go Back")

// Home navigation
ctrl := linkwell.GoHomeButton("Go Home", "/", "body")

// Plain anchor link
ctrl := linkwell.RedirectLink("View Profile", "/users/42")

// Arbitrary HTMX action
ctrl := linkwell.HTMXAction("Archive", linkwell.HxPost("/users/42/archive", "#content"))

// Dismiss button (HyperScript close)
ctrl := linkwell.DismissButton("Close")

// Report issue modal trigger
ctrl := linkwell.ReportIssueButton("Report Issue", requestID)
Control Modifiers

Controls are value types. Modifiers return copies.

ctrl := linkwell.RetryButton("Retry", linkwell.HxMethodGet, "/api/data", "#content").
	WithSwap(linkwell.SwapOuterHTML).
	WithVariant(linkwell.VariantDanger).
	WithIcon(linkwell.IconCheck).
	WithConfirm("Are you sure?").
	WithDisabled(true).
	WithErrorTarget("#inline-error")
HTMX Request Config

Build HTMX request attributes programmatically:

req := linkwell.HxGet("/users", "#user-list")
req = req.WithInclude("closest form")

// Or build manually
req := linkwell.HxRequestConfig{
	Method:  linkwell.HxMethodPost,
	URL:     "/users",
	Target:  "#user-list",
	Include: "closest form",
	Vals:    `{"status":"active"}`,
}

// Convert to attribute map for interop
attrs := req.Attrs() // map[string]string{"get": "/users", "target": "#user-list", ...}

Navigation

NavConfig and NavItem

NavConfig holds the app navigation layout. NavItem is a single navigation entry with optional HTMX attributes and children.

nav := linkwell.NavConfig{
	AppName:    "My App",
	MaxVisible: 5, // overflow after 5 items
	Items: []linkwell.NavItem{
		{Label: "Dashboard", Href: "/dashboard", Icon: "home"},
		{Label: "Users", Href: "/users", Icon: "users", Children: []linkwell.NavItem{
			{Label: "Active", Href: "/users?status=active"},
			{Label: "Invited", Href: "/users?status=invited"},
		}},
	},
}

Bridge a Control to a NavItem:

navItem := linkwell.NavItemFromControl(ctrl)
Active State

Set the active nav item based on the current request path. Exact match:

items := linkwell.SetActiveNavItem(nav.Items, "/users")

Prefix match (section-level navigation):

// "/users" is active when path is "/users/42/edit"
items := linkwell.SetActiveNavItemPrefix(nav.Items, "/users/42/edit")

Both functions handle nested children: a parent is marked active if any child matches.

Tabs

Student ask Grug: "what is the way of the hypermedia?"

Grug say: "server return html. browser render html. what is difficult?"

-- The Recorded Sayings of Layman Grug

TabConfig and TabItem describe in-page tabbed navigation. Structurally similar to NavItem but scoped to a content panel with HTMX lazy-load semantics. The server decides which tabs exist and which is active.

tabs := linkwell.NewTabConfig("user-tabs", "#tab-content",
	linkwell.TabItem{Label: "Overview", Href: "/users/42/overview", Icon: "user"},
	linkwell.TabItem{Label: "Activity", Href: "/users/42/activity", Icon: "clock"},
	linkwell.TabItem{Label: "Settings", Href: "/users/42/settings", Icon: "cog"},
)
tabs.Items = linkwell.SetActiveTab(tabs.Items, "/users/42/activity")

Each TabItem supports:

  • Target -- per-tab hx-target override (falls back to TabConfig.Target)
  • Badge -- optional count/status indicator
  • Swap -- HTMX swap strategy (defaults to innerHTML)
  • Disabled -- non-interactive state

Filters

FilterBar

FilterBar describes a filter form. The form ID enables hx-include from pagination/sort links.

bar := linkwell.NewFilterBar("/users", "#user-table",
	linkwell.SearchField("q", "Search users...", currentQuery),
	linkwell.SelectField("status", "Status", currentStatus, linkwell.SelectOptions(currentStatus,
		"", "All",
		"active", "Active",
		"inactive", "Inactive",
	)),
	linkwell.CheckboxField("verified", "Verified only", verifiedParam),
)
Field Types
linkwell.SearchField(name, placeholder, value)
linkwell.SelectField(name, label, value, options)
linkwell.RangeField(name, label, value, min, max, step)
linkwell.CheckboxField(name, label, value) // value: "true" or ""
linkwell.DateField(name, label, value)

Build select options from flat pairs:

opts := linkwell.SelectOptions(currentValue,
	"draft", "Draft",
	"published", "Published",
	"archived", "Archived",
)
FilterGroup

FilterGroup wraps a FilterBar and supports dynamic option updates and OOB swap fragments.

group := linkwell.NewFilterGroup("/products", "#product-table",
	linkwell.SearchField("q", "Search...", ""),
	linkwell.SelectField("category", "Category", "", categories),
)

// Update options dynamically
group.UpdateOptions("category", newCategories)

// Get only select fields for OOB rendering
selects := group.SelectFields()

Tables and Pagination

"You been repeating yourself since 2013. Every year new hook. Every year same table."

-- The Recorded Sayings of Layman Grug

Sortable Columns

SortableCol creates a column descriptor with sort state and toggle URL precomputed.

cols := []linkwell.TableCol{
	linkwell.SortableCol("name", "Name", sortKey, sortDir, baseURL, "#table", "#filter-form"),
	linkwell.SortableCol("email", "Email", sortKey, sortDir, baseURL, "#table", "#filter-form"),
	{Key: "actions", Label: "Actions"}, // non-sortable
}

Sort direction toggles: unsorted -> asc -> desc -> asc.

Pagination
totalPages := linkwell.ComputeTotalPages(totalItems, perPage)

info := linkwell.PageInfo{
	BaseURL:    "/users?q=foo",
	Page:       currentPage,
	PerPage:    25,
	TotalItems: totalItems,
	TotalPages: totalPages,
	Target:     "#user-table",
	Include:    "#filter-form",
}

controls := linkwell.PaginationControls(info)
// Returns nil if TotalPages <= 1
// Controls: [First] [Prev] [1] [2] [3] [Next] [Last]
// Current page is disabled with VariantPrimary

Generate a URL for a specific page:

url := info.URLForPage(3) // "/users?q=foo&page=3"

Modals

ModalConfig describes everything needed to render a modal dialog.

modal := linkwell.ModalConfig{
	ID:       "delete-user-modal",
	Title:    "Delete User",
	Buttons:  linkwell.ModalDeleteCancel,
	HxPost:   "/users/42/delete",
	HxTarget: "#user-list",
	HxSwap:   linkwell.SwapOuterHTML,
}

Preset button sets:

Set Buttons
ModalOK OK
ModalYesNo No, Yes
ModalSaveCancel Cancel, Save
ModalSaveCancelReset Reset, Cancel, Save
ModalSubmitCancel Cancel, Submit
ModalConfirmCancel Cancel, Confirm (danger)
ModalDeleteCancel Cancel, Delete (danger)

Report issue modal shortcut:

modal := linkwell.ReportIssueModal(requestID)

Toasts

THE FOOL deleted the API client. THE FOOL deleted the route constants. THE FOOL wrote an <a> tag. The browser followed it. It worked.

-- The Dothog Manifesto

Toast is the success/info/warning complement to ErrorContext. The server decides what feedback to show and the template renders the appropriate notification. Toasts are value types -- use the With* methods to derive modified copies.

// Factory functions for each variant
toast := linkwell.SuccessToast("User created")
toast := linkwell.InfoToast("Export queued")
toast := linkwell.WarningToast("API rate limit approaching")
toast := linkwell.ErrorToast("Upload failed")

Builder chain for a full notification with an undo action:

toast := linkwell.SuccessToast("User deleted").
	WithControls(linkwell.HTMXAction("Undo", linkwell.HxPost("/users/42/restore", "#user-table"))).
	WithAutoDismiss(5).
	WithOOB("#toast-container", "afterbegin")
Field Purpose
Message User-visible notification text
Variant Visual style: success, info, warning, error
Controls Optional action affordances (e.g., Undo button)
AutoDismiss Seconds before auto-close (0 = sticky)
OOBTarget CSS selector for HTMX OOB swap target
OOBSwap hx-swap-oob strategy (e.g., "afterbegin")

Stepper

grug supernatural power and marvelous activity: returning html and carrying single binary.

-- The Recorded Sayings of Layman Grug

StepperConfig describes a multi-step wizard flow where the server knows the full step sequence, current position, and completion state. Navigation controls are auto-generated.

stepper := linkwell.NewStepper(1, // currently on step 2 (0-indexed)
	linkwell.Step{Label: "Account", Href: "/onboard/account", Icon: "user"},
	linkwell.Step{Label: "Profile", Href: "/onboard/profile", Icon: "id-card"},
	linkwell.Step{Label: "Preferences", Href: "/onboard/prefs", Icon: "sliders"},
	linkwell.Step{Label: "Review", Href: "/onboard/review", Icon: "check-circle"},
)

NewStepper auto-computes:

  • Steps before the current index are marked StepComplete
  • The current step is marked StepActive
  • Steps after are marked StepPending
  • Pre-set statuses like StepSkipped are preserved
  • Prev control points to the previous step (nil on first)
  • Next control points to the next step (nil on last)
  • Submit control appears only on the final step (with VariantPrimary)

Action Patterns

HATEOAS: Hypermedia As The Engine Of Application State. Yes, it is an ugly acronym. The truth is not always beautiful. Sometimes the truth is an ugly acronym that you should have tattooed on the inside of your eyelids.

-- The Wisdom of the Uniform Interface

Action patterns are HATEOAS made concrete -- the server decides what actions are available, and the controls carry that decision to the template. The representation tells the client what is possible RIGHT NOW. When what is possible changes, the representation changes, and the client adapts.

All pattern functions conditionally include controls based on which URLs are provided -- omit a URL to hide that action.

Resource Actions

Edit + Delete controls for resource detail pages:

controls := linkwell.ResourceActions(linkwell.ResourceActionCfg{
	EditURL:     "/users/42/edit",
	DeleteURL:   "/users/42",
	ConfirmMsg:  "Delete this user?",
	Target:      "#content",
	ErrorTarget: "#error",
})
Row Actions

Controls for table rows with different swap targets:

// Edit swaps the row, Delete swaps the row
controls := linkwell.RowActions(linkwell.RowActionCfg{
	EditURL:     "/users/42/edit",
	DeleteURL:   "/users/42",
	RowTarget:   "#row-42",
	ConfirmMsg:  "Delete this user?",
	ErrorTarget: "#row-42-error",
})

// Edit swaps the row, Delete replaces the whole table
controls := linkwell.TableRowActions(linkwell.TableRowActionCfg{
	EditURL:     "/users/42/edit",
	DeleteURL:   "/users/42",
	RowTarget:   "#row-42",
	TableTarget: "#user-table",
	ConfirmMsg:  "Delete this user?",
})

Inline edit row controls:

// Existing row: Save uses PUT
controls := linkwell.RowFormActions(linkwell.RowFormActionCfg{
	SaveURL:      "/users/42",
	CancelURL:    "/users/42",
	SaveTarget:   "#row-42",
	CancelTarget: "#row-42",
})

// New row: Save uses POST
controls := linkwell.NewRowFormActions(linkwell.RowFormActionCfg{
	SaveURL:      "/users",
	CancelURL:    "/users/new/cancel",
	SaveTarget:   "#new-row",
	CancelTarget: "#new-row",
})
Form Actions

Save + Cancel for form footers. Save uses hx-include="closest form".

controls := linkwell.FormActions("/users")
Bulk Actions

Toolbar controls for batch operations on checkbox-selected rows:

controls := linkwell.BulkActions(linkwell.BulkActionCfg{
	DeleteURL:        "/users/bulk-delete",
	ActivateURL:      "/users/bulk-activate",
	DeactivateURL:    "/users/bulk-deactivate",
	TableTarget:      "#user-table",
	CheckboxSelector: ".user-checkbox",
})
Empty State and Catalog
ctrl := linkwell.EmptyStateAction("Create First User", "/users/new", "#content")
ctrl := linkwell.CatalogRowAction("/products/42/details", "#detail-row-42")

Error Controls

Guilt is a client-side state and the server does not care about client-side state. The server has never cared about client-side state. This is the First Lesson and also the Last Lesson.

-- The PENTAVERB

Status-code-specific control sets for error pages:

// Dispatch by status code
controls := linkwell.ErrorControlsForStatus(404, linkwell.ErrorControlOpts{
	HomeURL: "/",
})

// Or use individual builders
controls := linkwell.NotFoundControls("/")          // [Back, GoHome]
controls := linkwell.ServiceErrorControls(opts)     // [Retry?, Dismiss]
controls := linkwell.UnauthorizedControls("/login") // [Log In?, Dismiss]
controls := linkwell.ForbiddenControls()            // [Back, Dismiss]
controls := linkwell.InternalErrorControls(opts)    // [Retry?, Dismiss]

ErrorContext carries the full error state through a rendering pipeline:

ec := linkwell.ErrorContext{
	StatusCode: 500,
	Message:    "Database connection failed",
	Route:      "/api/users",
	RequestID:  "abc-123",
	Controls:   linkwell.InternalErrorControls(opts),
	Closable:   true,
}

// Fluent modifiers
ec = ec.WithControls(linkwell.ReportIssueButton("Report", "abc-123"))
ec = ec.WithOOB("#error-status", "innerHTML")

// Wrap as a returnable error
return linkwell.NewHTTPError(ec)

Graph Validation

If your client must read your API docs to know which URL to POST to, that is out-of-band. If your client must be recompiled when you rename a resource, you have coupled the client to the server's URI structure and you will maintain this coupling in blood and tears until one of you is decommissioned.

-- The Wisdom of the Uniform Interface

Link registration bugs are silent -- a typo in a path means a breadcrumb chain breaks or a related link disappears. ValidateGraph catches structural issues at test time before they reach production.

func TestLinkGraphIntegrity(t *testing.T) {
	setupRoutes(e)

	issues := linkwell.ValidateGraph()
	for _, issue := range issues {
		t.Errorf("link graph: %s -- %s (%s)", issue.Path, issue.Message, issue.Kind)
	}
}

Detected issues:

Kind Description
orphan Path with no inbound links from any other path
broken_up rel="up" target is not registered
dead_spoke Hub spoke path has no registered links

Validate that registered paths match your router's route set:

func TestAllLinksMatchRoutes(t *testing.T) {
	router := setupRouter()
	routes := extractRoutes(router)

	issues := linkwell.ValidateAgainstRoutes(routes)
	for _, issue := range issues {
		t.Errorf("route mismatch: %s -- %s", issue.Path, issue.Message)
	}
}
Kind Description
unregistered_route Link graph path (source or target) has no matching route
missing_route Route has no link graph presence

Sitemap

Derive a structured sitemap from the link registry. The hub/spoke/ring topology already contains the page hierarchy -- Sitemap exposes it as a queryable data type without maintaining a separate definition. Target-only pages (paths that appear only as link targets, never as source keys) are included automatically, so every reachable page in the graph has a sitemap entry.

// Full sitemap sorted by path
entries := linkwell.Sitemap()

// Only top-level entries (no parent)
roots := linkwell.SitemapRoots()

// HTML sitemap page
for _, entry := range entries {
	fmt.Printf("%s (%s)\n", entry.Path, entry.Title)
	if entry.Parent != "" {
		fmt.Printf("  parent: %s\n", entry.Parent)
	}
	for _, child := range entry.Children {
		fmt.Printf("  child: %s\n", child)
	}
}

// XML sitemap generation
for _, entry := range roots {
	fmt.Fprintf(w, "<url><loc>%s%s</loc></url>\n", baseURL, entry.Path)
}

Each SitemapEntry provides:

Field Source
Path Registered path (includes target-only pages)
Title Hub title, registered link title, or derived from path
Parent rel="up" target (empty for roots)
Children Hub spoke paths
Group Ring group name

Speculation Rules

The Speculation Rules API lets browsers prefetch or prerender pages before the user navigates, declared as JSON inside a <script type="speculationrules"> block. No JavaScript required -- the browser handles it natively.

linkwell's link registry already describes the navigation graph. Hub spokes are natural prefetch candidates (list-to-detail navigation), and Ring members are moderate candidates (peer navigation). The same data that drives breadcrumbs and sitemaps can drive speculation rules.

Why this lives in application code, not linkwell: linkwell is a data library with no rendering logic and no browser-specific concerns. Speculation rules are an output format -- the same way linkwell doesn't generate HTML but provides data for templates to consume, it doesn't generate <script> tags but provides the navigation data you need to build them.

Build speculation rules from the registry in your application:

// speculationRules builds a Speculation Rules JSON string from linkwell's
// link registry. Hub spokes get prefetched on hover (moderate eagerness),
// giving users near-instant navigation to detail pages.
func speculationRules() string {
    var patterns []string
    for _, hub := range linkwell.Hubs() {
        for _, spoke := range hub.Spokes {
            patterns = append(patterns, spoke.Href+"*")
        }
    }
    if len(patterns) == 0 {
        return ""
    }
    rules := map[string]any{
        "prefetch": []map[string]any{{
            "where":    map[string]any{"href_matches": patterns},
            "eagerness": "moderate",
        }},
    }
    b, _ := json.Marshal(rules)
    return string(b)
}

Emit it in your base layout template:

<!-- In your base layout template -->
{{ if $rules := speculationRules }}
<script type="speculationrules">{{ $rules }}</script>
{{ end }}

Browser support: Chrome 121+, Edge 121+. Other browsers ignore the <script type="speculationrules"> tag entirely, so this is progressive enhancement -- free to add, zero cost when unsupported.

Thread Safety

All registry operations (Link, Ring, Hub, LinksFor, AllLinks, Hubs, LoadStoredLink, RemoveLink) are protected by sync.RWMutex and are safe for concurrent use. The typical pattern is init-time registration (call Link, Ring, and Hub during route setup before the server starts accepting requests), then read with LinksFor, AllLinks, Hubs, etc. at request time.

RegisterFrom and ResolveFromMask are similarly protected and safe for concurrent use.

Testing

Use ResetForTesting to clear all registries (links, hubs, and breadcrumb-origin registrations) between tests. The Home breadcrumb (bit 0) is re-registered automatically. It is intended for test setup/teardown only and must not be called concurrently with request handlers. In parallel tests, call it at the start of each subtest and register it with t.Cleanup:

func TestMyHandler(t *testing.T) {
	linkwell.ResetForTesting()
	t.Cleanup(linkwell.ResetForTesting)

	linkwell.Hub("/admin", "Admin",
		linkwell.Rel("/admin/users", "Users"),
	)
	// ... test logic
}

Recipes

See recipes.md for integration patterns showing linkwell and tavern working together in server-rendered HTMX apps. Recipes cover live dashboards, real-time tables, delete-with-broadcast, scoped notifications, lifecycle-aware publishing, and multi-step wizard flows.

Philosophy

THE FOOL deleted the route constants. THE FOOL deleted the URL builder. THE FOOL deleted the TypeScript interfaces. THE FOOL wrote an <a> tag. The browser followed it. It worked. It had always worked.

-- The Dothog Manifesto

linkwell follows the dothog design philosophy: the server is the source of truth for navigation, the link registry is just a data structure, and templates consume pure data -- no rendering logic in the library.

The whole point of hypermedia is that the server tells the client what to do next IN THE RESPONSE ITSELF. linkwell is the vocabulary for expressing that. Register your link graph once, query it at request time, and let the controls carry the server's decisions to the HTML. The client receives a page, sees what it can do, and does it. Like a person. Using a website.

Grug's supernatural power and marvelous activity: returning HTML and carrying a single binary.

Architecture

How linkwell drives navigation
  startup                          request time
  ───────                          ────────────

  Hub("/admin", "Admin",           links := LinksFor("/admin/users")
    Rel("/admin/users", ...),      crumbs := BreadcrumbsFromLinks(path)
    Rel("/admin/roles", ...),      controls := ResourceActions(cfg)
  )                                         │
       │                                    v
       v                              ┌───────────┐
  ┌──────────┐                        │  template  │
  │ registry │ ◄── query at ──────►   │  renders   │
  │ (links)  │     request time       │  controls  │
  └──────────┘                        └───────────┘

Enter the application with a single URI and a set of standardized media types. Follow the links. Submit the forms. Let the server drive the state. That is all.

-- The Wisdom of the Uniform Interface

That is linkwell's entire design. There is no conclusion. There is only the next request.

License

MIT

Documentation

Overview

Package linkwell provides types and helpers for HATEOAS-style hypermedia controls, link relations (RFC 8288), and navigation primitives. All types are pure data descriptors — they carry no rendering logic and can be consumed by any template engine (templ, html/template, etc.) or serialized to JSON.

Index

Examples

Constants

View Source
const (
	IncludeClosestTR   = "closest tr"
	IncludeClosestForm = "closest form"
)

Include selector constants for HxRequestConfig.Include.

View Source
const (
	LabelEdit           = "Edit"
	LabelDelete         = "Delete"
	LabelDeleteSelected = "Delete Selected"
	LabelSave           = "Save"
	LabelCancel         = "Cancel"
	LabelDetails        = "Details"
	LabelActivate       = "Activate"
	LabelDeactivate     = "Deactivate"
	LabelGoBack         = "Go Back"
	LabelGoHome         = "Go Home"
	LabelRetry          = "Retry"
	LabelDismiss        = "Close"
	LabelLogIn          = "Log In"
	LabelReportIssue    = "Report Issue"
)

Default labels used by pattern factories. Override by building Controls directly.

View Source
const (
	RelRelated    = "related"
	RelUp         = "up"
	RelSelf       = "self"
	RelAlternate  = "alternate"
	RelCanonical  = "canonical"
	RelFirst      = "first"
	RelLast       = "last"
	RelNext       = "next"
	RelPrev       = "prev"
	RelCollection = "collection"
	RelItem       = "item"
)

Common IANA link relation types (RFC 8288). See https://www.iana.org/assignments/link-relations/ for the full registry.

View Source
const (
	LabelPrevious = "Previous"
	LabelNext     = "Next"
	LabelSubmit   = "Submit"
)

Default labels for stepper navigation controls.

View Source
const (
	ParamSort = "sort"
	ParamDir  = "dir"
	ParamPage = "page"
)

Query parameter keys used by sort and pagination helpers.

View Source
const (
	PaginationFirst = "«"
	PaginationPrev  = "‹"
	PaginationNext  = "›"
	PaginationLast  = "»"
)

Pagination navigation labels.

View Source
const BreadcrumbLabelHome = "Home"

BreadcrumbLabelHome is the default label for the root breadcrumb segment.

View Source
const (
	ConfirmDeleteSelected = "Delete selected items?"
)

Default confirm messages used by pattern factories.

View Source
const DefaultErrorStatusTarget = "#error-status"

DefaultErrorStatusTarget is the CSS selector for the error status container used by OOB placements and response builders to target the error panel.

View Source
const DefaultFilterFormID = "filter-form"

DefaultFilterFormID is the HTML form id used by NewFilterBar.

View Source
const (
	TargetBody = "body"
)

Default HTMX target selectors used by pattern factories.

Variables

View Source
var (
	ModalOK = ModalButtonSet{
		{Label: "OK", Role: ModalRolePrimary, Variant: VariantPrimary},
	}
	ModalYesNo = ModalButtonSet{
		{Label: "No", Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: "Yes", Role: ModalRolePrimary, Variant: VariantPrimary},
	}
	ModalSaveCancel = ModalButtonSet{
		{Label: LabelCancel, Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: LabelSave, Role: ModalRolePrimary, Variant: VariantPrimary},
	}
	ModalSaveCancelReset = ModalButtonSet{
		{Label: "Reset", Role: ModalRoleSecondary, Variant: VariantSecondary},
		{Label: LabelCancel, Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: LabelSave, Role: ModalRolePrimary, Variant: VariantPrimary},
	}
	ModalSubmitCancel = ModalButtonSet{
		{Label: LabelCancel, Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: "Submit", Role: ModalRolePrimary, Variant: VariantPrimary},
	}
	ModalConfirmCancel = ModalButtonSet{
		{Label: LabelCancel, Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: "Confirm", Role: ModalRolePrimary, Variant: VariantDanger},
	}
	ModalDeleteCancel = ModalButtonSet{
		{Label: LabelCancel, Role: ModalRoleCancel, Variant: VariantGhost},
		{Label: LabelDelete, Role: ModalRolePrimary, Variant: VariantDanger},
	}
)

Preset modal button sets.

Functions

func AllLinks() map[string][]LinkRelation

AllLinks returns a snapshot of all registered link relations grouped by source path. The returned map and slices are copies, safe to modify. Useful for admin dashboards or debug pages that inspect the link graph.

func ComputeTotalPages

func ComputeTotalPages(totalItems, perPage int) int

ComputeTotalPages calculates the number of pages needed to display totalItems at the given perPage size. Always returns at least 1.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	fmt.Println(linkwell.ComputeTotalPages(100, 25))
	fmt.Println(linkwell.ComputeTotalPages(101, 25))
	fmt.Println(linkwell.ComputeTotalPages(0, 25))
}
Output:
4
5
1

func FromNav

func FromNav(href, from string) string

FromNav appends the ?from= parameter to a href, preserving any existing query string. Returns href unchanged if from is empty. Use in templates to forward breadcrumb context to outbound links.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	fmt.Println(linkwell.FromNav("/users/42", "3"))
	fmt.Println(linkwell.FromNav("/users?q=foo", "3"))
	fmt.Println(linkwell.FromNav("/users/42", ""))
}
Output:
/users/42?from=3
/users?q=foo&from=3
/users/42

func FromParam

func FromParam(mask uint64) string

FromParam formats a bitmask as a decimal string suitable for ?from= query parameter values.

func FromQueryString

func FromQueryString(mask uint64) string

FromQueryString returns "from=N" formatted for inclusion in URL query strings. Returns an empty string if mask is 0 (no origin context).

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	fmt.Println(linkwell.FromQueryString(3))
	fmt.Println(linkwell.FromQueryString(0))
}
Output:
from=3

func Hub

func Hub(centerPath, centerTitle string, spokes ...RelEntry)

Hub registers a star topology where a center page links to all spokes via rel="related", and each spoke links back to the center via rel="up". Spokes do not link to each other. The centerTitle is used as the Group field on all links and as the hub label returned by Hubs. Registration is safe for concurrent use.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.ResetForTesting()

	linkwell.Hub("/admin", "Admin",
		linkwell.Rel("/admin/users", "Users"),
		linkwell.Rel("/admin/roles", "Roles"),
	)

	// Center links to spokes via rel="related"
	centerLinks := linkwell.LinksFor("/admin", "related")
	fmt.Printf("center has %d spokes\n", len(centerLinks))

	// Spokes link back to center via rel="up"
	upLinks := linkwell.LinksFor("/admin/users", "up")
	fmt.Printf("spoke -> %s (%s)\n", upLinks[0].Href, upLinks[0].Title)
}
Output:
center has 2 spokes
spoke -> /admin (Admin)
func Link(source, rel, target, title string)

Link registers a directional relationship from a source path to a target. The rel parameter should be an IANA link relation type (e.g., RelRelated, RelUp, "collection"). For rel="related", the inverse link is automatically created so the relationship is symmetric — both pages will see each other in their link sets. Registration is safe for concurrent use.

func LinkHeader

func LinkHeader(links []LinkRelation) string

LinkHeader formats a slice of LinkRelation values as an RFC 8288 Link header string. Each relation is rendered as `<href>; rel="type"; title="label"`, joined by commas. The title parameter is omitted when empty and properly escaped per RFC 7230 quoted-string rules. Returns an empty string if links is empty.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	links := []linkwell.LinkRelation{
		{Rel: "related", Href: "/warehouses", Title: "Warehouses"},
		{Rel: "up", Href: "/admin", Title: "Admin"},
	}
	fmt.Println(linkwell.LinkHeader(links))
}
Output:
</warehouses>; rel="related"; title="Warehouses", </admin>; rel="up"; title="Admin"
func LoadStoredLink(source string, r LinkRelation)

LoadStoredLink adds a single link relation from an external source (e.g., a database or configuration file) into the in-memory registry. Duplicate links (same source, href, and rel) are silently skipped.

func ParseFromParam

func ParseFromParam(raw string) uint64

ParseFromParam parses the ?from= query parameter string as a uint64 bitmask. Returns 0 if the input is empty or not a valid unsigned integer.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	mask := linkwell.ParseFromParam("3")
	fmt.Println(mask)
	fmt.Println(linkwell.ParseFromParam(""))
}
Output:
3
0

func RegisterFrom

func RegisterFrom(bit FromBit, crumb Breadcrumb)

RegisterFrom registers a breadcrumb at the given bit position. Call during route initialization. Bit 0 is pre-registered as Home. If the bit is already registered, the previous entry is replaced.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.RegisterFrom(linkwell.FromDashboard, linkwell.Breadcrumb{
		Label: "Dashboard", Href: "/dashboard",
	})

	crumbs := linkwell.ResolveFromMask(linkwell.FromHome | linkwell.FromDashboard)
	for _, c := range crumbs {
		fmt.Println(c.Label)
	}
}
Output:
Home
Dashboard
func RemoveLink(source, href, rel string) bool

RemoveLink removes the first link relation matching source, href, and rel from the in-memory registry. Returns true if a link was found and removed, false if no match was found.

func ResetForTesting

func ResetForTesting()

ResetForTesting clears all entries from the global link, hub, and breadcrumb-origin registries. The Home breadcrumb (bit 0) is re-registered automatically. Intended for use in test setup/teardown only — not safe to call in production while handlers may be reading the registry. In parallel tests, call ResetForTesting at the start of each subtest and register it with t.Cleanup to avoid cross-test pollution.

func Ring

func Ring(name string, members ...RelEntry)

Ring registers symmetric rel="related" links between all members, creating a fully-connected group where every member links to every other member. The name parameter is stored as the Group field on each LinkRelation for UI grouping. Duplicate links are skipped. Registration is safe for concurrent use.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.ResetForTesting()

	linkwell.Ring("Logistics",
		linkwell.Rel("/inventory", "Inventory"),
		linkwell.Rel("/warehouses", "Warehouses"),
		linkwell.Rel("/shipments", "Shipments"),
	)

	links := linkwell.LinksFor("/inventory")
	for _, l := range links {
		fmt.Printf("%s -> %s (group=%s)\n", l.Rel, l.Title, l.Group)
	}
}
Output:
related -> Warehouses (group=Logistics)
related -> Shipments (group=Logistics)

func TitleFromPath

func TitleFromPath(path string) string

TitleFromPath derives a human-readable title from the last segment of a URL path. Hyphens are replaced with spaces and each word is title-cased. Examples: "/demo/inventory" -> "Inventory", "/admin/error-traces" -> "Error Traces".

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	fmt.Println(linkwell.TitleFromPath("/demo/inventory"))
	fmt.Println(linkwell.TitleFromPath("/admin/error-traces"))
}
Output:
Inventory
Error Traces

Types

type Breadcrumb struct {
	// Label is the display text for this breadcrumb segment.
	Label string
	// Href is the navigation target. Empty for the terminal (current page) segment.
	Href string
}

Breadcrumb is one segment of a breadcrumb trail. When Href is empty, the segment represents the current page and should be rendered as plain text rather than a clickable link.

func BreadcrumbsFromLinks(path string) []Breadcrumb

BreadcrumbsFromLinks walks the rel="up" chain from path to build a breadcrumb trail. The trail starts with Home, includes each ancestor found via rel="up" links, and ends with the current page (empty Href). Returns nil if no rel="up" links are registered for the path.

When the exact path has no rel="up" links, BreadcrumbsFromLinks strips the last path segment and retries up the hierarchy until a registered path is found. Intermediate segments between the matched ancestor and the original path are included as breadcrumbs. This allows child pages like /admin/groups/new to inherit breadcrumbs from /admin/groups. Cycle-safe.

func BreadcrumbsFromPath(path string, labels map[int]string) []Breadcrumb

BreadcrumbsFromPath generates a breadcrumb trail from a URL path by splitting on "/" and title-casing each segment. The labels map overrides auto-generated labels by segment index (0-based, not counting the Home crumb) — use this to replace opaque IDs with human-readable names. The terminal segment always has an empty Href (rendered as text, not a link).

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	crumbs := linkwell.BreadcrumbsFromPath("/users/42/edit", map[int]string{
		1: "Jane Doe",
	})
	for _, c := range crumbs {
		if c.Href == "" {
			fmt.Printf("[%s]\n", c.Label)
		} else {
			fmt.Printf("%s (%s)\n", c.Label, c.Href)
		}
	}
}
Output:
Home (/)
Users (/users)
Jane Doe (/users/42)
[Edit]

func ResolveFromMask

func ResolveFromMask(mask uint64) []Breadcrumb

ResolveFromMask decodes a bitmask into an ordered breadcrumb trail by checking each registered bit position. Only registered bits are included; unregistered bits are silently ignored. Home (bit 0) is always included regardless of the mask value. Entries are ordered by bit position (lowest first).

func ResolveFromMaskWithPath added in v0.2.4

func ResolveFromMaskWithPath(mask uint64, path string, from string) []Breadcrumb

ResolveFromMaskWithPath combines bitmask-resolved breadcrumbs with path-derived crumbs (using the walk-up behavior of BreadcrumbsFromLinks), deduplicating by base path (query parameters stripped for comparison). The from parameter is forwarded as a ?from= query parameter on intermediate path crumb links. Returns nil if path produces no breadcrumbs.

type BulkActionCfg

type BulkActionCfg struct {
	DeleteURL        string // DELETE URL for bulk deletion.
	ActivateURL      string // PUT URL for bulk activation.
	DeactivateURL    string // PUT URL for bulk deactivation.
	TableTarget      string // CSS selector for the table to replace after the operation.
	CheckboxSelector string // CSS selector for row checkboxes (e.g., ".user-checkbox").
	ErrorTarget      string // CSS selector for inline error display.
}

BulkActionCfg configures toolbar controls for batch operations on checkbox-selected table rows. Each URL is optional — omit to hide that action. The CheckboxSelector is the CSS selector for row checkboxes included via hx-include.

type Control

type Control struct {
	// HxRequest carries the HTMX request attributes (method, URL, target, etc.).
	HxRequest HxRequestConfig
	// Kind determines how the template renders this control (button, link, etc.).
	Kind ControlKind
	// Label is the user-visible text for the control.
	Label string
	// Href is the URL for link-type controls (ControlKindLink, ControlKindHome).
	Href string
	// Variant sets the visual emphasis (primary, danger, ghost, etc.).
	Variant ControlVariant
	// Confirm is an optional hx-confirm message shown before the action executes.
	Confirm string
	// Icon is an optional icon name rendered alongside the label.
	Icon Icon
	// PushURL is set on navigation controls to update the browser URL via hx-push-url.
	PushURL string
	// Swap overrides the default hx-swap strategy for this control.
	Swap SwapMode
	// Disabled renders the control in a non-interactive state.
	Disabled bool
	// ErrorTarget overrides the parent hx-target-error so error responses render
	// inline near this control rather than in a global error container.
	ErrorTarget string
	// ModalID ties this control to a specific modal dialog.
	ModalID string
}

Control is a pure-data descriptor for a single hypermedia affordance — a button, link, or action that a user can take. Templates consume controls to render the appropriate HTML elements with the correct HTMX attributes, confirmation dialogs, icons, and visual styles. Controls are value types; use the With* methods to derive modified copies.

func BackButton

func BackButton(label string) Control

BackButton creates a client-side browser history.back() control. No server round-trip occurs — the template renders this as a HyperScript-powered button.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.BackButton("Go Back")
	fmt.Printf("kind=%s label=%s\n", ctrl.Kind, ctrl.Label)
}
Output:
kind=back label=Go Back

func BulkActions

func BulkActions(cfg BulkActionCfg) []Control

BulkActions returns toolbar controls for batch operations on checkbox-selected rows. Controls are conditionally included: omit DeleteURL, ActivateURL, or DeactivateURL to hide the corresponding action.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	controls := linkwell.BulkActions(linkwell.BulkActionCfg{
		DeleteURL:        "/users/bulk-delete",
		ActivateURL:      "/users/bulk-activate",
		TableTarget:      "#user-table",
		CheckboxSelector: ".user-checkbox",
	})
	for _, c := range controls {
		fmt.Printf("%s variant=%s\n", c.Label, c.Variant)
	}
}
Output:
Delete Selected variant=danger
Activate variant=secondary

func CatalogRowAction

func CatalogRowAction(detailURL, detailRowTarget string) Control

CatalogRowAction returns a ghost-variant Details button that fills an adjacent placeholder row via innerHTML swap. Use for catalog/listing views where clicking a row loads detail content into an expandable area below it.

func ConfirmAction

func ConfirmAction(label string, method HxMethod, url, target, confirmMsg string) Control

ConfirmAction creates a danger-variant HTMX button with an hx-confirm confirmation dialog. Use for destructive operations (delete, archive) where the user should explicitly confirm before the request is sent.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.ConfirmAction("Delete", linkwell.HxMethodDelete, "/users/42", "#list", "Delete?")
	fmt.Printf("kind=%s variant=%s confirm=%s\n", ctrl.Kind, ctrl.Variant, ctrl.Confirm)
}
Output:
kind=htmx variant=danger confirm=Delete?

func DismissButton

func DismissButton(label string) Control

DismissButton creates a HyperScript-powered close/dismiss control. Typically used to close error banners, notifications, or alert panels.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.DismissButton("Close")
	fmt.Printf("kind=%s label=%s\n", ctrl.Kind, ctrl.Label)
}
Output:
kind=dismiss label=Close

func EmptyStateAction

func EmptyStateAction(label, createURL, target string) Control

EmptyStateAction returns a single primary call-to-action control for empty list states (e.g., "Create First User" when a table has no rows).

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.EmptyStateAction("Create First User", "/users/new", "#content")
	fmt.Printf("%s kind=%s variant=%s\n", ctrl.Label, ctrl.Kind, ctrl.Variant)
}
Output:
Create First User kind=htmx variant=primary

func ErrorControlsForStatus

func ErrorControlsForStatus(statusCode int, opts ErrorControlOpts) []Control

ErrorControlsForStatus dispatches to the appropriate control builder based on HTTP status code. Returns a [Dismiss] control for unrecognized status codes. Use in generic error-handling middleware.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	controls := linkwell.ErrorControlsForStatus(404, linkwell.ErrorControlOpts{
		HomeURL: "/",
	})
	for _, c := range controls {
		fmt.Printf("%s kind=%s\n", c.Label, c.Kind)
	}
}
Output:
Go Back kind=back
Go Home kind=home

func ForbiddenControls

func ForbiddenControls() []Control

ForbiddenControls returns [Back, Dismiss] controls for a 403 response.

func FormActions

func FormActions(cancelHref string) []Control

FormActions returns [Save, Cancel] controls for form footers. The Save button uses hx-include="closest form" to submit the form data; the parent form element must carry the hx-post or hx-put attribute that drives submission. The Cancel button is a plain link to cancelHref.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	controls := linkwell.FormActions("/users")
	for _, c := range controls {
		fmt.Printf("%s kind=%s\n", c.Label, c.Kind)
	}
}
Output:
Save kind=htmx
Cancel kind=link

func GoHomeButton

func GoHomeButton(label, homeURL, target string) Control

GoHomeButton creates a navigation control that loads the home page via HTMX GET and pushes homeURL to browser history via hx-push-url.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.GoHomeButton("Go Home", "/", "body")
	fmt.Printf("kind=%s href=%s pushURL=%s\n", ctrl.Kind, ctrl.Href, ctrl.PushURL)
}
Output:
kind=home href=/ pushURL=/

func HTMXAction

func HTMXAction(label string, req HxRequestConfig) Control

HTMXAction creates an HTMX button with the supplied request config. Use when the preset factory functions do not fit — this gives full control over the HTMX attributes while still producing a typed Control.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.HTMXAction("Archive", linkwell.HxPost("/users/42/archive", "#content"))
	fmt.Printf("kind=%s method=%s url=%s\n", ctrl.Kind, ctrl.HxRequest.Method, ctrl.HxRequest.URL)
}
Output:
kind=htmx method=post url=/users/42/archive

func InternalErrorControls

func InternalErrorControls(opts ErrorControlOpts) []Control

InternalErrorControls returns [Retry, Dismiss] controls for a 500 response. The Retry button is omitted if opts.RetryURL is empty.

func NewRowFormActions

func NewRowFormActions(cfg RowFormActionCfg) []Control

NewRowFormActions returns Save + Cancel controls for a new-item inline form row. Save uses hx-post (instead of hx-put) with hx-include="closest tr". Controls are conditionally included: omit SaveURL to hide Save, omit CancelURL to hide Cancel.

func NotFoundControls

func NotFoundControls(homeURL string) []Control

NotFoundControls returns [Back] and optionally [GoHome] controls for a 404 response. Pass an empty homeURL to omit the GoHome button.

func PaginationControls

func PaginationControls(info PageInfo) []Control

PaginationControls generates the control slice for a pagination bar. Returns nil if TotalPages <= 1 (no pagination needed). The returned controls follow a [First][Prev][page window][Next][Last] layout. The current page is rendered as a disabled primary-variant control; boundary controls (First/Prev at page 1, Next/Last at last page) are disabled.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	info := linkwell.PageInfo{
		BaseURL:    "/users",
		Page:       2,
		PerPage:    25,
		TotalItems: 100,
		TotalPages: 4,
		Target:     "#user-table",
	}
	controls := linkwell.PaginationControls(info)
	for _, c := range controls {
		fmt.Printf("label=%-3s disabled=%v\n", c.Label, c.Disabled)
	}
}
Output:
label=«   disabled=false
label=‹   disabled=false
label=1   disabled=false
label=2   disabled=true
label=3   disabled=false
label=4   disabled=false
label=›   disabled=false
label=»   disabled=false
func RedirectLink(label, href string) Control

RedirectLink creates a plain anchor (<a>) control for same-tab navigation.

func ReportIssueButton

func ReportIssueButton(label, requestID string) Control

ReportIssueButton creates an HTMX button that fetches the Report Issue modal via GET to /report-issue (or /report-issue/{requestID} if provided). The server returns the modal fragment which auto-opens on load.

func ResourceActions

func ResourceActions(cfg ResourceActionCfg) []Control

ResourceActions returns Edit + Delete controls for resource detail pages. Controls are conditionally included: omit EditURL to hide Edit, omit DeleteURL to hide Delete. Returns an empty slice if both URLs are empty.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	controls := linkwell.ResourceActions(linkwell.ResourceActionCfg{
		EditURL:    "/users/42/edit",
		DeleteURL:  "/users/42",
		ConfirmMsg: "Delete this user?",
		Target:     "#content",
	})
	for _, c := range controls {
		fmt.Printf("%s kind=%s variant=%s\n", c.Label, c.Kind, c.Variant)
	}
}
Output:
Edit kind=htmx variant=secondary
Delete kind=htmx variant=danger

func RetryButton

func RetryButton(label string, method HxMethod, url, target string) Control

RetryButton creates a primary-variant HTMX button that re-issues a failed request. Typically used in error states to let the user retry the operation that produced the error.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.RetryButton("Retry", linkwell.HxMethodGet, "/api/data", "#content")
	fmt.Printf("kind=%s variant=%s\n", ctrl.Kind, ctrl.Variant)
}
Output:
kind=retry variant=primary

func RowActions

func RowActions(cfg RowActionCfg) []Control

RowActions returns Edit + Delete controls for a table row where both actions swap the same row target with outerHTML. Controls are conditionally included: omit EditURL to hide Edit, omit DeleteURL to hide Delete.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	controls := linkwell.RowActions(linkwell.RowActionCfg{
		EditURL:    "/users/42/edit",
		DeleteURL:  "/users/42",
		RowTarget:  "#row-42",
		ConfirmMsg: "Delete?",
	})
	for _, c := range controls {
		fmt.Printf("%s swap=%s\n", c.Label, c.Swap)
	}
}
Output:
Edit swap=outerHTML
Delete swap=outerHTML

func RowFormActions

func RowFormActions(cfg RowFormActionCfg) []Control

RowFormActions returns Save + Cancel controls for an inline edit row. Save uses hx-put with hx-include="closest tr" to submit the row's form fields. Controls are conditionally included: omit SaveURL to hide Save, omit CancelURL to hide Cancel.

func ServiceErrorControls

func ServiceErrorControls(opts ErrorControlOpts) []Control

ServiceErrorControls returns [Retry, Dismiss] controls for a 503 response. The Retry button is omitted if opts.RetryURL is empty.

func TableRowActions

func TableRowActions(cfg TableRowActionCfg) []Control

TableRowActions returns Edit + Delete controls where Edit swaps the row and Delete replaces the entire table container. Controls are conditionally included: omit EditURL to hide Edit, omit DeleteURL to hide Delete.

func UnauthorizedControls

func UnauthorizedControls(loginURL string) []Control

UnauthorizedControls returns [Log In, Dismiss] controls for a 401 response. The Log In button is omitted if loginURL is empty.

func (Control) WithConfirm

func (c Control) WithConfirm(msg string) Control

WithConfirm returns a copy of the control with the given hx-confirm message.

func (Control) WithDisabled

func (c Control) WithDisabled(d bool) Control

WithDisabled returns a copy of the control with the given disabled state.

func (Control) WithErrorTarget

func (c Control) WithErrorTarget(target string) Control

WithErrorTarget returns a copy of the control with the given hx-target-error CSS selector, overriding any parent error target so error responses render inline near this control.

func (Control) WithIcon

func (c Control) WithIcon(i Icon) Control

WithIcon returns a copy of the control with the given icon name.

func (Control) WithSwap

func (c Control) WithSwap(s SwapMode) Control

WithSwap returns a copy of the control with the given HTMX swap strategy.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.RetryButton("Retry", linkwell.HxMethodGet, "/api", "#c").
		WithSwap(linkwell.SwapOuterHTML).
		WithVariant(linkwell.VariantDanger).
		WithIcon(linkwell.IconCheck).
		WithConfirm("Sure?").
		WithDisabled(true).
		WithErrorTarget("#err")
	fmt.Printf("swap=%s variant=%s icon=%s confirm=%s disabled=%v errTarget=%s\n",
		ctrl.Swap, ctrl.Variant, ctrl.Icon, ctrl.Confirm, ctrl.Disabled, ctrl.ErrorTarget)
}
Output:
swap=outerHTML variant=danger icon=check confirm=Sure? disabled=true errTarget=#err

func (Control) WithVariant

func (c Control) WithVariant(v ControlVariant) Control

WithVariant returns a copy of the control with the given visual variant.

type ControlKind

type ControlKind string

ControlKind identifies the rendering strategy a template should use for a Control. Each kind implies a specific HTML element and behavior pattern.

const (
	// ControlKindRetry renders an HTMX button that re-issues a failed request.
	ControlKindRetry ControlKind = "retry"
	// ControlKindLink renders a plain HTML anchor (<a>) element.
	ControlKindLink ControlKind = "link"
	// ControlKindHTMX renders a button with HTMX attributes spread from HxRequest.
	ControlKindHTMX ControlKind = "htmx"
	// ControlKindDismiss renders a close button powered by HyperScript.
	ControlKindDismiss ControlKind = "dismiss"
	// ControlKindBack renders a button that calls browser history.back().
	ControlKindBack ControlKind = "back"
	// ControlKindHome renders a navigation button that redirects to the home page.
	ControlKindHome ControlKind = "home"
	// ControlKindReport renders an HTMX button that fetches a report modal.
	ControlKindReport ControlKind = "report"
)

type ControlVariant

type ControlVariant string

ControlVariant determines the visual emphasis of a Control. Templates map variants to CSS classes (e.g., DaisyUI btn-primary, btn-ghost). The zero value ("") renders as secondary, which is a safe default.

const (
	// VariantPrimary is the filled call-to-action style.
	VariantPrimary ControlVariant = "primary"
	// VariantSecondary is the supporting action style (default/zero value).
	VariantSecondary ControlVariant = "secondary"
	// VariantDanger is used for destructive actions.
	VariantDanger ControlVariant = "danger"
	// VariantGhost is a low-emphasis style.
	VariantGhost ControlVariant = "ghost"
	// VariantLink renders with a text-only link appearance.
	VariantLink ControlVariant = "link"
)

type ErrorContext

type ErrorContext struct {
	// Err is the underlying Go error (not shown to users, used for logging).
	Err error
	// Message is the user-facing error message.
	Message string
	// Route is the request path that produced the error (for logging/reporting).
	Route string
	// RequestID is the correlation ID for the request (passed to ReportIssueButton).
	RequestID string
	// OOBTarget is the CSS selector for an HTMX OOB swap target. When set, the
	// error renders as an out-of-band fragment rather than the main response.
	OOBTarget string
	// OOBSwap is the hx-swap-oob strategy (e.g., "innerHTML", "outerHTML").
	OOBSwap string
	// Controls is the set of hypermedia affordances rendered with the error
	// (e.g., Retry, Back, Dismiss buttons).
	Controls []Control
	// StatusCode is the HTTP status code for the error response.
	StatusCode int
	// Closable indicates whether the error panel should show a close/dismiss button.
	Closable bool
	// Theme is the DaisyUI theme for full-page error renders. Empty defaults to "dark".
	Theme string
}

ErrorContext carries all information needed to render a hypermedia error response with action controls. It flows through the error handling pipeline from handler to middleware to template. Use WithControls and WithOOB to build up the context fluently.

func (ErrorContext) WithControls

func (ec ErrorContext) WithControls(controls ...Control) ErrorContext

WithControls returns a copy of the ErrorContext with the given controls appended to the existing set. The original is not modified.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ec := linkwell.ErrorContext{
		StatusCode: 500,
		Message:    "Database error",
	}
	ec = ec.WithControls(linkwell.DismissButton("Close"))
	ec = ec.WithOOB("#error-status", "innerHTML")
	fmt.Printf("controls=%d oobTarget=%s\n", len(ec.Controls), ec.OOBTarget)
}
Output:
controls=1 oobTarget=#error-status

func (ErrorContext) WithOOB

func (ec ErrorContext) WithOOB(target, swap string) ErrorContext

WithOOB returns a copy of the ErrorContext configured for HTMX out-of-band swap rendering. Set target to the CSS selector and swap to the hx-swap-oob strategy (e.g., "innerHTML").

type ErrorControlOpts

type ErrorControlOpts struct {
	// RetryMethod is the HTTP method for the retry button. Defaults to GET if empty.
	RetryMethod HxMethod
	// RetryURL is the request URL to retry (typically the current request URL).
	RetryURL string
	// RetryTarget is the CSS selector for hx-target on the retry button.
	RetryTarget string
	// HomeURL is the home page URL for the GoHome button (typically "/").
	HomeURL string
	// LoginURL is the login page URL for the Log In button (typically "/login").
	LoginURL string
}

ErrorControlOpts carries context for building status-code-specific control sets. Zero values are safe: missing fields cause the corresponding control to be omitted from the result. For example, omitting RetryURL skips the Retry button; omitting HomeURL skips the GoHome button.

type FilterBar

type FilterBar struct {
	// ID is the HTML form id attribute. Defaults to DefaultFilterFormID ("filter-form")
	// when created via NewFilterBar.
	ID string
	// Action is the hx-get endpoint the form submits to (e.g., "/users").
	Action string
	// Target is the hx-target CSS selector (e.g., "#table-container").
	Target string
	// Fields is the ordered list of filter inputs rendered in the form.
	Fields []FilterField
}

FilterBar is the descriptor for a complete filter form. The form element carries an HTML id so that pagination and sort links can use hx-include="#filter-form" to forward filter state with their requests.

func NewFilterBar

func NewFilterBar(action, target string, fields ...FilterField) FilterBar

NewFilterBar creates a FilterBar with the default form ID and the given action endpoint, HTMX target, and fields.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	bar := linkwell.NewFilterBar("/users", "#user-table",
		linkwell.SearchField("q", "Search users...", "alice"),
		linkwell.SelectField("status", "Status", "active", linkwell.SelectOptions("active",
			"", "All",
			"active", "Active",
			"inactive", "Inactive",
		)),
	)
	fmt.Printf("id=%s action=%s fields=%d\n", bar.ID, bar.Action, len(bar.Fields))
	fmt.Printf("search value=%s\n", bar.Fields[0].Value)
}
Output:
id=filter-form action=/users fields=2
search value=alice

type FilterField

type FilterField struct {
	// HTMXAttrs holds optional HTMX attributes for this field (e.g., for
	// cascading filter updates triggered by hx-get on change).
	HTMXAttrs map[string]string
	// Kind determines the HTML input type rendered by the template.
	Kind FilterKind
	// Name is the form field name (maps to the query parameter key).
	Name string
	// Label is the visible label text. Used by select, range, checkbox, and date.
	Label string
	// Placeholder is hint text for search inputs.
	Placeholder string
	// Value is the current serialized value from the query string.
	Value string
	// Min is the minimum value for range inputs.
	Min string
	// Max is the maximum value for range inputs.
	Max string
	// Step is the increment step for range inputs.
	Step string
	// Options is the list of choices for select inputs.
	Options []FilterOption
	// Disabled renders the field in a non-interactive state.
	Disabled bool
}

FilterField is a pure-data descriptor for a single filter input. Value always holds the current serialized value as a string regardless of Kind:

  • Search/Select/Date: the query parameter value
  • Checkbox: "true" when checked, "" when unchecked (param absent)
  • Range: the current numeric string; Min/Max/Step bound the slider

func CheckboxField

func CheckboxField(name, label, value string) FilterField

CheckboxField creates a boolean toggle. Pass the raw query parameter value: "true" when checked, "" when unchecked or absent.

func DateField

func DateField(name, label, value string) FilterField

DateField creates a date picker input.

func RangeField

func RangeField(name, label, value, min, max, step string) FilterField

RangeField creates a range slider input bounded by min, max, and step.

func SearchField

func SearchField(name, placeholder, value string) FilterField

SearchField creates a text search input with the given name, placeholder, and current value.

func SelectField

func SelectField(name, label, value string, options []FilterOption) FilterField

SelectField creates a select dropdown with the given options. Use SelectOptions to build the options slice from flat value/label pairs.

type FilterGroup

type FilterGroup struct {
	// Bar is the underlying filter bar configuration.
	Bar FilterBar
}

FilterGroup wraps a FilterBar with methods for dynamic option updates and OOB (out-of-band) swap rendering. Use when filter options change based on other selections (e.g., cascading dropdowns) and the server needs to push updated select elements back to the page via HTMX OOB swaps.

func NewFilterGroup

func NewFilterGroup(action, target string, fields ...FilterField) FilterGroup

NewFilterGroup creates a FilterGroup with a default-ID FilterBar.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	group := linkwell.NewFilterGroup("/products", "#table",
		linkwell.SelectField("cat", "Category", "", nil),
	)
	group.UpdateOptions("cat", linkwell.SelectOptions("",
		"electronics", "Electronics",
		"clothing", "Clothing",
	))
	selects := group.SelectFields()
	fmt.Printf("select fields: %d\n", len(selects))
	fmt.Printf("options: %d\n", len(selects[0].Options))
}
Output:
select fields: 1
options: 2

func (*FilterGroup) SelectFields

func (g *FilterGroup) SelectFields() []FilterField

SelectFields returns only the select-type fields from the bar. Use when rendering OOB swap fragments that update dropdown options without replacing the entire filter form.

func (*FilterGroup) UpdateOptions

func (g *FilterGroup) UpdateOptions(name string, options []FilterOption)

UpdateOptions replaces the options for the named select field. Non-select fields or unrecognized names are silently ignored.

type FilterKind

type FilterKind string

FilterKind identifies the HTML input type for a FilterField. Templates use this to select the appropriate form control (text input, select, range slider, checkbox, or date picker).

const (
	FilterKindSearch   FilterKind = "search"
	FilterKindSelect   FilterKind = "select"
	FilterKindRange    FilterKind = "range"
	FilterKindCheckbox FilterKind = "checkbox"
	FilterKindDate     FilterKind = "date"
)

Filter kind constants for FilterField.Kind.

type FilterOption

type FilterOption struct {
	Value    string
	Label    string
	Selected bool
}

FilterOption is a single option in a select dropdown. Selected is typically set by SelectOptions based on the current query parameter value.

func SelectOptions

func SelectOptions(current string, pairs ...string) []FilterOption

SelectOptions builds a slice of FilterOption from flat value/label pairs. The current parameter is matched against each value to set Selected=true on the matching option. Unpaired trailing values are safely ignored.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	opts := linkwell.SelectOptions("published",
		"draft", "Draft",
		"published", "Published",
		"archived", "Archived",
	)
	for _, o := range opts {
		fmt.Printf("%s selected=%v\n", o.Label, o.Selected)
	}
}
Output:
Draft selected=false
Published selected=true
Archived selected=false

type FromBit

type FromBit = uint64

FromBit is a bitmask position for a registered breadcrumb origin. Lower bits render earlier in the trail. Bit 0 is reserved for Home and is always included. Use RegisterFrom to associate a Breadcrumb with a bit position, then pass a bitmask via the ?from= query parameter to reconstruct the trail.

const (
	FromHome      FromBit = 1 << iota // bit 0 — always shown
	FromDashboard                     // bit 1
	FromBit2                          // bit 2 — available for registration
	FromBit3                          // bit 3
	FromBit4                          // bit 4
	FromBit5                          // bit 5
	FromBit6                          // bit 6
	FromBit7                          // bit 7
)

Well-known breadcrumb bit positions. Bit 0 (Home) is always included. Register additional bits via RegisterFrom. Bits 2-7 are available for application-specific origins.

type HTTPError

type HTTPError struct {
	// EC is the error context carrying the status code, message, and controls.
	EC ErrorContext
}

HTTPError is a Go error that wraps an ErrorContext. Return it from handlers and let error-handling middleware detect it via errors.As to render the full hypermedia error response with action controls.

func NewHTTPError

func NewHTTPError(ec ErrorContext) *HTTPError

NewHTTPError wraps an ErrorContext in an HTTPError suitable for returning from a handler. The error handler middleware intercepts it and renders the appropriate hypermedia error response.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ec := linkwell.ErrorContext{
		StatusCode: 404,
		Message:    "User not found",
	}
	err := linkwell.NewHTTPError(ec)
	fmt.Println(err.Error())
}
Output:
HTTP 404: User not found

func (*HTTPError) Error

func (e *HTTPError) Error() string

type HubEntry

type HubEntry struct {
	Path   string
	Title  string
	Spokes []LinkRelation
}

HubEntry represents a hub center with its spoke links, returned by Hubs for site map or navigation tree rendering. Spokes are sorted alphabetically by title.

func Hubs

func Hubs() []HubEntry

Hubs returns all registered hub centers with their spoke links, sorted by center path. Spokes within each hub are sorted alphabetically by title. Use for rendering site maps or navigation trees.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.ResetForTesting()

	linkwell.Hub("/admin", "Admin",
		linkwell.Rel("/admin/users", "Users"),
		linkwell.Rel("/admin/roles", "Roles"),
	)

	for _, hub := range linkwell.Hubs() {
		fmt.Printf("%s (%s)\n", hub.Title, hub.Path)
		for _, spoke := range hub.Spokes {
			fmt.Printf("  %s %s\n", spoke.Title, spoke.Href)
		}
	}
}
Output:
Admin (/admin)
  Roles /admin/roles
  Users /admin/users

type HxMethod

type HxMethod string

HxMethod is the HTTP verb used in an HTMX request attribute. The value maps directly to the hx-get, hx-post, hx-put, hx-patch, or hx-delete attribute name.

const (
	HxMethodGet    HxMethod = "get"
	HxMethodPost   HxMethod = "post"
	HxMethodPut    HxMethod = "put"
	HxMethodPatch  HxMethod = "patch"
	HxMethodDelete HxMethod = "delete"
)

HTTP verb constants for HTMX request attributes.

type HxRequestConfig

type HxRequestConfig struct {
	// Method is the HTTP verb (maps to hx-get, hx-post, etc.).
	Method HxMethod
	// URL is the request endpoint.
	URL string
	// Target is the CSS selector for hx-target.
	Target string
	// Include is the CSS selector for hx-include (e.g., "closest form").
	Include string
	// Vals is a JSON-encoded string for hx-vals (e.g., `{"status":"active"}`).
	Vals string
}

HxRequestConfig describes the HTMX request attributes for a control. It maps to the hx-{method}, hx-target, hx-include, and hx-vals attributes. Use the HxGet, HxPost, HxPut, HxPatch, and HxDelete helpers for common cases, or build directly for full control.

func HxDelete

func HxDelete(url, target string) HxRequestConfig

HxDelete returns a DELETE request config.

func HxGet

func HxGet(url, target string) HxRequestConfig

HxGet returns a GET request config.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	req := linkwell.HxGet("/users", "#user-list")
	fmt.Printf("method=%s url=%s target=%s\n", req.Method, req.URL, req.Target)
}
Output:
method=get url=/users target=#user-list

func HxPatch

func HxPatch(url, target string) HxRequestConfig

HxPatch returns a PATCH request config.

func HxPost

func HxPost(url, target string) HxRequestConfig

HxPost returns a POST request config.

func HxPut

func HxPut(url, target string) HxRequestConfig

HxPut returns a PUT request config.

func (HxRequestConfig) Attrs

func (r HxRequestConfig) Attrs() map[string]string

Attrs converts the config to a map[string]string suitable for attribute spreading in templates. Keys are the HTMX attribute suffixes (e.g., "get", "target", "include", "vals"). Used for interop with NavItem.HTMXAttrs and other consumers that accept generic attribute maps.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	req := linkwell.HxPost("/users", "#list").WithInclude("closest form")
	attrs := req.Attrs()
	fmt.Println(attrs["post"])
	fmt.Println(attrs["target"])
	fmt.Println(attrs["include"])
}
Output:
/users
#list
closest form

func (HxRequestConfig) WithInclude

func (r HxRequestConfig) WithInclude(selector string) HxRequestConfig

WithInclude returns a copy of the request config with the given hx-include CSS selector.

type Icon

type Icon string

Icon is a typed icon name for Control.Icon. The built-in constants cover common actions, but any string is valid — templates interpret the value to select the appropriate icon component (e.g., Heroicons, Lucide).

const (
	IconPencilSquare Icon = "pencil-square"
	IconXMark        Icon = "x-mark"
	IconCheck        Icon = "check"
	IconHome         Icon = "home"
)

Built-in icon name constants.

type LinkIssue added in v0.2.17

type LinkIssue struct {
	// Path is the registry path where the issue was detected.
	Path string
	// Message is a human-readable description of the problem.
	Message string
	// Kind classifies the issue for programmatic filtering.
	// Values: "orphan", "broken_up", "dead_spoke", "unregistered_route", "missing_route".
	Kind string
}

LinkIssue describes a structural problem detected in the link registry.

func ValidateAgainstRoutes added in v0.2.17

func ValidateAgainstRoutes(routes []string) []LinkIssue

ValidateAgainstRoutes checks that all registered link paths exist in the provided route set, and that all routes have a link graph presence. Returns issues for any mismatches.

Detected issues:

  • unregistered_route: a path in the link registry has no matching route.
  • missing_route: a route has no presence in the link registry (neither as a source nor as a target).

func ValidateGraph added in v0.2.17

func ValidateGraph() []LinkIssue

ValidateGraph checks the link registry for structural issues and returns a slice of problems found. An empty slice means no issues were detected.

Detected issues:

  • orphan: a registered path has no inbound links from any other path.
  • broken_up: a rel="up" link targets a path that has no registered links.
  • dead_spoke: a hub spoke path has no links registered (empty link set).

type LinkRelation

type LinkRelation struct {
	// Rel is the IANA link relation type (e.g., "related", "collection", "up").
	// See https://www.iana.org/assignments/link-relations/ for registered values.
	Rel string
	// Href is the target URL of the linked resource.
	Href string
	// Title is a human-readable label for the link, suitable for rendering in
	// navigation bars, context panels, or breadcrumb trails.
	Title string
	// Group is an optional grouping label set by Ring or Hub registration.
	// Templates can use this to visually cluster related links.
	Group string
}

LinkRelation represents a typed relationship between two resources, following the IANA link relation model defined in RFC 8288. Each relation carries a relation type (Rel), target URL (Href), human-readable label (Title), and an optional group name for UI rendering (e.g., the ring or hub name).

func LinksFor

func LinksFor(path string, rels ...string) []LinkRelation

LinksFor returns all registered link relations for the given path. If one or more rel types are provided, only relations matching those types are returned. Returns a copy of the internal slice, safe to modify.

When no links are registered for the exact path, LinksFor walks up the path hierarchy by stripping the last segment and retrying until a registered path is found or the root is reached. This allows child paths like /admin/apps/1 to inherit links from /admin/apps.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.ResetForTesting()

	linkwell.Link("/page", "related", "/other", "Other")
	linkwell.Link("/page", "up", "/parent", "Parent")

	// All links for a path
	all := linkwell.LinksFor("/page")
	fmt.Printf("all: %d\n", len(all))

	// Filter by rel type
	upOnly := linkwell.LinksFor("/page", "up")
	fmt.Printf("up: %s\n", upOnly[0].Href)
}
Output:
all: 2
up: /parent

func RelatedLinksFor

func RelatedLinksFor(path string) []LinkRelation

RelatedLinksFor returns only rel="related" links for a path, excluding the path itself and deduplicating by href. Useful for rendering context bars or "See also" panels where self-links would be redundant.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	linkwell.ResetForTesting()

	linkwell.Ring("group",
		linkwell.Rel("/a", "A"),
		linkwell.Rel("/b", "B"),
		linkwell.Rel("/c", "C"),
	)

	peers := linkwell.RelatedLinksFor("/a")
	for _, l := range peers {
		fmt.Println(l.Title)
	}
}
Output:
B
C

type ModalButton

type ModalButton struct {
	// Label is the button text.
	Label string
	// Role determines the button's behavior (submit, cancel, auxiliary).
	Role ModalButtonRole
	// Variant sets the visual style of the button.
	Variant ControlVariant
}

ModalButton describes a single button inside a modal dialog footer.

type ModalButtonRole

type ModalButtonRole string

ModalButtonRole identifies the semantic role of a button within a modal dialog. Templates use this to determine behavior: primary buttons submit, cancel buttons dismiss, and secondary buttons perform auxiliary actions.

const (
	// ModalRolePrimary is the main action button (Submit, Save, Yes, OK, Delete).
	ModalRolePrimary ModalButtonRole = "primary"
	// ModalRoleCancel dismisses the modal without action.
	ModalRoleCancel ModalButtonRole = "cancel"
	// ModalRoleSecondary is an additional action (Reset, etc.).
	ModalRoleSecondary ModalButtonRole = "secondary"
)

type ModalButtonSet

type ModalButtonSet []ModalButton

ModalButtonSet is an ordered list of buttons for a modal footer. Buttons render left-to-right in the order they appear in the slice. Use the preset variables (ModalOK, ModalYesNo, etc.) for common patterns.

type ModalConfig

type ModalConfig struct {
	// ID is the unique HTML id for the modal element.
	ID string
	// Title is displayed in the modal header.
	Title string
	// Buttons defines the footer button set.
	Buttons ModalButtonSet
	// HxPost is the URL for the primary button's hx-post attribute. When empty,
	// the primary button uses client-side behavior only (e.g., close on confirm).
	HxPost string
	// HxTarget is the hx-target CSS selector for the primary button's HTMX
	// response.
	HxTarget string
	// HxSwap is the hx-swap strategy for the primary button's HTMX response.
	HxSwap SwapMode
}

ModalConfig describes everything needed to render a modal dialog. The ID must be unique on the page. When HxPost is set, the primary button submits the modal's form content via HTMX POST.

func ReportIssueModal

func ReportIssueModal(requestID string) ModalConfig

ReportIssueModal returns a ModalConfig preconfigured for the Report Issue flow. The modal posts to /report-issue (or /report-issue/{requestID}) with SwapNone so the response triggers a toast/alert without replacing DOM content.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	modal := linkwell.ReportIssueModal("req-abc")
	fmt.Printf("id=%s title=%s post=%s swap=%s\n", modal.ID, modal.Title, modal.HxPost, modal.HxSwap)
}
Output:
id=report-issue-modal title=Report Issue post=/report-issue/req-abc swap=none
type NavConfig struct {
	// Items is the ordered list of navigation entries.
	Items []NavItem
	// Promoted is an optional floating action button item shown on mobile.
	Promoted *NavItem
	// MaxVisible limits how many items are shown before overflowing into a
	// "more" menu. Zero means show all items.
	MaxVisible int
	// AppName is plain text displayed in the brand area of the navigation bar.
	AppName string
	// Brand is an optional component that replaces the default AppName text in
	// the brand slot. Any templ.Component satisfies Renderable. Set to nil to
	// use the plain text AppName.
	Brand Renderable
	// Topbar is an optional component that replaces the default mobile topbar
	// content. Any templ.Component satisfies Renderable. Set to nil to use the
	// default layout.
	Topbar Renderable
}

NavConfig holds the app-controlled parts of the navigation layout. Zero values are safe defaults: no promoted item, all items visible, no custom brand or topbar slots.

type NavItem struct {
	// HTMXAttrs holds optional HTMX attributes (e.g., from HxRequestConfig.Attrs())
	// for items that trigger HTMX requests instead of full navigation.
	HTMXAttrs map[string]string
	// Label is the user-visible text for this navigation entry.
	Label string
	// Href is the navigation target URL.
	Href string
	// Icon is the icon name rendered alongside the label.
	Icon string
	// Children are nested sub-items for dropdown menus.
	Children []NavItem
	// Active indicates this item matches the current page. Set by SetActiveNavItem
	// or SetActiveNavItemPrefix, or manually by the handler.
	Active bool
}

NavItem is a server-computed navigation entry. Active state is determined by the handler (via SetActiveNavItem or SetActiveNavItemPrefix), not by client-side JavaScript. Items may have children for dropdown/flyout menus.

func NavItemFromControl(ctrl Control) NavItem

NavItemFromControl converts a Control into a NavItem, mapping Label, Href, Icon, and HxRequest attributes. Useful when a control needs to appear in the navigation bar.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	ctrl := linkwell.Control{
		Label:     "Dashboard",
		Href:      "/dashboard",
		Icon:      linkwell.IconHome,
		HxRequest: linkwell.HxGet("/dashboard", "body"),
	}
	nav := linkwell.NavItemFromControl(ctrl)
	fmt.Printf("%s %s %s\n", nav.Label, nav.Href, nav.Icon)
}
Output:
Dashboard /dashboard home

func SetActiveNavItem

func SetActiveNavItem(items []NavItem, currentPath string) []NavItem

SetActiveNavItem sets the Active flag on nav items using exact path matching. A parent item is also marked active if any of its children match. Returns a new slice; the input is not modified.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	items := []linkwell.NavItem{
		{Label: "Dashboard", Href: "/dashboard"},
		{Label: "Users", Href: "/users"},
	}
	result := linkwell.SetActiveNavItem(items, "/users")
	for _, item := range result {
		fmt.Printf("%s active=%v\n", item.Label, item.Active)
	}
}
Output:
Dashboard active=false
Users active=true

func SetActiveNavItemPrefix

func SetActiveNavItemPrefix(items []NavItem, currentPath string) []NavItem

SetActiveNavItemPrefix sets the Active flag using longest-prefix matching. An item is active if the current path equals its Href or starts with Href followed by "/". Use for section-level navigation where /users should be active when the path is /users/42/edit. A parent is marked active if any child matches. Returns a new slice; the input is not modified.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	items := []linkwell.NavItem{
		{Label: "Users", Href: "/users"},
		{Label: "Settings", Href: "/settings"},
	}
	// /users is active when the path is /users/42/edit
	result := linkwell.SetActiveNavItemPrefix(items, "/users/42/edit")
	for _, item := range result {
		fmt.Printf("%s active=%v\n", item.Label, item.Active)
	}
}
Output:
Users active=true
Settings active=false

type PageInfo

type PageInfo struct {
	// BaseURL is the current URL (with existing query params preserved).
	// URLForPage appends/replaces the page parameter.
	BaseURL string
	// PageParam overrides the query parameter name for the page number.
	// Defaults to "page" when empty.
	PageParam string
	// Target is the CSS selector for hx-target on pagination links.
	Target string
	// Include is the CSS selector for hx-include (e.g., "#filter-form").
	Include string
	// Page is the current 1-based page number.
	Page int
	// PerPage is the number of items per page.
	PerPage int
	// TotalItems is the total number of items across all pages.
	TotalItems int
	// TotalPages is the total number of pages. Use ComputeTotalPages to calculate.
	TotalPages int
}

PageInfo carries server-computed pagination state used by PaginationControls to generate the page navigation bar. All fields are set by the handler; the template just passes PageInfo through.

func (PageInfo) URLForPage

func (pi PageInfo) URLForPage(page int) string

URLForPage builds the URL for a specific page number by appending or replacing the page query parameter in BaseURL.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	info := linkwell.PageInfo{BaseURL: "/users?q=foo"}
	fmt.Println(info.URLForPage(3))
}
Output:
/users?page=3&q=foo

type RelEntry

type RelEntry struct {
	Path  string
	Title string
}

RelEntry is a path and title pair used as input to Ring and Hub registration. Create instances with the Rel helper function.

func Rel

func Rel(path, title string) RelEntry

Rel creates a RelEntry for use with Ring and Hub. This is the preferred constructor for building the member lists passed to those functions.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	entry := linkwell.Rel("/inventory", "Inventory")
	fmt.Println(entry.Path, entry.Title)
}
Output:
/inventory Inventory

type Renderable added in v0.2.0

type Renderable interface {
	Render(ctx context.Context, w io.Writer) error
}

Renderable is a minimal rendering interface satisfied by templ.Component and any type that can render itself to an io.Writer. Using this interface instead of templ.Component directly allows consumers that don't use templ to avoid pulling in the templ module.

type ResourceActionCfg

type ResourceActionCfg struct {
	EditURL     string // GET URL to fetch the edit form.
	DeleteURL   string // DELETE URL to remove the resource.
	ConfirmMsg  string // hx-confirm message for the delete action.
	Target      string // CSS selector for the content area.
	ErrorTarget string // CSS selector for inline error display.
}

ResourceActionCfg configures Edit + Delete controls for resource detail pages (as opposed to table rows). Both actions target the same content area.

type RowActionCfg

type RowActionCfg struct {
	EditURL     string // GET URL to fetch the edit form for this row.
	DeleteURL   string // DELETE URL to remove this row.
	RowTarget   string // CSS selector for the row element (e.g., "#row-42").
	ConfirmMsg  string // hx-confirm message for the delete action.
	ErrorTarget string // CSS selector for inline error display.
}

RowActionCfg configures Edit + Delete controls for a table row. Both the Edit and Delete actions target the same row element (RowTarget) with outerHTML swap.

type RowFormActionCfg

type RowFormActionCfg struct {
	SaveURL      string // PUT or POST URL for saving the row.
	CancelURL    string // GET URL to restore the display row.
	SaveTarget   string // CSS selector for the save response target.
	CancelTarget string // CSS selector for the cancel response target.
	ErrorTarget  string // CSS selector for inline error display.
}

RowFormActionCfg configures Save + Cancel controls for inline table row editing. RowFormActions uses PUT for existing rows; NewRowFormActions uses POST for new rows.

type SitemapEntry added in v0.2.17

type SitemapEntry struct {
	// Path is the URL path of the page.
	Path string
	// Title is a human-readable label for the page.
	Title string
	// Parent is the rel="up" parent path, empty for root entries.
	Parent string
	// Children holds spoke paths when this entry is a hub center.
	Children []string
	// Group is the ring group name, if any.
	Group string
}

SitemapEntry represents a single page in the site hierarchy, derived from the link registry. Each entry captures the page's path, title, parent (via rel="up"), children (hub spokes), and optional ring group membership.

func Sitemap added in v0.2.17

func Sitemap() []SitemapEntry

Sitemap derives a structured sitemap from the link registry. It collects all registered paths, resolves parent relationships from rel="up" links, populates children from hub registrations, and captures ring group membership. Entries are sorted alphabetically by path.

func SitemapRoots added in v0.2.17

func SitemapRoots() []SitemapEntry

SitemapRoots returns only sitemap entries that have no parent (empty Parent field). These are the top-level pages in the site hierarchy.

type SortDir

type SortDir string

SortDir indicates the current sort direction for a table column. The zero value (SortNone) means the column is not currently sorted.

const (
	SortNone SortDir = ""
	SortAsc  SortDir = "asc"
	SortDesc SortDir = "desc"
)

Sort direction constants for TableCol.SortDir.

type Step added in v0.2.17

type Step struct {
	// Label is the user-visible name for this step.
	Label string
	// Href is the URL for this step.
	Href string
	// Status is the current state of this step (pending, active, complete, skipped).
	Status StepStatus
	// Icon is an optional icon name rendered alongside the step label.
	Icon Icon
}

Step describes a single step in a multi-step wizard flow.

type StepStatus added in v0.2.17

type StepStatus string

StepStatus represents the state of a step in a multi-step wizard flow.

const (
	// StepPending indicates the step has not been reached yet.
	StepPending StepStatus = "pending"
	// StepActive indicates the step is the current step.
	StepActive StepStatus = "active"
	// StepComplete indicates the step has been completed.
	StepComplete StepStatus = "complete"
	// StepSkipped indicates the step was skipped.
	StepSkipped StepStatus = "skipped"
)

type StepperConfig added in v0.2.17

type StepperConfig struct {
	// Steps is the ordered list of steps in the wizard.
	Steps []Step
	// Current is the 0-based index of the active step.
	Current int
	// Prev is a navigation control pointing to the previous step. Nil if the
	// current step is the first step.
	Prev *Control
	// Next is a navigation control pointing to the next step. Nil if the
	// current step is the last step.
	Next *Control
	// Submit is a submit control shown only on the final step. Nil on all
	// other steps.
	Submit *Control
}

StepperConfig describes a multi-step wizard flow where the server knows the full step sequence, current position, and completion state. Templates consume this to render step indicators, progress bars, and navigation controls.

func NewStepper added in v0.2.17

func NewStepper(currentIndex int, steps ...Step) StepperConfig

NewStepper creates a StepperConfig from the given current index and steps. Steps before currentIndex are marked Complete, the step at currentIndex is marked Active, and steps after are marked Pending. Pre-set statuses (e.g., StepSkipped) are preserved — only StepPending statuses are overwritten.

Navigation controls are auto-generated: Prev points to the previous step's Href (nil on the first step), Next points to the next step's Href (nil on the last step), and Submit is generated only on the final step.

type SwapMode

type SwapMode string

SwapMode is a typed HTMX swap strategy for Control.Swap. These mirror the hx-swap attribute values. Defined as a separate type (rather than importing an htmx package) to keep linkwell dependency-free.

const (
	SwapInnerHTML   SwapMode = "innerHTML"
	SwapOuterHTML   SwapMode = "outerHTML"
	SwapNone        SwapMode = "none"
	SwapBeforeBegin SwapMode = "beforebegin"
	SwapAfterBegin  SwapMode = "afterbegin"
	SwapBeforeEnd   SwapMode = "beforeend"
	SwapAfterEnd    SwapMode = "afterend"
	SwapDelete      SwapMode = "delete"
)

HTMX swap strategy constants.

type TabConfig added in v0.2.17

type TabConfig struct {
	// ID is a unique identifier for this tab group (e.g., "user-tabs").
	ID string
	// Items is the ordered list of tab entries.
	Items []TabItem
	// Target is the shared default hx-target CSS selector for all tabs.
	// Individual TabItem.Target values override this.
	Target string
}

TabConfig holds the configuration for an in-page tabbed navigation component. The server decides which tabs exist and which is active — templates consume this to render the tab bar and content panel.

func NewTabConfig added in v0.2.17

func NewTabConfig(id, target string, items ...TabItem) TabConfig

NewTabConfig creates a TabConfig with the given ID, shared target selector, and tab items. Items with an empty Target inherit the shared target at render time.

type TabItem added in v0.2.17

type TabItem struct {
	// Label is the user-visible text for this tab.
	Label string
	// Href is the content endpoint loaded when the tab is selected.
	Href string
	// Target is the hx-target CSS selector for this tab's content panel.
	// Overrides TabConfig.Target when set.
	Target string
	// Icon is the icon name rendered alongside the label.
	Icon Icon
	// Active indicates this tab matches the current page. Set by SetActiveTab
	// or manually by the handler.
	Active bool
	// Disabled renders the tab in a non-interactive state.
	Disabled bool
	// Badge is an optional count or status indicator displayed on the tab.
	Badge string
	// Swap is the HTMX swap strategy for this tab's content. Defaults to
	// innerHTML when empty.
	Swap SwapMode
}

TabItem is a server-computed tab entry for in-page tabbed navigation. Active state is determined by the handler (via SetActiveTab), not by client-side JavaScript. Each tab typically maps to an HTMX lazy-loaded content panel.

func SetActiveTab added in v0.2.17

func SetActiveTab(tabs []TabItem, currentPath string) []TabItem

SetActiveTab sets the Active flag on tab items using exact path matching. Returns a new slice; the input is not modified.

type TableCol

type TableCol struct {
	// Key identifies the column (maps to the "sort" query parameter value).
	Key string
	// Label is the visible column header text.
	Label string
	// SortDir is the current sort direction for this column.
	SortDir SortDir
	// SortURL is the HTMX request URL that toggles the sort direction. Built by
	// SortableCol with the next direction pre-encoded.
	SortURL string
	// Target is the CSS selector for hx-target on the sort link.
	Target string
	// Include is the CSS selector for hx-include on the sort link (e.g.,
	// "#filter-form" to forward filter state).
	Include string
	// Width is an optional CSS width hint (e.g., "120px", "20%").
	Width string
	// Sortable indicates whether the column header renders as a clickable sort link.
	Sortable bool
}

TableCol is a pure-data column descriptor for a table header. Sort metadata (direction, toggle URL) is precomputed by the handler so templates render without logic. Non-sortable columns leave SortURL empty.

func SortableCol

func SortableCol(key, label, currentSortKey, currentSortDir, baseURL, target, include string) TableCol

SortableCol creates a TableCol with precomputed sort state and toggle URL. Pass the current sort key and direction from the request's query parameters. The baseURL should have "sort" and "dir" params already stripped. Toggle logic: clicking an unsorted column sorts ascending, clicking the active ascending column toggles to descending, and clicking the active descending column toggles back to ascending.

Example
package main

import (
	"fmt"

	"github.com/catgoose/linkwell"
)

func main() {
	col := linkwell.SortableCol("name", "Name", "name", "asc", "/users", "#table", "#filter-form")
	fmt.Printf("key=%s dir=%s sortable=%v\n", col.Key, col.SortDir, col.Sortable)
}
Output:
key=name dir=asc sortable=true

type TableRowActionCfg

type TableRowActionCfg struct {
	EditURL     string // GET URL to fetch the edit form for this row.
	DeleteURL   string // DELETE URL to remove this row.
	RowTarget   string // CSS selector for the row element.
	TableTarget string // CSS selector for the table container.
	ConfirmMsg  string // hx-confirm message for the delete action.
	ErrorTarget string // CSS selector for inline error display.
}

TableRowActionCfg configures Edit + Delete controls where Edit swaps the individual row (RowTarget) and Delete replaces the entire table (TableTarget). Use when deleting a row requires re-rendering the full table (e.g., to update row numbers or totals).

type Toast added in v0.2.16

type Toast struct {
	// Message is the user-visible notification text.
	Message string
	// Variant determines the visual style (success, info, warning, error).
	Variant ToastVariant
	// Controls is an optional set of action affordances rendered with the toast
	// (e.g., an "Undo" button).
	Controls []Control
	// AutoDismiss is the number of seconds before the toast auto-closes.
	// Zero means the toast is sticky and must be dismissed manually.
	AutoDismiss int
	// OOBTarget is the CSS selector for an HTMX OOB swap target. When set,
	// the toast renders as an out-of-band fragment.
	OOBTarget string
	// OOBSwap is the hx-swap-oob strategy (e.g., "afterbegin").
	OOBSwap string
}

Toast is a pure-data descriptor for a transient notification. It is the success/info/warning complement to ErrorContext: the server decides what feedback to show and the template renders the appropriate alert with optional action controls. Toasts are value types; use the With* methods to derive modified copies.

func ErrorToast added in v0.2.16

func ErrorToast(message string) Toast

ErrorToast creates a Toast with the error variant.

func InfoToast added in v0.2.16

func InfoToast(message string) Toast

InfoToast creates a Toast with the info variant.

func SuccessToast added in v0.2.16

func SuccessToast(message string) Toast

SuccessToast creates a Toast with the success variant.

func WarningToast added in v0.2.16

func WarningToast(message string) Toast

WarningToast creates a Toast with the warning variant.

func (Toast) WithAutoDismiss added in v0.2.16

func (t Toast) WithAutoDismiss(seconds int) Toast

WithAutoDismiss returns a copy of the Toast with the given auto-dismiss duration in seconds. Pass 0 for a sticky toast.

func (Toast) WithControls added in v0.2.16

func (t Toast) WithControls(controls ...Control) Toast

WithControls returns a copy of the Toast with the given controls appended to the existing set. The original is not modified.

func (Toast) WithOOB added in v0.2.16

func (t Toast) WithOOB(target, swap string) Toast

WithOOB returns a copy of the Toast configured for HTMX out-of-band swap rendering. Set target to the CSS selector and swap to the hx-swap-oob strategy (e.g., "afterbegin").

type ToastVariant added in v0.2.16

type ToastVariant string

ToastVariant identifies the severity or intent of a Toast notification. Templates map variants to visual styles (e.g., DaisyUI alert-success).

const (
	// ToastSuccess indicates a successful operation.
	ToastSuccess ToastVariant = "success"
	// ToastInfo provides neutral informational feedback.
	ToastInfo ToastVariant = "info"
	// ToastWarning signals a non-blocking issue that deserves attention.
	ToastWarning ToastVariant = "warning"
	// ToastError indicates a failed operation (complement to ErrorContext).
	ToastError ToastVariant = "error"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL