diff --git a/README.md b/README.md index d040883..9b5d77d 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,7 @@ Claude Code skills for working with Muxt codebases: | Skill | Use Case | |-------|----------| -| [explore-from-route](docs/skills/muxt_explore-from-route/SKILL.md) | Trace from a URL path to its template and receiver method | -| [explore-from-method](docs/skills/muxt_explore-from-method/SKILL.md) | Find which routes and templates use a receiver method | -| [explore-from-error](docs/skills/muxt_explore-from-error/SKILL.md) | Trace an error message back to its handler and template | -| [explore-repo-overview](docs/skills/muxt_explore-repo-overview/SKILL.md) | Map all routes, templates, and the receiver type | +| [explore](docs/skills/muxt_explore/SKILL.md) | Trace through the template/method/route chain — pick a starting entry point (route, method, error, or fresh repo) | | [test-driven-development](docs/skills/muxt_test-driven-development/SKILL.md) | Create new templates and receiver methods using TDD | | [forms](docs/skills/muxt_forms/SKILL.md) | Form creation, struct binding, validation, accessible HTML | | [debug-generation-errors](docs/skills/muxt_debug-generation-errors/SKILL.md) | Diagnose and fix `muxt generate` / `muxt check` errors | diff --git a/docs/README.md b/docs/README.md index b647901..63e5482 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,10 +40,7 @@ Type-safe HTTP handlers from Go HTML templates. Claude Code skills for working with Muxt: -- **[explore-from-route](skills/muxt_explore-from-route/SKILL.md)** - Trace from a URL path to its template and receiver method -- **[explore-from-method](skills/muxt_explore-from-method/SKILL.md)** - Find which routes and templates use a receiver method -- **[explore-from-error](skills/muxt_explore-from-error/SKILL.md)** - Trace an error message back to its handler and template -- **[explore-repo-overview](skills/muxt_explore-repo-overview/SKILL.md)** - Map all routes, templates, and the receiver type +- **[explore](skills/muxt_explore/SKILL.md)** - Trace through the template/method/route chain — pick a starting entry point (route, method, error, or fresh repo) - **[template-driven-development](skills/muxt_test-driven-development/SKILL.md)** - Create new templates and methods using TDD - **[forms](skills/muxt_forms/SKILL.md)** - Form creation, struct binding, validation, and accessible form HTML - **[debug-generation-errors](skills/muxt_debug-generation-errors/SKILL.md)** - Diagnose and fix `muxt generate` / `muxt check` errors diff --git a/docs/reference/commands/explore-module.md b/docs/reference/commands/explore-module.md index 18f9265..53d0484 100644 --- a/docs/reference/commands/explore-module.md +++ b/docs/reference/commands/explore-module.md @@ -55,4 +55,4 @@ muxt explore-module --format=json | jq '.packages[].externalAssets[]' - [muxt generate](generate.md) — Generate handlers from templates - [muxt generate-fake-server](generate-fake-server.md) — Generate a fake server for interactive exploration -- [Explore Repo Overview](../../skills/muxt_explore-repo-overview/SKILL.md) — Skill for mapping an entire muxt codebase +- [Explore](../../skills/muxt_explore/SKILL.md) — Skill for mapping an entire muxt codebase diff --git a/docs/reference/commands/generate-fake-server.md b/docs/reference/commands/generate-fake-server.md index d4e450b..a76a2ef 100644 --- a/docs/reference/commands/generate-fake-server.md +++ b/docs/reference/commands/generate-fake-server.md @@ -89,5 +89,4 @@ take_screenshot({}) - [muxt generate](generate.md) — Generate handlers from templates - [muxt explore-module](explore-module.md) — Discover all muxt packages in the module -- [Explore Repo Overview](../../skills/muxt_explore-repo-overview/SKILL.md) — Full exploration workflow -- [Explore from Route](../../skills/muxt_explore-from-route/SKILL.md) — Trace a specific route +- [Explore](../../skills/muxt_explore/SKILL.md) — Full exploration workflow (pick an entry point: route, method, error, or fresh repo) diff --git a/docs/skills/muxt_debug-generation-errors/SKILL.md b/docs/skills/muxt_debug-generation-errors/SKILL.md index 385f427..389cb5c 100644 --- a/docs/skills/muxt_debug-generation-errors/SKILL.md +++ b/docs/skills/muxt_debug-generation-errors/SKILL.md @@ -3,295 +3,75 @@ name: muxt-debug-generation-errors description: "Muxt: Use when `muxt generate` or `muxt check` fails with an error. Covers reading error messages, diagnosing common error categories, and the fix-and-rerun workflow." --- -# Debugging Generation Errors +# Debugging muxt Generation Errors -When `muxt generate` or `muxt check` fails, use this workflow to diagnose and fix the error. +When `muxt generate` or `muxt check` fails, use this workflow to diagnose and fix. -## Reading Error Messages +## When to use this skill -Muxt errors include: -- **File position** — which `.gohtml` or `.go` file and line -- **Template name** — the template that triggered the error -- **Expected vs actual** — what Muxt expected and what it found +- `muxt generate` returns an error. +- `muxt check` reports a problem (template field error, unused template, dead code). +- Compiler error after `go generate` claims a method is missing on the receiver — that's actually a muxt issue. -Example error: +## Reading error messages -``` -Error: could not find receiver type server in example.com/internal/hypertext -``` +Muxt errors include: +- **File position** — which `.gohtml` or `.go` file and line. +- **Template name** — the template that triggered the error. +- **Expected vs actual** — what muxt expected and what it found. -Errors from `muxt generate` include the affected type or template and the package path. Template body errors from `muxt check` include file position and field name. +Errors from `muxt generate` include the affected type or template and the package path. Template-body errors from `muxt check` include file position and field name. ## `muxt check` vs `muxt generate` -| Command | What it does | Writes files? | -|---------|-------------|---------------| -| `muxt generate` | Type-checks and generates handler code | Yes | -| `muxt check` | Type-checks only (read-only validation) | No | - -Use `muxt check` for fast feedback during development. Use `muxt generate` when you're ready to produce the handler code. - -**Note:** `muxt check` only accepts `--use-templates-variable` and `--verbose` flags. It does not accept `--use-receiver-type` — it discovers types from the generated code. Run `muxt generate` first to produce the generated code, then `muxt check` to validate template body types. - -`muxt check` also detects issues that `muxt generate` does not: -- Unused templates (defined but never called as routes) -- Dead code outside `{{define}}` blocks -- Template body type errors (accessing nonexistent fields) - -## Fix Workflow - -1. **Read the error** — identify the file, template, and problem -2. **Find the template** — open the `.gohtml` file at the reported position -3. **Check the method signature** — ensure the receiver method exists with the right parameters and return types -4. **Fix the mismatch** — update the template name, method, or both -5. **Re-run** — `muxt check` or `muxt generate` to verify the fix - -```bash -muxt check # fast validation -muxt generate # generate + validate -go test ./... # run tests after fixing -``` - -## Common Error Categories - -### Missing or Wrong Receiver Type - -**Error:** `could not find receiver type` - -**Cause:** The `--use-receiver-type` flag names a type that doesn't exist in the package. - -**Fix:** Check the flag value matches your type name exactly (case-sensitive): - -```bash -# Wrong -muxt generate --use-receiver-type=server - -# Right -muxt generate --use-receiver-type=Server -``` - -**Test file:** `err_missing_receiver_type.txt` - -### Missing Templates Variable - -**Error:** `could not find templates variable` - -**Cause:** Muxt can't find a package-level `var templates` declaration. - -**Fix:** Ensure you have a package-level variable that parses templates: - -```go -//go:embed *.gohtml -var templateFS embed.FS - -var templates = template.Must(template.ParseFS(templateFS, "*.gohtml")) -``` - -The variable must be at package scope (not inside a function). - -**Test file:** `err_missing_templates_variable.txt` - -### Method Not Found on Receiver - -When a method in the template call expression doesn't exist on the receiver type, `muxt generate` does **not** produce an error. Instead, it infers a method signature from the call expression and adds it to the generated `RoutesReceiver` interface. The Go compiler then fails because the receiver type doesn't satisfy the interface: - -``` -*Server does not implement RoutesReceiver (missing method GetUser) -``` - -**Fix:** Either add the method to the receiver type or fix the template name: - -```go -// Template says: GetUser(ctx, id) -// Add this method: -func (s Server) GetUser(ctx context.Context, id int) (User, error) { ... } -``` - -Run `go generate ./...` followed by `go build` or `go test` to see the compiler error. - -### Method Signature Mismatches - -#### Wrong Parameter Count - -**Error:** `expected 2 arguments but got 3` (or similar) - -**Cause:** The template call expression has a different number of arguments than the method. - -**Fix:** Match the template arguments to the method parameters (or vice versa based on what was changed more recently): - -```gotmpl -{{define "GET /article/{id} GetArticle(ctx, id)"}} -``` - -```go -func (s Server) GetArticle(ctx context.Context, id int) (Article, error) -``` - -**Test files:** `err_missing_args.txt`, `err_extra_args.txt` - -#### Wrong Parameter Types - -**Error:** `argument type mismatch` or `unsupported type` - -**Cause:** A template argument maps to a parameter type that Muxt can't parse. - -**Fix:** Use supported types: `context.Context`, `*http.Request`, `http.ResponseWriter`, `string`, `int`, `bool`, or a type implementing `encoding.TextUnmarshaler`. - -**Test files:** `err_arg_type_mismatch.txt`, `err_arg_context_type.txt`, `err_arg_request_type.txt`, `err_arg_response_type.txt`, `err_arg_path_value_type.txt` - -#### No Return Value - -**Error:** `method for pattern "GET / Home()" has no results it should have one or two` - -**Cause:** The method returns nothing. Muxt needs at least one return value to render the template. - -**Fix:** Add a return type. Use a named empty struct `type HomePage struct{}` if no data is needed: - -```go -// Wrong: no return -func (s Server) Home(ctx context.Context) { ... } - -// Right: returns data for the template -func (s Server) Home(ctx context.Context) HomePage { ... } -``` - -**Test file:** `err_method_no_return.txt` - -### Form Field Type Errors - -**Error:** `unsupported type: url.URL` or `unsupported composite type` - -**Cause:** A form struct field uses a type that Muxt can't parse from form data. - -**Fix:** Use supported field types: `string`, `int`, `bool`, `[]string`, `[]int`. For complex types, parse them manually in your method. - -**Test files:** `err_form_unsupported_field_type.txt`, `err_form_unsupported_composite.txt` - -### Form Return Type Errors - -**Error:** `unsupported return type with form` or `bool return with form` - -**Cause:** The method's return type is incompatible with form handling. - -**Fix:** Use `(T, error)` or `T` as the return type for form methods. - -**Test files:** `err_form_unsupported_return.txt`, `err_form_bool_return.txt` - -### Duplicate Patterns - -**Error:** `duplicate route pattern: GET /` - -**Cause:** Two templates define the same HTTP method + path combination. - -**Fix:** Each route pattern must be unique. Rename or remove the duplicate: - -```gotmpl -{{/* These conflict: */}} -{{define "GET / Greetings()"}}...{{end}} -{{define "GET / Welcome()"}}...{{end}} - -{{/* Fix: use different paths or remove one */}} -{{define "GET / Welcome()"}}...{{end}} -{{define "GET /greetings Greetings()"}}...{{end}} -``` - -**Test file:** `err_duplicate_pattern.txt` - -### Template Type Errors (`muxt check`) - -**Error:** `.Result.WrongField` — field not found - -**Cause:** The template body accesses a field that doesn't exist on the method's return type. - -**Fix:** Check the return type's fields and fix the template: - -```go -type Article struct { - Title string - Body string -} -``` - -```gotmpl -{{/* Wrong: */}} -

{{.Result.Name}}

- -{{/* Right: */}} -

{{.Result.Title}}

-``` - -**Test file:** `err_check_with_wrong_field.txt` - -### Dead Code / Unused Templates (`muxt check`) - -**Error:** `unused template "sidebar"` or `dead code outside define` - -**Cause:** A template is defined but never referenced, or there's content outside any `{{define}}` block. - -**Fix:** Remove unused templates or wrap content in a `{{define}}` block. - -**Test files:** `err_check_with_unused_template.txt`, `err_check_with_dead_code_outside_define.txt` - -### Path Method Name Collision - -**Error:** `TemplateRoutePaths method name collision: handlers list and List both produce method List` - -**Cause:** Two handler methods differ only in the case of the first letter (e.g., `list` and `List`). Since `TemplateRoutePaths` methods are always exported for template access, both would produce the same method name. - -**Fix:** Rename one of the handler methods so they don't collide after capitalization. - -**Test file:** `err_path_method_collision.txt` - -### Unexportable Path Method Identifier - -**Error:** `cannot export identifier "_list" for TemplateRoutePaths method: first character '_' has no uppercase form` - -**Cause:** The handler method starts with a character that has no uppercase form (e.g., `_`). `TemplateRoutePaths` methods must be exported for template access. - -**Fix:** Rename the handler method to start with a letter. - -**Test file:** `err_path_method_unexportable.txt` - -### CLI Errors - -**Error:** `unknown command` or `unknown flag` - -**Cause:** Typo in the command or flag name. - -**Fix:** Check `muxt --help` for available commands and flags. - -**Test files:** `err_cli_unknown_command.txt`, `err_unknown_flag.txt`, `err_invalid_identifier_flag.txt`, `err_invalid_output_filename.txt` - -## Reference - -### All Error Test Cases (`cmd/muxt/testdata/err_*`) - -| Error | Test File | -|-------|-----------| -| Missing receiver type | `err_missing_receiver_type.txt` | -| Missing templates variable | `err_missing_templates_variable.txt` | -| Undefined form method (inferred) | `err_form_with_undefined_method.txt` | -| Method has no return | `err_method_no_return.txt` | -| Missing arguments | `err_missing_args.txt` | -| Extra arguments | `err_extra_args.txt` | -| Argument type mismatch | `err_arg_type_mismatch.txt` | -| Context type error | `err_arg_context_type.txt` | -| Request type error | `err_arg_request_type.txt` | -| Request pointer type error | `err_arg_request_ptr_type.txt` | -| Response type error | `err_arg_response_type.txt` | -| Path value type error | `err_arg_path_value_type.txt` | -| Field list type error | `err_arg_field_list_type.txt` | -| Unsupported form field type | `err_form_unsupported_field_type.txt` | -| Unsupported form composite | `err_form_unsupported_composite.txt` | -| Unsupported form return | `err_form_unsupported_return.txt` | -| Bool return with form | `err_form_bool_return.txt` | -| Duplicate route pattern | `err_duplicate_pattern.txt` | -| Wrong template field | `err_check_with_wrong_field.txt` | -| Unused template | `err_check_with_unused_template.txt` | -| Dead code outside define | `err_check_with_dead_code_outside_define.txt` | -| Unknown CLI command | `err_cli_unknown_command.txt` | -| Unknown flag | `err_unknown_flag.txt` | -| Invalid identifier flag | `err_invalid_identifier_flag.txt` | -| Invalid output filename | `err_invalid_output_filename.txt` | -| Path method name collision | `err_path_method_collision.txt` | -| Unexportable path method | `err_path_method_unexportable.txt` | +| Command | What | Writes files? | +|---------|------|---------------| +| `muxt generate` | Type-checks AND generates handler code | Yes | +| `muxt check` | Type-checks only (read-only) | No | + +`muxt check` only accepts `--use-templates-variable` and `--verbose`. It discovers types from the **already-generated code**, so run `muxt generate` first, then `muxt check`. + +`muxt check` also detects: +- Unused templates (defined but never called as routes). +- Dead code outside `{{define}}` blocks. +- Template-body type errors (accessing nonexistent fields). + +## Fix workflow + +1. **Read the error** — file, template, problem. +2. **Find the template** — open the `.gohtml` at the reported position. +3. **Check the method signature** — receiver method exists with right parameters and returns? +4. **Fix the mismatch** — template name, method, or both. +5. **Re-run:** + ```bash + muxt check # fast validation + muxt generate # generate + validate + go test ./... + ``` + +## Quick diagnosis lookup + +`references/error-catalog.md` has the full table. Summary by symptom: + +| Symptom | Likely category | +|---------|-----------------| +| `could not find receiver type` | Wrong `--use-receiver-type` value | +| `could not find templates variable` | Missing package-level `var templates` | +| Compiler: "does not implement RoutesReceiver" | Method not found on receiver — inferred but not implemented | +| `expected N arguments but got M` | Param count mismatch | +| `argument type mismatch`, `unsupported type` | Param type unsupported | +| `method ... has no results` | Method returns nothing — needs at least one return value | +| `unsupported type: url.URL`, `unsupported composite type` | Form field uses unsupported Go type | +| `unsupported return type with form`, `bool return with form` | Form method needs `(T, error)` or `T` | +| `duplicate route pattern: GET /` | Two templates with the same method+path | +| `.Result.WrongField` field not found | Template body accesses missing struct field | +| `unused template`, `dead code outside define` | Cleanup needed (remove or wrap in `{{define}}`) | +| `TemplateRoutePaths method name collision` | Two handlers differ only in first-letter case | +| `cannot export identifier "_..."` | Handler name starts with non-letter | +| `unknown command`, `unknown flag` | CLI typo | + +Each category has fix steps and a corresponding test case in `cmd/muxt/testdata/err_*.txt` — see `references/error-catalog.md`. + +## Reference files + +- `references/error-catalog.md` — every error category with full message, cause, fix steps, and the test file demonstrating the fix. diff --git a/docs/skills/muxt_debug-generation-errors/references/error-catalog.md b/docs/skills/muxt_debug-generation-errors/references/error-catalog.md new file mode 100644 index 0000000..804b59a --- /dev/null +++ b/docs/skills/muxt_debug-generation-errors/references/error-catalog.md @@ -0,0 +1,198 @@ +# muxt error catalog + +Each entry: error message, cause, fix, and the txtar test case in `cmd/muxt/testdata/`. + +## Missing or wrong receiver type + +**Error:** `could not find receiver type` + +**Cause:** `--use-receiver-type` flag names a type that doesn't exist (case-sensitive). + +**Fix:** Match the type name exactly: + +```bash +# Wrong +muxt generate --use-receiver-type=server +# Right +muxt generate --use-receiver-type=Server +``` + +**Test:** `err_missing_receiver_type.txt` + +## Missing templates variable + +**Error:** `could not find templates variable` + +**Cause:** No package-level `var templates` declaration. + +**Fix:** + +```go +//go:embed *.gohtml +var templateFS embed.FS + +var templates = template.Must(template.ParseFS(templateFS, "*.gohtml")) +``` + +The variable must be at package scope, not inside a function. + +**Test:** `err_missing_templates_variable.txt` + +## Method not found on receiver + +`muxt generate` does NOT produce an error directly. It infers a method signature from the call expression and adds it to `RoutesReceiver`. The Go compiler then fails: + +``` +*Server does not implement RoutesReceiver (missing method GetUser) +``` + +**Fix:** add the method, or fix the template name: + +```go +// Template says: GetUser(ctx, id) +func (s Server) GetUser(ctx context.Context, id int) (User, error) { ... } +``` + +Run `go generate ./...` then `go build` or `go test` to surface the compiler error. + +## Wrong parameter count + +**Error:** `expected 2 arguments but got 3` (or similar) + +**Fix:** match arguments to method parameters: + +```gotmpl +{{define "GET /article/{id} GetArticle(ctx, id)"}} +``` + +```go +func (s Server) GetArticle(ctx context.Context, id int) (Article, error) +``` + +**Tests:** `err_missing_args.txt`, `err_extra_args.txt` + +## Wrong parameter types + +**Error:** `argument type mismatch` or `unsupported type` + +**Fix:** use supported types — `context.Context`, `*http.Request`, `http.ResponseWriter`, `string`, `int`, `bool`, `encoding.TextUnmarshaler`. + +**Tests:** `err_arg_type_mismatch.txt`, `err_arg_context_type.txt`, `err_arg_request_type.txt`, `err_arg_response_type.txt`, `err_arg_path_value_type.txt` + +## No return value + +**Error:** `method for pattern "GET / Home()" has no results it should have one or two` + +**Fix:** add a return type. Use a named empty struct if no data is needed: + +```go +// Wrong +func (s Server) Home(ctx context.Context) { ... } + +// Right +func (s Server) Home(ctx context.Context) HomePage { ... } +``` + +**Test:** `err_method_no_return.txt` + +## Form field type errors + +**Error:** `unsupported type: url.URL` or `unsupported composite type` + +**Fix:** use supported field types — `string`, `int`, `bool`, `[]string`, `[]int`, or types implementing `encoding.TextUnmarshaler`. For complex types, parse manually. + +**Tests:** `err_form_unsupported_field_type.txt`, `err_form_unsupported_composite.txt` + +## Form return type errors + +**Error:** `unsupported return type with form` or `bool return with form` + +**Fix:** use `(T, error)` or `T` for form methods. + +**Tests:** `err_form_unsupported_return.txt`, `err_form_bool_return.txt` + +## Duplicate patterns + +**Error:** `duplicate route pattern: GET /` + +**Fix:** each route pattern must be unique. Rename or remove a duplicate: + +```gotmpl +{{/* Conflicts: */}} +{{define "GET / Greetings()"}}...{{end}} +{{define "GET / Welcome()"}}...{{end}} + +{{/* Fix: */}} +{{define "GET / Welcome()"}}...{{end}} +{{define "GET /greetings Greetings()"}}...{{end}} +``` + +**Test:** `err_duplicate_pattern.txt` + +## Template type errors (`muxt check`) + +**Error:** `.Result.WrongField` — field not found + +**Fix:** match template access to the actual return type: + +```go +type Article struct { Title, Body string } +``` + +```gotmpl +{{/* Wrong */}}

{{.Result.Name}}

+{{/* Right */}}

{{.Result.Title}}

+``` + +**Test:** `err_check_with_wrong_field.txt` + +## Dead code / unused templates (`muxt check`) + +**Error:** `unused template "sidebar"` or `dead code outside define` + +**Fix:** remove unused templates or wrap content in `{{define}}`. + +**Tests:** `err_check_with_unused_template.txt`, `err_check_with_dead_code_outside_define.txt` + +## Path method name collision + +**Error:** `TemplateRoutePaths method name collision: handlers list and List both produce method List` + +**Cause:** two handler methods differ only in case of the first letter. `TemplateRoutePaths` methods are always exported, so both produce the same method name. + +**Fix:** rename one method. + +**Test:** `err_path_method_collision.txt` + +## Unexportable path method identifier + +**Error:** `cannot export identifier "_list" for TemplateRoutePaths method: first character '_' has no uppercase form` + +**Fix:** rename the handler method to start with a letter. + +**Test:** `err_path_method_unexportable.txt` + +## CLI errors + +**Error:** `unknown command` or `unknown flag` + +**Fix:** `muxt --help`. + +**Tests:** `err_cli_unknown_command.txt`, `err_unknown_flag.txt`, `err_invalid_identifier_flag.txt`, `err_invalid_output_filename.txt` + +## All error test cases summary + +| Error | Test | +|-------|------| +| Missing receiver type | `err_missing_receiver_type.txt` | +| Missing templates variable | `err_missing_templates_variable.txt` | +| Undefined form method (inferred) | `err_form_with_undefined_method.txt` | +| Method has no return | `err_method_no_return.txt` | +| Missing / extra arguments | `err_missing_args.txt`, `err_extra_args.txt` | +| Argument type mismatch (general / context / request / request-pointer / response / path-value / field-list) | `err_arg_type_mismatch.txt`, `err_arg_context_type.txt`, `err_arg_request_type.txt`, `err_arg_request_ptr_type.txt`, `err_arg_response_type.txt`, `err_arg_path_value_type.txt`, `err_arg_field_list_type.txt` | +| Unsupported form field / composite / return / bool return | `err_form_unsupported_field_type.txt`, `err_form_unsupported_composite.txt`, `err_form_unsupported_return.txt`, `err_form_bool_return.txt` | +| Duplicate route pattern | `err_duplicate_pattern.txt` | +| Wrong template field | `err_check_with_wrong_field.txt` | +| Unused template / dead code outside define | `err_check_with_unused_template.txt`, `err_check_with_dead_code_outside_define.txt` | +| Unknown CLI command / flag / invalid identifier / invalid output filename | `err_cli_unknown_command.txt`, `err_unknown_flag.txt`, `err_invalid_identifier_flag.txt`, `err_invalid_output_filename.txt` | +| Path method name collision / unexportable | `err_path_method_collision.txt`, `err_path_method_unexportable.txt` | diff --git a/docs/skills/muxt_explore-from-error/SKILL.md b/docs/skills/muxt_explore-from-error/SKILL.md deleted file mode 100644 index 0092fe6..0000000 --- a/docs/skills/muxt_explore-from-error/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: muxt-explore-from-error -description: "Muxt: Use when tracing an error message or log line back through a Muxt codebase to find the handler and template that produced it." ---- - -# Explore from Error - -Start from an error message or log line and trace back to the receiver method, handler, and template. - -## Step 1: Find the Error Source - -Grep the codebase for the error string to locate the receiver method that produces it: - -```bash -grep -rn "user not found" --include='*.go' . -``` - -This identifies the method and file where the error originates. - -## Step 2: Find the Generated Handler - -Use gopls Find References on the method to find the generated handler that calls it: - -``` -go_symbol_references({"file": "/path/to/receiver.go", "symbol": "Server.GetUser"}) -``` - -Read the generated handler to understand how errors flow through `TemplateData[R, T]`. - -## Step 3: Find the Route Template - -Use `muxt list-template-callers` with `--match` to find the route template. The `--match` flag takes a regular expression matched against the full template name (`[METHOD ][HOST]/PATH[ STATUS][ CALL]`): - -```bash -muxt list-template-callers --match "GetUser" -``` - -## Step 4: Check Error Rendering - -Read the template to see how `.Err` is rendered: - -- Is there a `{{with .Err}}` block? -- Does the template display the error message? -- Are there missing error handling paths? - -If the template lacks error handling, this explains why errors may be swallowed silently. - -## Step 5: Enable Handler Logging - -Muxt-generated handlers log via `log/slog`. Without any flags, they call `slog.ErrorContext` on the global logger when template execution fails. - -To also see a debug-level log line for every incoming request, enable `--output-routes-func-with-logger-param`. This adds a `*slog.Logger` parameter to `TemplateRoutes` and generates both log levels: - -- **Debug**: every request (pattern, path, method) -- **Error**: template execution failures (pattern, path, error message) - -```go -//go:generate muxt generate --use-receiver-type=Server --output-routes-func-with-logger-param -``` - -Make sure the logger's level is set low enough to see debug output: - -```go -// Development: text output, debug level -logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, -})) -TemplateRoutes(mux, receiver, logger) -``` - -For production, use JSON output and environment-based level: - -```go -level := slog.LevelError -if os.Getenv("ENV") == "development" { - level = slog.LevelDebug -} -logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: level, -})) -``` - -See Go's [log/slog documentation](https://pkg.go.dev/log/slog) and [structured logging blog post](https://go.dev/blog/slog). - -## Reference - -- [Call Results](../../reference/call-results.md) -- [Template Name Syntax](../../reference/template-names.md) -- [CLI Commands](../../reference/cli.md) - -### Test Cases (`cmd/muxt/testdata/`) - -- `reference_structured_logging.txt` — Structured logging with JSON parsing and slog field assertions -- `reference_cli_logger_flag.txt` — `--output-routes-func-with-logger-param` flag diff --git a/docs/skills/muxt_explore-from-method/SKILL.md b/docs/skills/muxt_explore-from-method/SKILL.md deleted file mode 100644 index 3676994..0000000 --- a/docs/skills/muxt_explore-from-method/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: muxt-explore-from-method -description: "Muxt: Use when exploring a Muxt codebase starting from a receiver method name. Traces from method to templates and routes that use it." ---- - -# Explore from Method - -Start from a receiver method name and find which templates and routes use it. - -## Step 1: Find the Method - -Use gopls workspace symbol search for the method name: - -``` -go_search({"query": "GetUser"}) -``` - -Go to Definition to read its implementation, signature, and return types. - -## Step 2: Find References - -Use gopls Find References to see where the method is called: - -``` -go_symbol_references({"file": "/path/to/receiver.go", "symbol": "Server.GetUser"}) -``` - -This shows the generated handler code that calls the method, confirming it's wired up. - -## Step 3: Find the Route Template - -Use `muxt list-template-callers` with `--match` to find which route template(s) reference the method. The `--match` flag takes a regular expression matched against the full template name. - -Template names follow this pattern: - -``` -[METHOD ][HOST]/PATH[ STATUS][ CALL] -``` - -Examples: `GET /users/{id} GetUser(ctx, id)`, `POST /article 201 CreateArticle(ctx, form)`, `/help` - -Match by call expression to find templates that invoke the method: - -```bash -muxt list-template-callers --match "GetUser" -``` - -This shows the template name containing the call expression, any templates that call it, and any Go `ExecuteTemplate` calls that invoke it. - -## Step 4: Read the Template - -Open the `.gohtml` file(s) to see how the method's return value is rendered: - -- Which fields of the return type are accessed -- How errors are handled (`.Err`) -- What sub-templates are called with the data - -## Reference - -- [Template Name Syntax](../../reference/template-names.md) -- [Call Parameters](../../reference/call-parameters.md) -- [Call Results](../../reference/call-results.md) -- [CLI Commands](../../reference/cli.md) - -### Test Cases (`cmd/muxt/testdata/`) - -- `reference_list_template_callers.txt` — `muxt list-template-callers` output format -- `howto_list_template_callers.txt` — Using `--match` flag to filter template callers diff --git a/docs/skills/muxt_explore-from-route/SKILL.md b/docs/skills/muxt_explore-from-route/SKILL.md deleted file mode 100644 index 9c438c6..0000000 --- a/docs/skills/muxt_explore-from-route/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: muxt-explore-from-route -description: "Muxt: Use when exploring a Muxt codebase starting from a URL path or route pattern. Traces from route to template to receiver method." ---- - -# Explore from Route - -Start from a URL path or route pattern and trace through the template to the receiver method. - -## Step 1: Find the Route Template - -Use `muxt list-template-calls` with `--match` to find the route template. The `--match` flag takes a regular expression matched against the full template name. - -Template names follow this pattern: - -``` -[METHOD ][HOST]/PATH[ STATUS][ CALL] -``` - -Examples: `GET /users/{id} GetUser(ctx, id)`, `POST /article 201 CreateArticle(ctx, form)`, `/help` - -Craft your `--match` regex to target any part of the name: - -```bash -# Match by path -muxt list-template-calls --match "/users" - -# Match by method and path -muxt list-template-calls --match "^GET /users" - -# Match by call expression -muxt list-template-calls --match "GetUser" - -# Match a specific path parameter pattern -muxt list-template-calls --match "/users/\\{id\\}" -``` - -This shows the template name and any sub-templates it calls. - -Use `muxt list-template-callers` with the same `--match` flag to see which other templates (and Go `ExecuteTemplate` calls) reference this one: - -```bash -muxt list-template-callers --match "^GET /users" -``` - -## Step 2: Read the Template - -Open the `.gohtml` file containing the matched template. Look for: - -- The **call expression** in the template name (e.g., `GetUser(ctx, id)`) -- **Sub-template calls** via `{{template "name" .}}` or `{{block "name" .}}` -- How the **return value** is rendered (`.Result`, `.Err`, fields) - -## Step 3: Trace the Receiver Method - -Use gopls to navigate to the method named in the call expression: - -1. **Workspace symbol search** for the method name (e.g., `GetUser`) -2. **Go to Definition** on the method to read its implementation -3. Read the method signature to understand parameter parsing and return types - -## Step 4: Trace Types Used in the Template - -Use gopls to inspect types referenced in the template: - -1. **Go to Definition** on the return type to see its fields (these are what `.Result.FieldName` accesses) -2. **Go to Definition** on form struct types if the template handles `POST` with form binding -3. **Package API** for any imported types used as parameters or return values - -## Reference - -- [Template Name Syntax](../../reference/template-names.md) -- [Call Parameters](../../reference/call-parameters.md) -- [Call Results](../../reference/call-results.md) -- [CLI Commands](../../reference/cli.md) - -### Test Cases (`cmd/muxt/testdata/`) - -- `reference_list_template_calls.txt` — `muxt list-template-calls` output format -- `howto_list_template_calls.txt` — Using `--match` flag to filter template calls diff --git a/docs/skills/muxt_explore-repo-overview/SKILL.md b/docs/skills/muxt_explore-repo-overview/SKILL.md deleted file mode 100644 index bef8ebd..0000000 --- a/docs/skills/muxt_explore-repo-overview/SKILL.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -name: muxt-explore-repo-overview -description: "Muxt: Use when new to a Muxt codebase and need the big picture. Maps all routes, templates, and the receiver type." ---- - -# Explore Repo Overview - -Get the big picture of an existing Muxt codebase by mapping all routes, templates, and the receiver type. - -## Step 1: Discover All Muxt Packages - -```bash -muxt explore-module -``` - -Or for structured data: - -```bash -muxt explore-module --format=json -``` - -This discovers all packages with muxt-generated files, shows their configuration (routes function, receiver interface, receiver type), and provides the exact commands to drill into each package. - -## Step 2: Drill Into Specific Packages - -Use the commands from explore-module output to list routes and template relationships: - -```bash -muxt explore-module --format=json | jq -r '.packages[].commands.calls' | sh -``` - -Find HTMX-enabled packages: - -```bash -muxt explore-module --format=json | jq -r '.packages[] | select(.config.htmxHelpers) | .path' -``` - -List external CDN assets: - -```bash -muxt explore-module --format=json | jq '.packages[].externalAssets[]' -``` - -**Warning:** `muxt list-template-calls` and `muxt list-template-callers` can produce very large output in bigger codebases and may clog up the context window. Before running them unfiltered, check the size: - -```bash -muxt list-template-calls | wc -l -muxt list-template-callers | wc -l -``` - -If the output is large, use `--match` to focus on a specific area of interest (see the [explore-from-route](../muxt_explore-from-route/SKILL.md) and [explore-from-method](../muxt_explore-from-method/SKILL.md) skills). Or page through it: - -```bash -muxt list-template-calls | head -50 -muxt list-template-callers | head -50 -``` - -Only run the full unfiltered output when the line count is manageable. - -## Step 3: Trace Navigation Links in Templates - -Scan templates for navigation links: - -```bash -bash ${CLAUDE_SKILL_DIR}/scripts/scan-navigation.sh -``` - -Look for two patterns: - -- **Hardcoded paths** like `` — these reference routes by string and can break silently if routes change -- **Type-checked paths** like `` — these use the generated `TemplateRoutePaths` helper, so the compiler catches broken links - -The `.Path` method is available on `TemplateData` in every route template. Each route with a call expression gets a corresponding method on `TemplateRoutePaths`: - -```gotmpl -{{define "GET /{$} Home(ctx)"}} - Users - User 42 -{{end}} -``` - -When exploring, note which links use `.Path` (safe) vs hardcoded strings (fragile). This is a good signal for code quality. - -## Step 4: Understand the Receiver Type - -Use `go doc` or the explore-module output to find the receiver type: - -```bash -muxt explore-module --format=json | jq -r '.packages[0] | "go doc \(.path) \(.config.receiverType)"' | sh -``` - -For deeper exploration, use gopls: - -1. **Workspace symbol search** for the receiver type name (e.g., `Server`) -2. **Go to Definition** to read its struct fields and dependencies -3. **Package API** to see all its methods — each method corresponds to a route's call expression - -## Step 5: Explore Interactively (Optional) - -Generate an httptest exploration server with a counterfeiter fake: - -```bash -muxt generate-fake-server path/to/package -``` - -This generates files in `./cmd/explore-goland/`: - -- **`main.go`** — readable entry point: creates the fake receiver, wires routes, starts httptest server -- **`internal/fake/receiver.go`** — counterfeiter-generated fake struct with `*Returns()` and `*CallCount()` methods - -Use `--output` to change the output directory: - -```bash -muxt generate-fake-server path/to/package --output ./cmd/my-explorer -``` - -### Set Up Fake Return Values - -Edit `main.go` to set up return values on the fake receiver before the server starts. The fake has `*Returns(...)` methods for each interface method: - -```go -receiver := new(fake.RoutesReceiver) - -// Set up fake data so routes render with realistic content -receiver.ListArticlesReturns([]hypertext.Article{ - {ID: 1, Title: "First Post", Body: "Hello world"}, - {ID: 2, Title: "Second Post", Body: "Another article"}, -}, nil) -receiver.GetArticleReturns(hypertext.Article{ - ID: 1, Title: "First Post", Body: "Hello world", -}, nil) -``` - -This lets you see what the templates render with specific data. Modify the return values and re-run `go run ./cmd/explore-goland/` to test different states (empty lists, error conditions, edge cases). - -### Run the Server - -```bash -go run ./cmd/explore-goland/ -``` - -The server prints `Explore at: http://127.0.0.1:` and waits for Ctrl-C. - -### Browse with Chrome DevTools MCP - -Use Chrome DevTools MCP tools to explore the frontend: - -``` -navigate_page({"url": "http://127.0.0.1:/"}) -take_snapshot({}) -take_screenshot({}) -``` - -Click through links, inspect the DOM, and take screenshots to understand the page structure. For HTMX-enabled pages, interact with elements and observe the network requests: - -``` -click({"uid": ""}) -take_snapshot({}) -list_network_requests({}) -``` - -### Explore the Full Frontend Stack - -Chrome DevTools MCP operates a real browser, so HTMX swaps, JavaScript, and CSS all work. Use it to verify the full frontend stack: - -1. **Navigate** to a page and take a snapshot to see the rendered DOM -2. **Click** interactive elements (buttons, links, HTMX triggers) and snapshot again to see what changed -3. **Check network requests** to see HTMX fragment fetches and form submissions -4. **Take screenshots** to see the visual result with CSS applied - -For HTMX pages, this is the fastest way to verify that `hx-get`, `hx-post`, and swap targets work correctly with the fake data you configured in `main.go`. - -## Reference - -- [Template Name Syntax](../../reference/template-names.md) -- [Templates Variable](../../reference/templates-variable.md) -- [CLI Commands](../../reference/cli.md) - -### Test Cases (`cmd/muxt/testdata/`) - -- `reference_package_discovery.txt` — How muxt discovers the package and templates variable -- `reference_template_embed_gen_decl.txt` — `//go:embed` with `var` declaration -- `reference_template_glob.txt` — Template glob patterns -- `reference_template_with_multiple_embeds.txt` — Multiple `//go:embed` directives -- `reference_list_template_calls.txt` — `muxt list-template-calls` output format -- `reference_list_template_callers.txt` — `muxt list-template-callers` output format -- `reference_multiple_generated_routes.txt` — Multiple templates variables in one package diff --git a/docs/skills/muxt_explore/SKILL.md b/docs/skills/muxt_explore/SKILL.md new file mode 100644 index 0000000..e37f068 --- /dev/null +++ b/docs/skills/muxt_explore/SKILL.md @@ -0,0 +1,100 @@ +--- +name: muxt-explore +description: "Muxt: Use when exploring a Muxt codebase. Pick the entry point that matches your starting point — a URL/route, a receiver method, an error message/log line, or a fresh repo. Each entry traces through the template-method-route chain." +--- + +# Explore a Muxt Codebase + +Muxt's template-method-route coupling chain has three anchors: the **template** (defines the route + call expression), the **receiver method** (provides the data), and the **route** (the URL pattern). Pick the entry point matching what you have: + +| You have | Section | +|---|---| +| A URL path or route pattern | [From a route](#from-a-route) | +| A receiver method name | [From a method](#from-a-method) | +| An error message or log line | [From an error](#from-an-error) | +| Nothing — fresh codebase | [Repo overview](#repo-overview) | + +## From a route + +Start from a URL path / route pattern; trace through template → method. + +1. **Find the template.** `muxt list-template-calls --match ""` — regex is matched against the full template name `[METHOD ][HOST]/PATH[ STATUS][ CALL]`. Examples: + ```bash + muxt list-template-calls --match "/users" # by path + muxt list-template-calls --match "^GET /users" # method + path + muxt list-template-calls --match "GetUser" # by call expression + muxt list-template-calls --match "/users/\\{id\\}" # path-param pattern + ``` + `muxt list-template-callers --match ...` shows which other templates and Go `ExecuteTemplate` calls reference this one. + +2. **Read the template.** Open the matched `.gohtml`. Note: the call expression in the template name, `{{template "name" .}}` or `{{block ...}}` sub-template calls, how `.Result` / `.Err` / fields are rendered. + +3. **Trace the receiver method.** gopls workspace symbol search for the method named in the call expression. Go to Definition for implementation; read signature for parameter parsing and return types. + +4. **Trace types used in the template.** Go to Definition on the return type (fields = `.Result.FieldName`), on form structs for POST routes, and Package API for imported types. + +## From a method + +Start from a receiver method name; find templates and routes that use it. + +1. **Find the method.** `go_search({"query": "GetUser"})` then Go to Definition for implementation, signature, return types. +2. **Find references.** `go_symbol_references({"file": "/path/to/receiver.go", "symbol": "Server.GetUser"})` — shows the generated handler that calls it. +3. **Find the route template.** `muxt list-template-callers --match "GetUser"` matches the regex against the full template name. The output shows the template containing the call, templates that call it, and Go `ExecuteTemplate` invocations. +4. **Read the template** to see how the return value renders — which fields, how errors are handled, sub-templates invoked. + +## From an error + +Start from an error message or log line; find the receiver method and template. + +1. **Find the error source.** Grep for the error string: + ```bash + grep -rn "user not found" --include='*.go' . + ``` +2. **Find the generated handler.** `go_symbol_references` on the method shows the generated handler that calls it. Read it to understand how errors flow through `TemplateData[R, T]`. +3. **Find the route template.** `muxt list-template-callers --match ""`. +4. **Check error rendering** in the template — `{{with .Err}}` block? Does it display the error message? Missing handling can swallow errors silently. +5. **Enable handler logging** if errors aren't surfacing — see `references/structured-logging.md` for slog setup. + +## Repo overview + +You're new to the codebase. Map all routes, templates, and the receiver type. + +1. **Discover all muxt packages.** `muxt explore-module` (or `--format=json` for structured data). Shows package config (routes function, receiver interface, receiver type) and exact drill-in commands. + +2. **Drill into specific packages** using the commands from explore-module output: + ```bash + muxt explore-module --format=json | jq -r '.packages[].commands.calls' | sh + muxt explore-module --format=json | jq -r '.packages[] | select(.config.htmxHelpers) | .path' + ``` + +3. **Watch context size.** `muxt list-template-calls` and `list-template-callers` can produce huge output. Check first: + ```bash + muxt list-template-calls | wc -l + ``` + If large, use `--match` to focus, or page (`| head -50`). + +4. **Trace navigation.** Run `bash ${CLAUDE_SKILL_DIR}/scripts/scan-navigation.sh`. Look for hardcoded paths (``, fragile) vs type-checked paths (``, safe). + +5. **Understand the receiver type.** `go doc` it; gopls workspace symbol search; Go to Definition for fields and dependencies. + +6. **(Optional) Explore interactively.** Generate an httptest exploration server with counterfeiter-faked receiver: see `references/interactive-exploration.md` for the full workflow (`muxt generate-fake-server`, fake return setup, Chrome DevTools MCP). + +## Reference files + +- `references/structured-logging.md` — `--output-routes-func-with-logger-param` setup and slog level conventions for surfacing handler errors. +- `references/interactive-exploration.md` — `muxt generate-fake-server`, configuring fake return values, browsing with Chrome DevTools MCP. + +## External reference + +- [Template Name Syntax](../../reference/template-names.md) +- [Call Parameters](../../reference/call-parameters.md) +- [Call Results](../../reference/call-results.md) +- [Templates Variable](../../reference/templates-variable.md) +- [CLI Commands](../../reference/cli.md) + +### Test Cases (`cmd/muxt/testdata/`) + +- `reference_list_template_calls.txt`, `howto_list_template_calls.txt` — `list-template-calls` output and `--match` +- `reference_list_template_callers.txt`, `howto_list_template_callers.txt` — `list-template-callers` output and `--match` +- `reference_package_discovery.txt`, `reference_template_embed_gen_decl.txt`, `reference_template_glob.txt`, `reference_template_with_multiple_embeds.txt`, `reference_multiple_generated_routes.txt` — package and template discovery +- `reference_structured_logging.txt`, `reference_cli_logger_flag.txt` — logger setup diff --git a/docs/skills/muxt_explore/references/interactive-exploration.md b/docs/skills/muxt_explore/references/interactive-exploration.md new file mode 100644 index 0000000..7e82707 --- /dev/null +++ b/docs/skills/muxt_explore/references/interactive-exploration.md @@ -0,0 +1,72 @@ +# Interactive exploration via fake server + Chrome DevTools MCP + +Generate an httptest exploration server with a counterfeiter fake so you can browse the running app with realistic data. Useful when reading templates and trying to picture what they render is too indirect. + +## Generate + +```bash +muxt generate-fake-server path/to/package +``` + +Files in `./cmd/explore-goland/`: + +- **`main.go`** — readable entry point: creates the fake receiver, wires routes, starts httptest server. +- **`internal/fake/receiver.go`** — counterfeiter-generated fake struct with `*Returns()` and `*CallCount()` methods. + +Override the output dir: + +```bash +muxt generate-fake-server path/to/package --output ./cmd/my-explorer +``` + +## Set up fake return values + +Edit `main.go` before the server starts. The fake has `*Returns(...)` per interface method: + +```go +receiver := new(fake.RoutesReceiver) + +// Set up fake data so routes render with realistic content +receiver.ListArticlesReturns([]hypertext.Article{ + {ID: 1, Title: "First Post", Body: "Hello world"}, + {ID: 2, Title: "Second Post", Body: "Another article"}, +}, nil) +receiver.GetArticleReturns(hypertext.Article{ + ID: 1, Title: "First Post", Body: "Hello world", +}, nil) +``` + +Modify return values and rerun `go run ./cmd/explore-goland/` to test different states (empty lists, errors, edge cases). + +## Run the server + +```bash +go run ./cmd/explore-goland/ +``` + +Prints `Explore at: http://127.0.0.1:` and waits for Ctrl-C. + +## Browse with Chrome DevTools MCP + +``` +navigate_page({"url": "http://127.0.0.1:/"}) +take_snapshot({}) +take_screenshot({}) +``` + +Click links, inspect the DOM, screenshot the rendered page. For HTMX-enabled pages, interact and observe the network: + +``` +click({"uid": ""}) +take_snapshot({}) +list_network_requests({}) +``` + +Chrome DevTools MCP runs a real browser, so HTMX swaps, JavaScript, and CSS all work. Workflow: + +1. **Navigate** + take snapshot for the rendered DOM. +2. **Click** interactive elements (buttons, links, HTMX triggers); snapshot again to see what changed. +3. **Check network requests** for HTMX fragment fetches and form submissions. +4. **Take screenshots** for the visual result with CSS applied. + +For HTMX pages this is the fastest way to verify `hx-get`, `hx-post`, and swap targets work correctly with the fake data. diff --git a/docs/skills/muxt_explore/references/structured-logging.md b/docs/skills/muxt_explore/references/structured-logging.md new file mode 100644 index 0000000..4e25925 --- /dev/null +++ b/docs/skills/muxt_explore/references/structured-logging.md @@ -0,0 +1,36 @@ +# Structured logging in muxt handlers + +Muxt-generated handlers log via `log/slog`. Without any flags, they call `slog.ErrorContext` on the global logger when template execution fails. + +To also see a debug-level log line for every incoming request, enable `--output-routes-func-with-logger-param`. This adds a `*slog.Logger` parameter to `TemplateRoutes` and generates both log levels: + +- **Debug** — every request (pattern, path, method). +- **Error** — template execution failures (pattern, path, error message). + +```go +//go:generate muxt generate --use-receiver-type=Server --output-routes-func-with-logger-param +``` + +Make sure the logger's level is set low enough to see debug output: + +```go +// Development: text output, debug level +logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, +})) +TemplateRoutes(mux, receiver, logger) +``` + +For production, use JSON output and an environment-based level: + +```go +level := slog.LevelError +if os.Getenv("ENV") == "development" { + level = slog.LevelDebug +} +logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, +})) +``` + +See Go's [log/slog documentation](https://pkg.go.dev/log/slog) and [structured logging blog post](https://go.dev/blog/slog). diff --git a/docs/skills/muxt_explore-repo-overview/scripts/scan-navigation.sh b/docs/skills/muxt_explore/scripts/scan-navigation.sh similarity index 100% rename from docs/skills/muxt_explore-repo-overview/scripts/scan-navigation.sh rename to docs/skills/muxt_explore/scripts/scan-navigation.sh diff --git a/docs/skills/muxt_forms/SKILL.md b/docs/skills/muxt_forms/SKILL.md index 853423a..8714341 100644 --- a/docs/skills/muxt_forms/SKILL.md +++ b/docs/skills/muxt_forms/SKILL.md @@ -7,582 +7,99 @@ description: "Muxt: Use when creating HTML forms in a Muxt codebase — form str Create forms that bind to Go structs with type-safe field parsing and validation. Muxt generates server-side code from your template and struct definitions. -## Form Struct Design +## When to use this skill -Define a struct for form data. Field names map to HTML `name` attributes using the exact Go field name (case-preserved). Use the `name` tag to override: +- Adding a POST/PATCH/PUT route that accepts form data. +- Need server-side validation generated from HTML attributes. +- Need accessible forms (labels, ARIA, error display). +- Uploading files (multipart). -```go -type CreateArticleForm struct { - Title string - Body string - Count int - Publish bool -} -``` +## Form struct + name mapping -With custom field names (use `name` tag to map to lowercase or hyphenated HTML names): +Field names map to HTML `name` attributes using the exact Go field name. Use `name` tag to override (`name:"full-name"`). Use the `template` tag to point at the block whose validation attributes muxt should scan. Slice fields accept multiple values. -```go -type ContactForm struct { - FullName string `name:"full-name"` - Email string `name:"email-address"` -} -``` +See `references/examples.md` for full struct, HTML, and re-render-on-error examples. -Use the `template` struct tag to tell muxt which template block contains the HTML `` for a field. Muxt scans that template for validation attributes (`min`, `max`, `pattern`, etc.) and generates server-side checks: - -```go -type CreateArticleForm struct { - Title string `template:"create-form"` - Count int `template:"create-form"` -} -``` +## Template name syntax -Slice fields accept multiple values from checkboxes or multi-selects: - -```go -type FilterForm struct { - Categories []string - Ratings []int -} -``` - -## Template Name Syntax - -Use a `form` parameter in the call expression. The status code is optional (defaults to 200): +Use a `form` parameter in the call expression. Status code optional (defaults to 200): ```gotmpl {{define "POST /article 201 CreateArticle(ctx, form)"}}...{{end}} {{define "PATCH /article/{id} UpdateArticle(ctx, id, form)"}}...{{end}} -{{define "POST /contact SendMessage(form)"}}...{{end}} ``` -The `form` parameter tells Muxt to parse the request body as form data and bind it to the struct type in the method signature. - -## Accessible Form HTML - -Use semantic HTML with proper labeling, ARIA attributes, and validation feedback: - -```gotmpl -{{define "POST /article 201 CreateArticle(ctx, form)"}} -{{if .Err}} -
-

{{.Err.Error}}

-
-{{else}} -

Article "{{.Result.Title}}" created.

-{{end}} -{{end}} -``` - -### Form Input Patterns - -Use `
` with a ``: - -```gotmpl -
- Categories -
- - -
-
- - -
-
-``` +The `form` parameter tells muxt to parse the request body as form data and bind it to the struct type in the method signature. -## HTML5 Validation Attributes +## HTML5 validation attributes -Muxt generates server-side validation for a subset of HTML5 validation attributes on `` elements. Not all HTML5 attributes are supported — write tests to verify the validation behavior you depend on. +Muxt generates server-side validation for **a subset** of HTML5 validation attributes. Not all are supported — write tests for the validation behavior you depend on. -### Supported Attributes +### Supported -| Attribute | Input Types | Generated Check | +| Attribute | Input types | Generated check | |-----------|------------|-----------------| -| `min` | `number`, `range`, `date`, `month`, `week`, `time`, `datetime-local` | `if value < min` → 400 Bad Request | -| `max` | `number`, `range`, `date`, `month`, `week`, `time`, `datetime-local` | `if value > max` → 400 Bad Request | +| `min` | `number`, `range`, `date`, `month`, `week`, `time`, `datetime-local` | `if value < min` → 400 | +| `max` | same as `min` | `if value > max` → 400 | | `pattern` | `text`, `search`, `url`, `tel`, `email`, `password` | `regexp.MustCompile(pattern).MatchString(value)` → 400 | | `minlength` | all | `if len(value) < minlength` → 400 | | `maxlength` | all | `if len(value) > maxlength` → 400 | -### Not Supported +### Not supported (validate in the receiver method if needed) -These common HTML5 validation attributes are **not** enforced server-side by muxt. If you need them, validate in your receiver method: +`required`, `step`, `accept`, `multiple` — silent on the server side. -- `required` — no server-side enforcement -- `step` — no step validation for numeric inputs -- `accept` — file input types not validated -- `multiple` — not validated +When validation fails, the generated handler returns 400 with an error message. Display with `role="alert"`. -```gotmpl - -

Age must be between 0 and 150.

+## Field type parsing - -

Format: ABC-1234 (3 uppercase letters, dash, 4 digits).

-``` +| Go type | Notes | +|---------|-------| +| `string` | No parsing. | +| `int*`, `uint*` | `strconv` parsing. | +| `bool` | `strconv.ParseBool` (`"true"`, `"1"`, `"t"`, etc. → true). | +| `encoding.TextUnmarshaler` | Custom types implementing `UnmarshalText` (e.g., `time.Time`). | +| `[]string` / `[]int` | Multiple values collected. | -When validation fails, the generated handler returns HTTP 400 with an error message. Display it with `role="alert"`: - -```gotmpl -{{if .Err}} -
-

{{.Err.Error}}

-
-{{end}} -``` +Unsupported field types (`url.URL`, maps, nested structs) → generation error. Use scalars, slices of scalars, or `TextUnmarshaler`. -## Field Type Parsing - -Muxt parses form fields based on the struct field type: - -| Go Type | HTML Input | Notes | -|---------|-----------|-------| -| `string` | `` | No parsing needed | -| `int`, `int8`–`int64` | `` | Parsed with `strconv` | -| `uint`, `uint8`–`uint64` | `` | Parsed with `strconv` | -| `bool` | `` | Parsed with `strconv.ParseBool`: `"true"`, `"1"`, `"t"`, `"T"`, `"TRUE"`, `"True"` are true | -| `encoding.TextUnmarshaler` | any | Custom types implementing `UnmarshalText` (e.g., `time.Time`) | -| `[]string` | Multiple checkboxes/select | All values collected | -| `[]int` | Multiple number inputs | Each value parsed | - -Unsupported field types (e.g., `url.URL`, maps, nested structs) produce a generation error. Use simple scalar types, slices of scalars, or types implementing `encoding.TextUnmarshaler`. - -## Receiver Method Signature - -The method takes the form struct as a parameter: +## Receiver method ```go -func (s Server) CreateArticle(ctx context.Context, form CreateArticleForm) (Article, error) { - return s.db.InsertArticle(ctx, form.Title, form.Body) -} +func (s Server) CreateArticle(ctx context.Context, form CreateArticleForm) (Article, error) { ... } ``` -Return `(T, error)` to render the result or display an error in the template. +Return `(T, error)` to render the result or display an error. -## Re-rendering After Validation Errors +## Re-rendering after validation errors -When a standard form submission fails server-side validation, re-render the form with the user's submitted values and per-field errors. Return a result type with named error fields, and use `.Request.FormValue` to repopulate inputs from the request: +For standard form submissions, return a result type with named error fields and use `.Request.FormValue` to repopulate inputs from the request. The result type carries per-field errors; `.Request.FormValue` preserves submitted values — no need to echo form data through the result struct. Full pattern in `references/examples.md`. -```go -type UpdateArticleResult struct { - Article Article - TitleErr error - BodyErr error -} -``` - -```go -func (s Server) UpdateArticle(ctx context.Context, id int, form UpdateArticleForm) (UpdateArticleResult, error) { - var result UpdateArticleResult - if form.Title == "" { - result.TitleErr = fmt.Errorf("title is required") - } - if len(form.Body) < 10 { - result.BodyErr = fmt.Errorf("body must be at least 10 characters") - } - if result.TitleErr != nil || result.BodyErr != nil { - return result, nil // no top-level error — field errors drive the template - } - article, err := s.db.UpdateArticle(ctx, id, form.Title, form.Body) - result.Article = article - return result, err -} -``` - -The template reads submitted values from the request and displays per-field errors: - -```gotmpl -{{define "PATCH /article/{id} UpdateArticle(ctx, id, form)"}} -{{if .Err}} -

{{.Err.Error}}

-{{else}} -
-
- - - {{if .Result.TitleErr}} - - {{end}} -
- -
- - - {{if .Result.BodyErr}} - - {{end}} -
- - -
-{{end}} -{{end}} -``` +For per-field inline validation (validate as the user types), see [HTMX Inline Validation](../muxt_htmx/SKILL.md#inline-field-validation) — requires HTMX and a dedicated validation endpoint per field. -The result type carries per-field errors while `.Request.FormValue` preserves the submitted values — no need to echo form data back through the result struct. +## Testing -For standard HTML forms, rely on the supported HTML5 validation attributes for client-side validation. These provide immediate browser feedback without extra endpoints. Muxt generates matching server-side checks for the supported subset — write tests to verify the behavior you depend on, especially for attributes muxt does not enforce. +Write tests for every validation constraint you rely on, especially attributes muxt does not enforce server-side. Test both valid and boundary-violation submissions. See `references/examples.md` for complete patterns: valid submissions, min/max boundary, pattern, slice fields, per-field error display. -For per-field inline validation (validating individual fields as the user types or tabs away), see [HTMX Inline Validation](../muxt_htmx/SKILL.md#inline-field-validation) — that pattern requires HTMX and a dedicated validation endpoint per field. +## File uploads (multipart) -## Testing Forms +Use the `multipart` parameter (mutually exclusive with `form`) when the request is `multipart/form-data` — required for ``. Set `enctype="multipart/form-data"` on the form. Use `*multipart.FileHeader` for single files, `[]*multipart.FileHeader` for multiple, `*multipart.Form` for raw access. Default max size 32 MiB; override with `--output-multipart-max-memory=`. Full struct/template/method/error-handling patterns in `references/examples.md`. -Write tests for every validation constraint you rely on. Muxt only supports a subset of HTML5 validation attributes server-side — unsupported attributes like `required` pass silently. Test both valid and invalid submissions to confirm the generated handler behaves as expected. +## Reference files -### Valid Submission +- `references/examples.md` — full HTML/Go examples for struct design, accessible HTML, slice fields, re-render on error, every test pattern (valid, boundaries, pattern, slice, per-field errors), and multipart uploads. -```go -{ - Name: "creating an article with valid data", - Given: func(t *testing.T, g Given) { - g.app.CreateArticleReturns(Article{Title: "New Post"}, nil) - }, - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{ - "Title": []string{"New Post"}, - "Body": []string{"Content"}, - "Count": []string{"500"}, - } - req := httptest.NewRequest(http.MethodPost, "/article", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, then Then, res *http.Response) { - assert.Equal(t, http.StatusCreated, res.StatusCode) - require.Equal(t, 1, then.app.CreateArticleCallCount()) - _, form := then.app.CreateArticleArgsForCall(0) - assert.Equal(t, "New Post", form.Title) - assert.Equal(t, "Content", form.Body) - assert.Equal(t, 500, form.Count) - }, -}, -``` +## External reference -### Validation Failures (min/max) +- [Call Parameters](../../reference/call-parameters.md), [Call Results](../../reference/call-results.md), [Template Name Syntax](../../reference/template-names.md) +- [Template-Driven Development](../muxt_test-driven-development/SKILL.md) — TDD workflow for all route types. -Generated validation returns HTTP 400 Bad Request. Test both boundary and out-of-range values: +### Test cases (`cmd/muxt/testdata/`) -```go -{ - Name: "count below min returns 400", - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{ - "Title": []string{"Post"}, - "Body": []string{"Content"}, - "Count": []string{"50"}, - } - req := httptest.NewRequest(http.MethodPost, "/article", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, then Then, res *http.Response) { - assert.Equal(t, http.StatusBadRequest, res.StatusCode) - assert.Equal(t, 0, then.app.CreateArticleCallCount()) - }, -}, -{ - Name: "count at min boundary succeeds", - Given: func(t *testing.T, g Given) { - g.app.CreateArticleReturns(Article{Title: "Post"}, nil) - }, - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{ - "Title": []string{"Post"}, - "Body": []string{"Content"}, - "Count": []string{"100"}, - } - req := httptest.NewRequest(http.MethodPost, "/article", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, then Then, res *http.Response) { - assert.Equal(t, http.StatusCreated, res.StatusCode) - require.Equal(t, 1, then.app.CreateArticleCallCount()) - }, -}, -``` +Form basics: `howto_form_basic.txt`, `howto_form_with_struct.txt`, `howto_form_with_field_tag.txt`, `howto_form_with_slice.txt`, `reference_form_field_types.txt`, `reference_form_with_html_min_attr.txt`, `reference_form_with_empty_struct.txt`. -### Validation Failures (pattern) +Validation: `reference_validation_min_max.txt`, `reference_validation_pattern.txt`. -```go -{ - Name: "code not matching pattern returns 400", - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{"Code": []string{"abc-1234"}} - req := httptest.NewRequest(http.MethodPost, "/submit", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, _ Then, res *http.Response) { - assert.Equal(t, http.StatusBadRequest, res.StatusCode) - }, -}, -{ - Name: "code matching pattern succeeds", - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{"Code": []string{"ABC-1234"}} - req := httptest.NewRequest(http.MethodPost, "/submit", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, _ Then, res *http.Response) { - assert.Equal(t, http.StatusOK, res.StatusCode) - }, -}, -``` - -### Validation Failures (minlength/maxlength) - -```go -{ - Name: "title too short returns 400", - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{"Title": []string{"ab"}} - req := httptest.NewRequest(http.MethodPost, "/article", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, _ Then, res *http.Response) { - assert.Equal(t, http.StatusBadRequest, res.StatusCode) - }, -}, -``` - -### Slice Fields - -Provide multiple values and assert they're all received: - -```go -{ - Name: "multiple categories submitted", - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{ - "Categories": []string{"tech", "science", "art"}, - "Ratings": []string{"4", "5"}, - } - req := httptest.NewRequest(http.MethodPost, "/filter", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, then Then, res *http.Response) { - assert.Equal(t, http.StatusOK, res.StatusCode) - require.Equal(t, 1, then.app.FilterCallCount()) - _, form := then.app.FilterArgsForCall(0) - assert.Equal(t, []string{"tech", "science", "art"}, form.Categories) - assert.Equal(t, []int{4, 5}, form.Ratings) - }, -}, -``` - -### Per-Field Validation Errors in Response - -When the handler returns per-field errors, assert that the re-rendered form shows error messages and preserves submitted values: - -```go -{ - Name: "validation errors shown per field", - Given: func(t *testing.T, g Given) { - g.app.UpdateArticleReturns(UpdateArticleResult{ - TitleErr: fmt.Errorf("title is required"), - }, nil) - }, - When: func(t *testing.T, _ When) *http.Request { - form := url.Values{ - "Title": []string{""}, - "Body": []string{"Some content here"}, - } - req := httptest.NewRequest(http.MethodPatch, "/article/1", - strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req - }, - Then: func(t *testing.T, _ Then, res *http.Response) { - assert.Equal(t, http.StatusOK, res.StatusCode) - doc := domtest.ParseResponseDocument(t, res) - - titleErr := doc.QuerySelector("#title-err[role=alert]") - require.NotNil(t, titleErr) - assert.Equal(t, "title is required", titleErr.TextContent()) - - titleInput := doc.QuerySelector("#title") - require.NotNil(t, titleInput) - assert.Equal(t, "true", titleInput.GetAttribute("aria-invalid")) - }, -}, -``` - -## File Uploads (Multipart Forms) - -Use the `multipart` parameter instead of `form` when the request is `multipart/form-data` — required for ``. `multipart` is a strict superset of `form`: it parses both text fields and file fields. The two parameters are **mutually exclusive** in the same call. - -### Form HTML - -Set `enctype="multipart/form-data"` on the form element. Use `` for files. - -```html -
- - - - - - - -
-``` - -### Struct Definition - -Import `mime/multipart` and use `*multipart.FileHeader` for single files, `[]*multipart.FileHeader` for multiple files under the same name. Text fields work the same as `form`. - -```go -import "mime/multipart" - -type UploadForm struct { - Title string `name:"title"` - Tags []string `name:"tag"` // multiple values allowed - Avatar *multipart.FileHeader `name:"avatar"` // single file - Photos []*multipart.FileHeader `name:"photos"` // multiple files -} -``` - -### Template and Method - -```gotmpl -{{define "POST /upload 201 Upload(ctx, multipart)"}}

{{.Result.Filename}}

{{end}} -``` - -```go -func (s Server) Upload(ctx context.Context, form UploadForm) (UploadResult, error) { - if form.Avatar == nil { - return UploadResult{}, errors.New("avatar is required") - } - f, err := form.Avatar.Open() - if err != nil { - return UploadResult{}, fmt.Errorf("opening upload: %w", err) - } - defer f.Close() - // ... store the file ... - return UploadResult{Filename: form.Avatar.Filename}, nil -} -``` - -The receiver method's parameter name is independent of the call-site `multipart` identifier — name it `form`, `upload`, or anything readable. - -### Raw `*multipart.Form` - -For full access to `Value` and `File` maps, accept `*multipart.Form` directly: - -```go -func (s Server) Upload(ctx context.Context, form *multipart.Form) (Result, error) { - for name, headers := range form.File { ... } - return Result{}, nil -} -``` - -### Max upload size - -Defaults to 32 MiB. Override globally with `--output-multipart-max-memory=` (e.g. `64MB`, `128MiB`, `1GB`). Per `mime/multipart` semantics, data exceeding `maxMemory` spills to the OS temp directory — handlers see file headers regardless of size, but `Open()` may return a temp-file reader. - -### Error handling - -Malformed multipart bodies (truncated, bad boundary) are captured into `td.errList` with `td.errStatusCode = http.StatusBadRequest`. The receiver method is still called — guard nil-checks on file fields if you don't trust the request: - -```go -if form.Avatar != nil { - // ... process the file ... -} -``` +Errors: `err_form_unsupported_field_type.txt`, `err_form_unsupported_composite.txt`, `err_form_bool_return.txt`, `err_form_unsupported_return.txt`, `err_form_with_undefined_method.txt`. -## Reference - -- [Call Parameters](../../reference/call-parameters.md) -- [Call Results](../../reference/call-results.md) -- [Template Name Syntax](../../reference/template-names.md) -- [Template-Driven Development](../muxt_test-driven-development/SKILL.md) — TDD workflow for all route types - -### Test Cases (`cmd/muxt/testdata/`) - -| Feature | Test File | -|---------|-----------| -| Basic form binding | `howto_form_basic.txt` | -| Form with struct | `howto_form_with_struct.txt` | -| Form field name tags | `howto_form_with_field_tag.txt` | -| Slice fields | `howto_form_with_slice.txt` | -| Field type parsing | `reference_form_field_types.txt` | -| Validation: min/max | `reference_validation_min_max.txt` | -| Validation: pattern | `reference_validation_pattern.txt` | -| HTML min attribute | `reference_form_with_html_min_attr.txt` | -| Empty struct form | `reference_form_with_empty_struct.txt` | -| Unsupported field type error | `err_form_unsupported_field_type.txt` | -| Unsupported composite type | `err_form_unsupported_composite.txt` | -| Bool return with form | `err_form_bool_return.txt` | -| Unsupported return with form | `err_form_unsupported_return.txt` | -| Undefined form method | `err_form_with_undefined_method.txt` | -| Multipart with single file | `reference_multipart_basic.txt` | -| Multipart with `[]*FileHeader` | `reference_multipart_multiple_files.txt` | -| Multipart with text + files | `reference_multipart_mixed.txt` | -| Multipart raw `*multipart.Form` | `reference_multipart_raw.txt` | -| Multipart `name` tag rebind | `reference_multipart_with_name_tag.txt` | -| `--output-multipart-max-memory` | `reference_multipart_max_memory_flag.txt` | -| Multipart parse error → 400 | `reference_multipart_parse_error.txt` | -| File upload how-to | `howto_multipart_file_upload.txt` | -| `form` + `multipart` rejected | `err_multipart_with_form.txt` | +Multipart: `reference_multipart_basic.txt`, `reference_multipart_multiple_files.txt`, `reference_multipart_mixed.txt`, `reference_multipart_raw.txt`, `reference_multipart_with_name_tag.txt`, `reference_multipart_max_memory_flag.txt`, `reference_multipart_parse_error.txt`, `howto_multipart_file_upload.txt`, `err_multipart_with_form.txt`. diff --git a/docs/skills/muxt_forms/references/examples.md b/docs/skills/muxt_forms/references/examples.md new file mode 100644 index 0000000..c6461ba --- /dev/null +++ b/docs/skills/muxt_forms/references/examples.md @@ -0,0 +1,363 @@ +# muxt forms: full examples + +## Form struct design + +Field names map to HTML `name` attributes using the exact Go field name (case-preserved). Use the `name` tag to override. + +```go +type CreateArticleForm struct { + Title string + Body string + Count int + Publish bool +} + +type ContactForm struct { + FullName string `name:"full-name"` + Email string `name:"email-address"` +} +``` + +The `template` struct tag tells muxt which template block contains the `` for a field. Muxt scans that template for validation attributes (`min`, `max`, `pattern`, etc.) and generates server-side checks: + +```go +type CreateArticleForm struct { + Title string `template:"create-form"` + Count int `template:"create-form"` +} +``` + +Slice fields accept multiple values from checkboxes or multi-selects: + +```go +type FilterForm struct { + Categories []string + Ratings []int +} +``` + +## Accessible form HTML + +```gotmpl +{{define "POST /article 201 CreateArticle(ctx, form)"}} +{{if .Err}} +
+

{{.Err.Error}}

+
+{{else}} +

Article "{{.Result.Title}}" created.

+{{end}} +{{end}} +``` + +```gotmpl +
+
+ + +

A short, descriptive title for the article.

+
+ +
+ + +

The full article content. Markdown is supported.

+
+ +
+ + +

Target word count (100 to 10,000).

+
+ +
+
+ Publish immediately? + + + + +
+
+ + +
+``` + +### Checkbox groups (slice fields) + +```gotmpl +
+ Categories +
+ + +
+
+ + +
+
+``` + +## Re-rendering after validation errors + +Return per-field errors and use `.Request.FormValue` to repopulate inputs: + +```go +type UpdateArticleResult struct { + Article Article + TitleErr error + BodyErr error +} + +func (s Server) UpdateArticle(ctx context.Context, id int, form UpdateArticleForm) (UpdateArticleResult, error) { + var result UpdateArticleResult + if form.Title == "" { + result.TitleErr = fmt.Errorf("title is required") + } + if len(form.Body) < 10 { + result.BodyErr = fmt.Errorf("body must be at least 10 characters") + } + if result.TitleErr != nil || result.BodyErr != nil { + return result, nil // no top-level error — field errors drive the template + } + article, err := s.db.UpdateArticle(ctx, id, form.Title, form.Body) + result.Article = article + return result, err +} +``` + +```gotmpl +{{define "PATCH /article/{id} UpdateArticle(ctx, id, form)"}} +{{if .Err}} +

{{.Err.Error}}

+{{else}} +
+
+ + + {{if .Result.TitleErr}} + + {{end}} +
+
+ + + {{if .Result.BodyErr}} + + {{end}} +
+ +
+{{end}} +{{end}} +``` + +The result type carries per-field errors; `.Request.FormValue` preserves the submitted values — no need to echo form data back through the result struct. + +For per-field inline validation (validating individual fields as the user types), see [HTMX Inline Validation](../../muxt_htmx/SKILL.md#inline-field-validation). + +## Testing forms — examples per pattern + +### Valid submission + +```go +{ + Name: "creating an article with valid data", + Given: func(t *testing.T, g Given) { + g.app.CreateArticleReturns(Article{Title: "New Post"}, nil) + }, + When: func(t *testing.T, _ When) *http.Request { + form := url.Values{ + "Title": []string{"New Post"}, + "Body": []string{"Content"}, + "Count": []string{"500"}, + } + req := httptest.NewRequest(http.MethodPost, "/article", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + Then: func(t *testing.T, then Then, res *http.Response) { + assert.Equal(t, http.StatusCreated, res.StatusCode) + require.Equal(t, 1, then.app.CreateArticleCallCount()) + _, form := then.app.CreateArticleArgsForCall(0) + assert.Equal(t, "New Post", form.Title) + }, +}, +``` + +### min/max boundary + +```go +{ + Name: "count below min returns 400", + When: func(t *testing.T, _ When) *http.Request { + form := url.Values{"Title": []string{"Post"}, "Body": []string{"Content"}, "Count": []string{"50"}} + req := httptest.NewRequest(http.MethodPost, "/article", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + Then: func(t *testing.T, then Then, res *http.Response) { + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + assert.Equal(t, 0, then.app.CreateArticleCallCount()) + }, +}, +``` + +Test both boundary (passes) and out-of-range (fails) values. + +### pattern, minlength + +```go +{ + Name: "code not matching pattern returns 400", + When: func(t *testing.T, _ When) *http.Request { + form := url.Values{"Code": []string{"abc-1234"}} + req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + Then: func(t *testing.T, _ Then, res *http.Response) { + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + }, +}, +``` + +### Slice fields + +```go +{ + Name: "multiple categories submitted", + When: func(t *testing.T, _ When) *http.Request { + form := url.Values{ + "Categories": []string{"tech", "science", "art"}, + "Ratings": []string{"4", "5"}, + } + req := httptest.NewRequest(http.MethodPost, "/filter", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + Then: func(t *testing.T, then Then, res *http.Response) { + require.Equal(t, 1, then.app.FilterCallCount()) + _, form := then.app.FilterArgsForCall(0) + assert.Equal(t, []string{"tech", "science", "art"}, form.Categories) + assert.Equal(t, []int{4, 5}, form.Ratings) + }, +}, +``` + +### Per-field validation errors visible in response + +```go +{ + Name: "validation errors shown per field", + Given: func(t *testing.T, g Given) { + g.app.UpdateArticleReturns(UpdateArticleResult{ + TitleErr: fmt.Errorf("title is required"), + }, nil) + }, + When: func(t *testing.T, _ When) *http.Request { + form := url.Values{"Title": []string{""}, "Body": []string{"Some content here"}} + req := httptest.NewRequest(http.MethodPatch, "/article/1", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + Then: func(t *testing.T, _ Then, res *http.Response) { + assert.Equal(t, http.StatusOK, res.StatusCode) + doc := domtest.ParseResponseDocument(t, res) + + titleErr := doc.QuerySelector("#title-err[role=alert]") + require.NotNil(t, titleErr) + assert.Equal(t, "title is required", titleErr.TextContent()) + + titleInput := doc.QuerySelector("#title") + require.NotNil(t, titleInput) + assert.Equal(t, "true", titleInput.GetAttribute("aria-invalid")) + }, +}, +``` + +## File uploads (multipart) + +`multipart` parameter (mutually exclusive with `form`) is required for ``. Set `enctype="multipart/form-data"` on the form. + +### Form HTML + +```html +
+ + + + + +
+``` + +### Struct + +```go +import "mime/multipart" + +type UploadForm struct { + Title string `name:"title"` + Tags []string `name:"tag"` + Avatar *multipart.FileHeader `name:"avatar"` + Photos []*multipart.FileHeader `name:"photos"` +} +``` + +### Method and template + +```gotmpl +{{define "POST /upload 201 Upload(ctx, multipart)"}}

{{.Result.Filename}}

{{end}} +``` + +```go +func (s Server) Upload(ctx context.Context, form UploadForm) (UploadResult, error) { + if form.Avatar == nil { + return UploadResult{}, errors.New("avatar is required") + } + f, err := form.Avatar.Open() + if err != nil { + return UploadResult{}, fmt.Errorf("opening upload: %w", err) + } + defer f.Close() + // ... store the file ... + return UploadResult{Filename: form.Avatar.Filename}, nil +} +``` + +### Raw `*multipart.Form` + +For full access to `Value` and `File` maps: + +```go +func (s Server) Upload(ctx context.Context, form *multipart.Form) (Result, error) { + for name, headers := range form.File { ... } + return Result{}, nil +} +``` + +### Max upload size + +Defaults to 32 MiB. Override globally with `--output-multipart-max-memory=` (e.g. `64MB`, `128MiB`, `1GB`). Per `mime/multipart` semantics, data exceeding `maxMemory` spills to OS temp dir — handlers see file headers regardless of size, but `Open()` may return a temp-file reader. + +### Error handling + +Malformed multipart bodies (truncated, bad boundary) are captured into `td.errList` with `td.errStatusCode = http.StatusBadRequest`. The receiver method is still called — guard nil-checks on file fields: + +```go +if form.Avatar != nil { + // ... process the file ... +} +``` diff --git a/docs/skills/muxt_htmx/SKILL.md b/docs/skills/muxt_htmx/SKILL.md index 86bf3ea..44949c6 100644 --- a/docs/skills/muxt_htmx/SKILL.md +++ b/docs/skills/muxt_htmx/SKILL.md @@ -1,377 +1,88 @@ --- name: muxt-htmx -description: "Muxt: Use when exploring, developing, or testing HTMX interactions in a Muxt codebase. Covers finding hx-* attributes, using --output-htmx-helpers, testing fragment chains, and verifying inter-route coupling." +description: "Muxt: Use when exploring, developing, or testing HTMX interactions in a Muxt codebase. Covers finding hx-* attributes, using --output-htmx-helpers, testing fragment chains, and verifying inter-route coupling. Distinct from muxt_datastar (data-* attributes / Datastar SSE) and muxt_forms (standard HTML form submission)." --- # HTMX with Muxt -Explore and develop HTMX interactions in a Muxt codebase. Covers discovery, the generated HTMX helpers, and testing inter-route coupling. +Develop and test HTMX interactions in a Muxt codebase: discovery, generated helpers, fragment chains, inline validation. -## Exploring HTMX in a Codebase +## When to use this skill -### Find HTMX Interactions +- Templates contain `hx-*` attributes (`hx-get`, `hx-post`, `hx-swap`, `hx-target`, `hx-trigger`, `hx-confirm`, `hx-boost`). +- Building features with HTMX-driven partial swaps or inline validation. +- Writing fragment-chain tests for inter-route coupling. -Scan templates for HTMX attributes that trigger HTTP requests: +## Discovering HTMX in a codebase ```bash grep -rn 'hx-get\|hx-post\|hx-put\|hx-patch\|hx-delete' --include='*.gohtml' . +grep -rn 'hx-target\|hx-swap\|hx-select' --include='*.gohtml' . +grep -rn 'hx-trigger\|hx-confirm\|hx-boost' --include='*.gohtml' . ``` -Each match is a client-side interaction that targets a Muxt route. Note the associated attributes: +For each `hx-get="/some/path"`: +1. Find the route handling it: `muxt list-template-calls --match "/some/path"`. +2. Read that template — what fragment is returned? +3. Check the triggering element's `hx-target`/`hx-swap` for where the fragment lands. +4. Check if the route uses `.HXRequest` to branch fragment vs full page. -```bash -# Find swap targets — where does the response go? -grep -rn 'hx-target\|hx-swap\|hx-select' --include='*.gohtml' . - -# Find triggers — what starts the request? -grep -rn 'hx-trigger\|hx-confirm\|hx-boost' --include='*.gohtml' . -``` - -### Trace an Interaction - -For each `hx-get="/some/path"` found: - -1. Find the route template that handles `/some/path` using `muxt list-template-calls --match "/some/path"` -2. Read that template to see what HTML fragment it returns -3. Check the `hx-target` and `hx-swap` on the triggering element to see where the fragment lands -4. Check if the route uses `.HXRequest` to return different HTML for HTMX vs direct navigation - -See [HTMX attributes reference](https://htmx.org/reference/#attributes) for all available attributes. - -## HTMX Helpers (`--output-htmx-helpers`) - -Enable the flag to generate helper methods on `TemplateData`: - -```go -//go:generate muxt generate --use-receiver-type=Server --output-htmx-helpers -``` - -This generates two sets of methods: - -### Response Header Helpers (set in templates) - -Use these in templates to control HTMX client behavior: - -| Template Call | HTTP Header | HTMX Docs | -|---------------|-------------|-----------| -| `{{.HXLocation "/path"}}` | `HX-Location` | [hx-location](https://htmx.org/headers/hx-location/) | -| `{{.HXPushURL "/path"}}` | `HX-Push-Url` | [hx-push-url](https://htmx.org/attributes/hx-push-url/) | -| `{{.HXRedirect "/path"}}` | `HX-Redirect` | [hx-redirect](https://htmx.org/headers/hx-redirect/) | -| `{{.HXRefresh}}` | `HX-Refresh: true` | [response headers](https://htmx.org/reference/#response_headers) | -| `{{.HXReplaceURL "/path"}}` | `HX-Replace-Url` | [hx-replace-url](https://htmx.org/attributes/hx-replace-url/) | -| `{{.HXReswap "outerHTML"}}` | `HX-Reswap` | [hx-reswap](https://htmx.org/attributes/hx-swap/) | -| `{{.HXRetarget "#id"}}` | `HX-Retarget` | [response headers](https://htmx.org/reference/#response_headers) | -| `{{.HXReselect ".selector"}}` | `HX-Reselect` | [response headers](https://htmx.org/reference/#response_headers) | -| `{{.HXTrigger "event"}}` | `HX-Trigger` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | -| `{{.HXTriggerAfterSettle "event"}}` | `HX-Trigger-After-Settle` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | -| `{{.HXTriggerAfterSwap "event"}}` | `HX-Trigger-After-Swap` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | - -### Request Header Readers (check in templates) - -Use these to detect HTMX requests and branch template output: - -| Template Call | HTTP Header | HTMX Docs | -|---------------|-------------|-----------| -| `{{.HXRequest}}` | `HX-Request` | [hx-request](https://htmx.org/attributes/hx-request/) | -| `{{.HXBoosted}}` | `HX-Boosted` | [hx-boost](https://htmx.org/attributes/hx-boost/) | -| `{{.HXCurrentURL}}` | `HX-Current-URL` | [request headers](https://htmx.org/reference/#request_headers) | -| `{{.HXHistoryRestoreRequest}}` | `HX-History-Restore-Request` | [request headers](https://htmx.org/reference/#request_headers) | -| `{{.HXPrompt}}` | `HX-Prompt` | [hx-prompt](https://htmx.org/attributes/hx-prompt/) | -| `{{.HXTargetElementID}}` | `HX-Target` | [request headers](https://htmx.org/reference/#request_headers) | -| `{{.HXTriggerName}}` | `HX-Trigger-Name` | [request headers](https://htmx.org/reference/#request_headers) | -| `{{.HXTriggerElementID}}` | `HX-Trigger` | [request headers](https://htmx.org/reference/#request_headers) | - -### Progressive Enhancement Pattern - -Use `.HXRequest` to return fragments for HTMX and full pages for direct navigation: - -```gotmpl -{{define "GET /article/{id} GetArticle(ctx, id)"}} -{{if .HXRequest}} - {{template "article-content" .}} -{{else}} - - -{{.Result.Title}} -{{template "article-content" .}} - -{{end}} -{{end}} -``` - -See [HTMX server examples](https://htmx.org/examples/) for more interaction patterns. - -## Template Fragments - -HTMX endpoints frequently return partial HTML — a single row, a form field, a status badge — rather than full pages. Many templating languages need special fragment syntax to render part of a template. Go's `html/template` already has this built in: every `{{define "name"}}...{{end}}` block is a fragment that can be rendered independently with `{{template "name" .}}`. - -This means muxt route templates are themselves fragments. A route template returns its `{{define}}` block as the response body. Sub-templates composed with `{{template}}` are also fragments. No special syntax or library support is needed — Go's template system provides [locality of behavior](https://htmx.org/essays/locality-of-behaviour/) for free. - -The progressive enhancement pattern above shows this in action: the route template branches on `.HXRequest` to return either the fragment (`{{template "article-content" .}}`) or a full page wrapping it. - -For background on why template fragments matter for hypermedia-driven applications, see [Template Fragments](https://htmx.org/essays/template-fragments/). - -## Testing HTMX Fragment Chains - -HTMX interactions form chains: a page contains an `hx-get` attribute pointing to another route, whose response swaps into a target element. These chains couple routes together. Tests should verify the coupling stays consistent. - -**Do not use Given/When/Then table-driven tests for fragment chains.** Instead, write a single test function with helpers that exercise a sequence of requests and assert that the inter-route coupling is correct. - -### Structure - -```go -func TestArticleWorkflow(t *testing.T) { - app := new(fake.App) - mux := http.NewServeMux() - TemplateRoutes(mux, app) - var p TemplateRoutePaths - - // Load the article list page - listReq := httptest.NewRequest("GET", p.ListArticles(), nil) - listRec := httptest.NewRecorder() - mux.ServeHTTP(listRec, listReq) - assert.Equal(t, http.StatusOK, listRec.Code) - listPage := domtest.ParseResponseDocument(t, listRec.Result()) - - // Verify HTMX attributes couple correctly to routes - editBtn := listPage.QuerySelector("[hx-get]") - require.NotNil(t, editBtn) - assert.Equal(t, p.EditArticle(1), editBtn.GetAttribute("hx-get")) - assert.Equal(t, "#article-1", editBtn.GetAttribute("hx-target")) +See [HTMX attributes reference](https://htmx.org/reference/#attributes). - // Follow the hx-get to the edit form fragment - editReq := httptest.NewRequest("GET", p.EditArticle(1), nil) - editReq.Header.Set("HX-Request", "true") - editRec := httptest.NewRecorder() - mux.ServeHTTP(editRec, editReq) - assert.Equal(t, http.StatusOK, editRec.Code) - editFragment := domtest.ParseResponseDocumentFragment(t, editRec.Result(), atom.Div) +## Generated helpers (`--output-htmx-helpers`) - // Verify the fragment contains the target element from hx-target="#article-1" - targetEl := editFragment.QuerySelector("#article-1") - require.NotNil(t, targetEl, "fragment must contain element matching hx-target") - titleInput := editFragment.QuerySelector("input[name=title]") - require.NotNil(t, titleInput) +Enable the flag to expose helper methods on `TemplateData`: - // Submit the edit form - updateForm := url.Values{"title": []string{"Updated Title"}} - updateReq := httptest.NewRequest("PUT", p.UpdateArticle(1), strings.NewReader(updateForm.Encode())) - updateReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - updateReq.Header.Set("HX-Request", "true") - updateRec := httptest.NewRecorder() - mux.ServeHTTP(updateRec, updateReq) - assert.Equal(t, http.StatusOK, updateRec.Code) -} -``` - -### Exhaustive Coupling Assertions - -In large codebases, assert that all HTMX attributes in a response are accounted for. This catches new `hx-get` or `hx-post` attributes that lack test coverage: - -```go -func TestAllHTMXEndpointsAreTested(t *testing.T) { - app := new(fake.App) - mux := http.NewServeMux() - p := TemplateRoutes(mux, app) - - // Render the full page - homeReq := httptest.NewRequest("GET", p.Home(), nil) - homeRec := httptest.NewRecorder() - mux.ServeHTTP(homeRec, homeReq) - homePage := domtest.ParseResponseDocument(t, homeRec.Result()) - - // Map of CSS selector → allowed attribute values. - // The key is the full selector so you can be specific (e.g. "form[hx-post]"). - for selector, expectedValues := range map[string][]string{ - "[hx-get]": {p.EditArticle(1), p.EditArticle(2)}, - "[hx-post]": {p.CreateArticle()}, - "form[hx-put]": {p.UpdateArticle(1), p.UpdateArticle(2)}, - "[hx-delete]": {p.DeleteArticle(1), p.DeleteArticle(2)}, - } { - // Extract the hx-* attribute name from the selector - attr := selector[strings.LastIndex(selector, "hx-"):] - attr = attr[:strings.IndexByte(attr, ']')] - - var found []string - for _, el := range homePage.QuerySelectorAll(selector) { - found = append(found, el.GetAttribute(attr)) - } - assert.ElementsMatch(t, expectedValues, found, - "selector %q: unexpected attribute values", selector) - } -} -``` - -When a new `hx-get="/new/route"` appears in a template, this test fails — forcing the developer to add it to the allowed map and write a corresponding fragment chain test. - -### Testing Response Headers - -When templates set HTMX response headers via helpers like `.HXRetarget` or `.HXTrigger`, assert those headers in the chain test: - -```go - // Follow a POST that triggers a client-side event - req := httptest.NewRequest("POST", "/task/1/complete", nil) - req.Header.Set("HX-Request", "true") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - assert.Equal(t, "taskCompleted", rec.Header().Get("HX-Trigger")) - assert.Equal(t, "#task-list", rec.Header().Get("HX-Retarget")) -``` - -## Testing HTMX Islands with chromedp - -For dynamic [islands](https://htmx.org/essays/hypermedia-friendly-scripting/#islands) that load content via HTMX after the initial page render, `domtest` alone cannot verify the behavior because it doesn't execute JavaScript. Use [chromedp](https://github.com/chromedp/chromedp) to test these interactions in a real browser. - -chromedp tests are slow and require a browser. Skip them in short mode or gate them behind a build tag so `go test` stays fast by default: - -```go -//go:build chromedp +- **Response header helpers** — `{{.HXRedirect "/path"}}`, `{{.HXTrigger "event"}}`, `{{.HXRetarget "#id"}}`, etc. Set HTMX-specific response headers from templates. +- **Request header readers** — `{{.HXRequest}}`, `{{.HXBoosted}}`, `{{.HXPrompt}}`, etc. Branch template output based on what the client sent. -func TestDashboardIsland(t *testing.T) { - if testing.Short() { - t.Skip("skipping chromedp test in short mode") - } +Full helper tables and the progressive-enhancement pattern (`.HXRequest` to return fragments for HTMX, full pages for direct nav): see `references/examples.md`. - mux := http.NewServeMux() - TemplateRoutes(mux, srv) - ts := httptest.NewServer(mux) - defer ts.Close() +## Template fragments — locality of behaviour - ctx, cancel := chromedp.NewContext(context.Background()) - defer cancel() +Go's `html/template` provides fragments natively. Every `{{define}}...{{end}}` is a fragment renderable with `{{template "name" .}}`. Muxt route templates are themselves fragments. No special syntax is needed — see [Template Fragments essay](https://htmx.org/essays/template-fragments/). - var statsText string - err := chromedp.Run(ctx, - // Navigate to the full page — this loads htmx.js - chromedp.Navigate(ts.URL+"/dashboard"), - // Wait for the island content to be swapped in by HTMX - chromedp.WaitVisible("#stats-island", chromedp.ByQuery), - chromedp.Text("#stats-island", &statsText, chromedp.ByQuery), - ) - require.NoError(t, err) - assert.Contains(t, statsText, "Total Articles") -} -``` +## Testing fragment chains -If the island content comes from a fragment endpoint (one that returns different HTML for `HX-Request` vs direct navigation), the test must navigate to the parent page that triggers the `hx-get`, not to the fragment URL directly. The parent page loads htmx.js which then fires the request. +HTMX interactions form chains: page A's `hx-get` points at route B, whose response swaps into a target element on A. Tests must verify the inter-route coupling stays consistent. -Use chromedp for targeted island tests only. Prefer `domtest` for everything else — it's faster and doesn't require a browser. +**Do NOT use Given/When/Then table-driven tests for fragment chains.** Use a single test that exercises the request sequence and asserts the chain (page → fragment → submit → response). Full pattern with `domtest.ParseResponseDocumentFragment` and `HX-Request` headers in `references/examples.md`. -## Inline Field Validation +For exhaustive coverage, also assert that **all** `hx-*` attributes in the rendered page are accounted for in tests — see the `TestAllHTMXEndpointsAreTested` pattern in `references/examples.md`. New `hx-get` in a template fails this test, forcing a corresponding fragment chain test. -HTMX enables per-field validation by sending individual field values to the server as the user interacts with the form. Each field posts to a validation endpoint that returns the field container with error feedback. +When templates set HTMX response headers, assert them via `rec.Header().Get("HX-Trigger")`, etc. -### Pattern +## Islands with chromedp -Add `hx-post` to an input's wrapping `
`. Use `hx-target="this"` and `hx-swap="outerHTML"` so the server response replaces the entire field container: +For HTMX-loaded islands (content swapped after initial render), `domtest` cannot verify (no JS execution). Use chromedp behind a build tag (`//go:build chromedp`) so `go test` stays fast by default. Navigate to the *parent* page (which loads htmx.js), not the fragment URL directly. Full example in `references/examples.md`. -```gotmpl -{{define "GET /contact Contact(ctx)"}} -
-
- - -

-
- -
-{{end}} -``` +## Inline field validation -The validation endpoint receives the request, checks the field, and returns the same container with error or success styling: - -```gotmpl -{{define "POST /contact/email ValidateEmail(request)"}} -
- - - {{with .Err}} - - {{else}} -

- {{end}} -
-{{end}} -``` +Per-field validation as the user types/blurs. `hx-post` on an input's wrapping `
`, `hx-target="this"`, `hx-swap="outerHTML"`. The validation endpoint returns the same container with error or success styling. Default trigger is `change`; use `hx-trigger="blur"` or `keyup delay:500ms`. Full pattern, template + handler examples in `references/examples.md`. -The handler validates and returns an error or nil: - -```go -func (s Server) ValidateEmail(r *http.Request) error { - email := r.FormValue("email") - if !isValidEmail(email) { - return fmt.Errorf("invalid email address") - } - if s.db.EmailExists(email) { - return fmt.Errorf("that email is already taken") - } - return nil -} -``` +For standard form validation without HTMX, see [`muxt_forms`](../muxt_forms/SKILL.md#re-rendering-after-validation-errors). -By default, HTMX triggers on `change` for inputs. Add `hx-trigger` for different timing: - -```html - - - - - -``` - -This is an HTMX-specific pattern. For standard HTML forms without HTMX, rely on HTML5 validation attributes (`required`, `pattern`, `min`/`max`) for client-side feedback, and handle validation errors on the full form submission. See [Forms](../muxt_forms/SKILL.md#re-rendering-after-validation-errors). - -See [HTMX inline validation example](https://htmx.org/examples/inline-validation/). - -## HTTP Status Codes and HTMX - -By default, HTMX only swaps content for 2xx responses. Non-2xx responses are ignored unless you use the [response-targets extension](https://htmx.org/extensions/response-targets/), which lets you target different elements based on status code: - -```html -
- -
-``` +## Status codes and HTMX -Without this extension, setting `StatusCode()` on error types (returning 404, 500, etc.) won't cause HTMX to display the error template — the response is silently dropped. If your handlers return non-2xx status codes for error states, either: +HTMX swaps only on 2xx by default. Non-2xx silent unless you use the [response-targets extension](https://htmx.org/extensions/response-targets/) (`hx-target-404`, `hx-target-5*`). Alternatives: return 200 with error content in body, or use `HX-Reswap`/`HX-Retarget` headers. See `references/examples.md` for the pattern. -1. Use the `response-targets` extension with `hx-target-*` attributes to handle error responses -2. Return 200 with error content in the body (simpler, works without extensions) -3. Use `HX-Reswap` and `HX-Retarget` response headers to redirect error content to a different element +## Reference files -See [HTMX response-targets extension](https://htmx.org/extensions/response-targets/) for the full API. +- `references/examples.md` — full helper tables, progressive enhancement, fragment-chain test, exhaustive coupling test, response-header testing, chromedp islands, inline validation pattern, status-code/extension setup. -## Reference +## External reference -- [`muxt generate` flags](../../reference/commands/generate.md) — `--output-htmx-helpers` flag -- [HTMX Example](../../examples/counter-htmx/) — Counter app with HTMX helpers +- [`muxt generate` flags](../../reference/commands/generate.md) — `--output-htmx-helpers` +- [HTMX example](../../examples/counter-htmx/) — counter app with helpers - [Template Name Syntax](../../reference/template-names.md) -- [chromedp](https://github.com/chromedp/chromedp) — Headless Chrome for island testing +- [chromedp](https://github.com/chromedp/chromedp) — headless Chrome for islands -### Examples and Test Cases +### Test cases (`cmd/muxt/testdata/`) -- `docs/examples/counter-htmx/` — Complete counter app demonstrating `--output-htmx-helpers` -- `cmd/muxt/testdata/reference_htmx_helpers.txt` — `--output-htmx-helpers` flag, HX-Request detection, response header assertions -- `cmd/muxt/testdata/howto_form_basic.txt` — Form binding (relevant to HTMX POST patterns) -- `cmd/muxt/testdata/reference_status_codes.txt` — Status codes (relevant to HTMX response handling) +- `reference_htmx_helpers.txt` — `--output-htmx-helpers`, HX-Request detection, response-header assertions +- `howto_form_basic.txt` — form binding (relevant to HTMX POST patterns) +- `reference_status_codes.txt` — relevant to HTMX response handling -### HTMX Official Documentation +### HTMX docs -- [HTMX Documentation](https://htmx.org/docs/) — Core concepts -- [HTMX Attributes Reference](https://htmx.org/reference/#attributes) — All `hx-*` attributes -- [HTMX Request Headers](https://htmx.org/reference/#request_headers) — Headers sent by HTMX -- [HTMX Response Headers](https://htmx.org/reference/#response_headers) — Headers the server can set -- [HTMX Examples](https://htmx.org/examples/) — Common interaction patterns -- [HTMX Essays](https://htmx.org/essays/) — Design philosophy +[Documentation](https://htmx.org/docs/) · [Attributes](https://htmx.org/reference/#attributes) · [Request headers](https://htmx.org/reference/#request_headers) · [Response headers](https://htmx.org/reference/#response_headers) · [Examples](https://htmx.org/examples/) · [Essays](https://htmx.org/essays/) diff --git a/docs/skills/muxt_htmx/references/examples.md b/docs/skills/muxt_htmx/references/examples.md new file mode 100644 index 0000000..94108ed --- /dev/null +++ b/docs/skills/muxt_htmx/references/examples.md @@ -0,0 +1,278 @@ +# muxt + HTMX: examples + +## HTMX helpers (`--output-htmx-helpers`) + +Enable to generate helper methods on `TemplateData`: + +```go +//go:generate muxt generate --use-receiver-type=Server --output-htmx-helpers +``` + +### Response header helpers (set in templates) + +| Template call | HTTP header | HTMX docs | +|---------------|-------------|-----------| +| `{{.HXLocation "/path"}}` | `HX-Location` | [hx-location](https://htmx.org/headers/hx-location/) | +| `{{.HXPushURL "/path"}}` | `HX-Push-Url` | [hx-push-url](https://htmx.org/attributes/hx-push-url/) | +| `{{.HXRedirect "/path"}}` | `HX-Redirect` | [hx-redirect](https://htmx.org/headers/hx-redirect/) | +| `{{.HXRefresh}}` | `HX-Refresh: true` | [response headers](https://htmx.org/reference/#response_headers) | +| `{{.HXReplaceURL "/path"}}` | `HX-Replace-Url` | [hx-replace-url](https://htmx.org/attributes/hx-replace-url/) | +| `{{.HXReswap "outerHTML"}}` | `HX-Reswap` | [hx-reswap](https://htmx.org/attributes/hx-swap/) | +| `{{.HXRetarget "#id"}}` | `HX-Retarget` | [response headers](https://htmx.org/reference/#response_headers) | +| `{{.HXReselect ".sel"}}` | `HX-Reselect` | [response headers](https://htmx.org/reference/#response_headers) | +| `{{.HXTrigger "event"}}` | `HX-Trigger` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | +| `{{.HXTriggerAfterSettle "event"}}` | `HX-Trigger-After-Settle` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | +| `{{.HXTriggerAfterSwap "event"}}` | `HX-Trigger-After-Swap` | [hx-trigger](https://htmx.org/headers/hx-trigger/) | + +### Request header readers (check in templates) + +| Template call | HTTP header | HTMX docs | +|---------------|-------------|-----------| +| `{{.HXRequest}}` | `HX-Request` | [hx-request](https://htmx.org/attributes/hx-request/) | +| `{{.HXBoosted}}` | `HX-Boosted` | [hx-boost](https://htmx.org/attributes/hx-boost/) | +| `{{.HXCurrentURL}}` | `HX-Current-URL` | [request headers](https://htmx.org/reference/#request_headers) | +| `{{.HXHistoryRestoreRequest}}` | `HX-History-Restore-Request` | [request headers](https://htmx.org/reference/#request_headers) | +| `{{.HXPrompt}}` | `HX-Prompt` | [hx-prompt](https://htmx.org/attributes/hx-prompt/) | +| `{{.HXTargetElementID}}` | `HX-Target` | [request headers](https://htmx.org/reference/#request_headers) | +| `{{.HXTriggerName}}` | `HX-Trigger-Name` | [request headers](https://htmx.org/reference/#request_headers) | +| `{{.HXTriggerElementID}}` | `HX-Trigger` | [request headers](https://htmx.org/reference/#request_headers) | + +## Progressive enhancement + +Use `.HXRequest` to return fragments for HTMX and full pages for direct nav: + +```gotmpl +{{define "GET /article/{id} GetArticle(ctx, id)"}} +{{if .HXRequest}} + {{template "article-content" .}} +{{else}} + + +{{.Result.Title}} +{{template "article-content" .}} + +{{end}} +{{end}} +``` + +## Template fragments — locality of behaviour + +HTMX endpoints frequently return partial HTML. Go's `html/template` already provides this — every `{{define "name"}}...{{end}}` is a fragment renderable with `{{template "name" .}}`. No special syntax needed. + +Muxt route templates are themselves fragments. Sub-templates composed with `{{template}}` are also fragments. See [Template Fragments](https://htmx.org/essays/template-fragments/) for the broader argument. + +## Testing fragment chains + +HTMX interactions form chains: a page contains an `hx-get` attribute pointing to another route, whose response swaps into a target element. Tests verify the inter-route coupling. + +**Do not use Given/When/Then table-driven tests for fragment chains.** Use a single test function that exercises a sequence of requests: + +```go +func TestArticleWorkflow(t *testing.T) { + app := new(fake.App) + mux := http.NewServeMux() + TemplateRoutes(mux, app) + var p TemplateRoutePaths + + // Load the article list page + listReq := httptest.NewRequest("GET", p.ListArticles(), nil) + listRec := httptest.NewRecorder() + mux.ServeHTTP(listRec, listReq) + assert.Equal(t, http.StatusOK, listRec.Code) + listPage := domtest.ParseResponseDocument(t, listRec.Result()) + + // Verify HTMX attributes couple correctly to routes + editBtn := listPage.QuerySelector("[hx-get]") + require.NotNil(t, editBtn) + assert.Equal(t, p.EditArticle(1), editBtn.GetAttribute("hx-get")) + assert.Equal(t, "#article-1", editBtn.GetAttribute("hx-target")) + + // Follow the hx-get to the edit form fragment + editReq := httptest.NewRequest("GET", p.EditArticle(1), nil) + editReq.Header.Set("HX-Request", "true") + editRec := httptest.NewRecorder() + mux.ServeHTTP(editRec, editReq) + assert.Equal(t, http.StatusOK, editRec.Code) + editFragment := domtest.ParseResponseDocumentFragment(t, editRec.Result(), atom.Div) + + // Verify the fragment contains the target element from hx-target="#article-1" + targetEl := editFragment.QuerySelector("#article-1") + require.NotNil(t, targetEl, "fragment must contain element matching hx-target") + titleInput := editFragment.QuerySelector("input[name=title]") + require.NotNil(t, titleInput) + + // Submit the edit form + updateForm := url.Values{"title": []string{"Updated Title"}} + updateReq := httptest.NewRequest("PUT", p.UpdateArticle(1), strings.NewReader(updateForm.Encode())) + updateReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + updateReq.Header.Set("HX-Request", "true") + updateRec := httptest.NewRecorder() + mux.ServeHTTP(updateRec, updateReq) + assert.Equal(t, http.StatusOK, updateRec.Code) +} +``` + +## Exhaustive coupling assertions + +Catch new `hx-get` / `hx-post` that lack test coverage: + +```go +func TestAllHTMXEndpointsAreTested(t *testing.T) { + app := new(fake.App) + mux := http.NewServeMux() + p := TemplateRoutes(mux, app) + + homeReq := httptest.NewRequest("GET", p.Home(), nil) + homeRec := httptest.NewRecorder() + mux.ServeHTTP(homeRec, homeReq) + homePage := domtest.ParseResponseDocument(t, homeRec.Result()) + + for selector, expectedValues := range map[string][]string{ + "[hx-get]": {p.EditArticle(1), p.EditArticle(2)}, + "[hx-post]": {p.CreateArticle()}, + "form[hx-put]": {p.UpdateArticle(1), p.UpdateArticle(2)}, + "[hx-delete]": {p.DeleteArticle(1), p.DeleteArticle(2)}, + } { + attr := selector[strings.LastIndex(selector, "hx-"):] + attr = attr[:strings.IndexByte(attr, ']')] + + var found []string + for _, el := range homePage.QuerySelectorAll(selector) { + found = append(found, el.GetAttribute(attr)) + } + assert.ElementsMatch(t, expectedValues, found, + "selector %q: unexpected attribute values", selector) + } +} +``` + +A new `hx-get="/new/route"` in a template fails this test, forcing a corresponding fragment chain test. + +## Testing response headers + +```go +req := httptest.NewRequest("POST", "/task/1/complete", nil) +req.Header.Set("HX-Request", "true") +rec := httptest.NewRecorder() +mux.ServeHTTP(rec, req) + +assert.Equal(t, "taskCompleted", rec.Header().Get("HX-Trigger")) +assert.Equal(t, "#task-list", rec.Header().Get("HX-Retarget")) +``` + +## Islands with chromedp + +For islands that load via HTMX after initial render, `domtest` can't verify (no JS). Use chromedp: + +```go +//go:build chromedp + +func TestDashboardIsland(t *testing.T) { + if testing.Short() { + t.Skip("skipping chromedp test in short mode") + } + + mux := http.NewServeMux() + TemplateRoutes(mux, srv) + ts := httptest.NewServer(mux) + defer ts.Close() + + ctx, cancel := chromedp.NewContext(context.Background()) + defer cancel() + + var statsText string + err := chromedp.Run(ctx, + chromedp.Navigate(ts.URL+"/dashboard"), + chromedp.WaitVisible("#stats-island", chromedp.ByQuery), + chromedp.Text("#stats-island", &statsText, chromedp.ByQuery), + ) + require.NoError(t, err) + assert.Contains(t, statsText, "Total Articles") +} +``` + +If the island content comes from a fragment endpoint (returns different HTML for `HX-Request` vs direct), navigate to the *parent* page (which loads htmx.js and fires the request), not the fragment URL directly. Use chromedp for targeted island tests only — prefer domtest elsewhere. + +## Inline field validation + +Add `hx-post` to an input's wrapping `
`. Use `hx-target="this"` and `hx-swap="outerHTML"`: + +```gotmpl +{{define "GET /contact Contact(ctx)"}} +
+
+ + +

+
+ +
+{{end}} +``` + +Validation endpoint returns the same container with error or success styling: + +```gotmpl +{{define "POST /contact/email ValidateEmail(request)"}} +
+ + + {{with .Err}} + + {{else}} +

+ {{end}} +
+{{end}} +``` + +```go +func (s Server) ValidateEmail(r *http.Request) error { + email := r.FormValue("email") + if !isValidEmail(email) { + return fmt.Errorf("invalid email address") + } + if s.db.EmailExists(email) { + return fmt.Errorf("that email is already taken") + } + return nil +} +``` + +Default trigger is `change`. For different timing: + +```html + + +``` + +HTMX-specific pattern. For standard HTML forms without HTMX, rely on HTML5 validation attributes and full-form-submit error handling — see [Forms](../../muxt_forms/SKILL.md#re-rendering-after-validation-errors). + +See [HTMX inline validation example](https://htmx.org/examples/inline-validation/). + +## Status codes and HTMX + +HTMX swaps only on 2xx by default. Non-2xx is ignored unless you use the [response-targets extension](https://htmx.org/extensions/response-targets/): + +```html +
+ +
+``` + +Without it, `StatusCode()` returning 404/500 won't render the error template — the response is silently dropped. Options: + +1. Use `response-targets` with `hx-target-*` attributes. +2. Return 200 with error content in the body (simpler). +3. Use `HX-Reswap`/`HX-Retarget` response headers to redirect error content. diff --git a/docs/skills/muxt_integrate-existing-project/SKILL.md b/docs/skills/muxt_integrate-existing-project/SKILL.md index 0c4f385..34da2c2 100644 --- a/docs/skills/muxt_integrate-existing-project/SKILL.md +++ b/docs/skills/muxt_integrate-existing-project/SKILL.md @@ -3,37 +3,38 @@ name: muxt-integrate-existing-project description: "Muxt: Use when adding Muxt to an existing Go web application. Covers incremental migration, package setup, and wiring Muxt routes alongside existing handlers." --- -# Integrate into an Existing Project +# Integrate muxt into an existing project Add Muxt to an existing Go web application incrementally. No big-bang rewrites. -## Step 1: Add Type Safety to Existing Templates (Optional, Low Risk) +## When to use this skill -If your service already uses `html/template` with `embed.FS`, add compile-time checks without changing handler code: +- Existing Go web app uses raw `net/http` or another router and you want to add muxt for new routes. +- You want compile-time type checks on existing `html/template` usage before changing architecture. +- You're migrating routes one at a time alongside legacy handlers. + +## Step 1: Add type safety to existing templates (optional, low risk) + +If your service already uses `html/template` with `embed.FS`, run: ```bash go install github.com/typelate/muxt/cmd/muxt@latest muxt check ``` -**Requirements:** -- Package-level `var templates` with `embed.FS` -- String literals in `ExecuteTemplate` calls -- Concrete types (not `any`/`interface{}`) - -`muxt check` validates template names, field access, and call expressions. Use `--verbose` to see each endpoint checked. Note: `muxt check` does not accept `--use-receiver-type` — it discovers types from the generated code and template definitions. +**Requirements:** package-level `var templates`, string literals in `ExecuteTemplate` calls, concrete types (not `any`/`interface{}`). -This catches template errors at build time. Team learns Muxt semantics before changing architecture. +`muxt check` validates template names, field access, and call expressions. `--verbose` to see each endpoint. Note: `muxt check` does not accept `--use-receiver-type` — it discovers types from the generated code and template definitions. -## Step 2: Create an Isolated Package +This catches template errors at build time. Team learns muxt semantics before architectural changes. -Create a new package for Muxt-generated code. Keep existing handlers untouched. +## Step 2: Create an isolated package ```bash mkdir -p internal/hypertext ``` -Create `internal/hypertext/templates.go`: +`internal/hypertext/templates.go`: ```go package hypertext @@ -50,17 +51,9 @@ var fs embed.FS var templates = template.Must(template.ParseFS(fs, "*.gohtml")) ``` -If the receiver type lives in a different package: - -```go -//go:generate muxt generate --use-receiver-type=Server --use-receiver-type-package=github.com/yourorg/yourapp/internal/domain -``` +If the receiver type lives in a different package, add `--use-receiver-type-package=github.com/yourorg/yourapp/internal/domain`. See [Templates Variable](../../reference/templates-variable.md) for embedding patterns (subdirectories, custom functions, multiple directories). -See [Templates Variable](../../reference/templates-variable.md) for embedding patterns (subdirectories, custom functions, multiple directories). - -## Step 3: Write Templates and Receiver Methods - -Create a route template (`internal/hypertext/dashboard.gohtml`): +## Step 3: Write templates and receiver methods ```gotmpl {{define "GET /dashboard Dashboard(ctx)"}} @@ -72,8 +65,6 @@ Create a route template (`internal/hypertext/dashboard.gohtml`): {{end}} ``` -Implement the receiver method: - ```go type DashboardData struct { Greeting string @@ -92,76 +83,48 @@ func (s *Server) Dashboard(ctx context.Context) (DashboardData, error) { } ``` -Use concrete return types (not `any`). Return `(T, error)` for fallible operations. +Use concrete return types (not `any`). Return `(T, error)` for fallible operations. Full TDD workflow: [Template-Driven Development](../muxt_test-driven-development/SKILL.md). -See [Template-Driven Development](../muxt_test-driven-development/SKILL.md) for the full TDD workflow. - -## Step 4: Generate and Wire +## Step 4: Generate and wire ```bash go generate ./internal/hypertext/ ``` -Wire both routing systems in your main function: - ```go mux := http.NewServeMux() // Existing routes continue working api.RegisterRoutes(mux, srv) -// New Muxt routes added incrementally +// New muxt routes added incrementally hypertext.TemplateRoutes(mux, srv) http.ListenAndServe(":8080", mux) ``` -Both systems share the same receiver. Muxt routes use generated handlers, existing routes are unchanged. +Both systems share the same receiver. muxt routes use generated handlers; existing routes are unchanged. -## Step 5: Migrate Routes Incrementally +## Step 5: Migrate routes incrementally -Move routes one at a time. Path-based coexistence works because `http.ServeMux` routes by specificity: +Path-based coexistence works because `http.ServeMux` routes by specificity: ```go -mux.HandleFunc("/admin/", legacyAdminHandler) // Not yet migrated -hypertext.TemplateRoutes(mux, srv) // New paths via Muxt +mux.HandleFunc("/admin/", legacyAdminHandler) // not yet migrated +hypertext.TemplateRoutes(mux, srv) // new paths via muxt ``` Each migrated route gets compile-time type checking. Unmigrated routes keep working. -## Common Pitfalls +## Common pitfalls -**Avoid `any` in method signatures:** -```go -// Bad - type checker can't help -func (s *Server) Dashboard(ctx context.Context) (any, error) - -// Good - concrete types enable static analysis -func (s *Server) Dashboard(ctx context.Context) (DashboardData, error) -``` - -**Avoid runtime template selection:** -```go -// Bad - Muxt can't analyze -templateName := getTemplateName(r) -templates.ExecuteTemplate(w, templateName, data) - -// Good - static template names -templates.ExecuteTemplate(w, "user-profile", data) -``` - -**Avoid mixing HTTP and business logic:** -```go -// Bad - tightly coupled to HTTP -func (s *Server) Dashboard(w http.ResponseWriter, r *http.Request) { ... } - -// Good - pure domain logic -func (s *Server) Dashboard(ctx context.Context) (DashboardData, error) { ... } -``` +- **`any` in method signatures** — kills type analysis. Use concrete types. +- **Runtime template selection** — `templates.ExecuteTemplate(w, dynamicName, data)`. muxt can't analyze. Use static template names. +- **Mixing HTTP and business logic** — `func (s *Server) Dashboard(w http.ResponseWriter, r *http.Request)` is tightly coupled. Prefer pure domain methods returning `(T, error)`. -## Testing Strategy +## Testing strategy -Unit test domain methods without HTTP: +Unit-test domain methods without HTTP: ```go func TestDashboard(t *testing.T) { @@ -172,7 +135,7 @@ func TestDashboard(t *testing.T) { } ``` -Integration test generated routes with HTTP: +Integration-test generated routes via HTTP: ```go func TestDashboardRoute(t *testing.T) { @@ -190,22 +153,15 @@ func TestDashboardRoute(t *testing.T) { } ``` -See [Template-Driven Development](../muxt_test-driven-development/SKILL.md) for the full testing workflow. +Full workflow: [Template-Driven Development](../muxt_test-driven-development/SKILL.md). -## Reference +## External reference -- [CLI Commands](../../reference/cli.md) -- [Template Name Syntax](../../reference/template-names.md) -- [Call Parameters](../../reference/call-parameters.md) -- [Call Results](../../reference/call-results.md) -- [Templates Variable](../../reference/templates-variable.md) -- [Package Structure](../../explanation/package-structure.md) +- [CLI Commands](../../reference/cli.md), [Template Name Syntax](../../reference/template-names.md), [Call Parameters](../../reference/call-parameters.md), [Call Results](../../reference/call-results.md), [Templates Variable](../../reference/templates-variable.md), [Package Structure](../../explanation/package-structure.md) -### Test Cases (`cmd/muxt/testdata/`) +### Test cases (`cmd/muxt/testdata/`) - `reference_receiver_with_different_package.txt` — `--use-receiver-type-package` flag -- `reference_receiver_with_pointer.txt` — Pointer receiver types -- `reference_receiver_with_embedded_method.txt` — Embedded method promotion -- `reference_multiple_generated_routes.txt` — Multiple templates variables in one package -- `reference_package_discovery.txt` — How muxt discovers the package -- `reference_template_embed_gen_decl.txt` — `//go:embed` with `var` declaration +- `reference_receiver_with_pointer.txt`, `reference_receiver_with_embedded_method.txt` — pointer / embedded receivers +- `reference_multiple_generated_routes.txt` — multiple templates variables in one package +- `reference_package_discovery.txt`, `reference_template_embed_gen_decl.txt` — package and embed discovery diff --git a/docs/skills/muxt_maintain-tools/SKILL.md b/docs/skills/muxt_maintain-tools/SKILL.md index 372b424..79d9965 100644 --- a/docs/skills/muxt_maintain-tools/SKILL.md +++ b/docs/skills/muxt_maintain-tools/SKILL.md @@ -1,11 +1,11 @@ --- name: muxt-maintain-tools -description: "Muxt: Use when installing or updating muxt, gofumpt, counterfeiter, domtest, or other tools used in a Muxt codebase." +description: "Muxt: Use when installing, updating, or upgrading the tools used in a Muxt codebase — muxt itself, gofumpt, counterfeiter, domtest, testify, sqlc, txtar, chromedp." --- -# Maintain Tools +# Maintain muxt-related tools -Install and update the tools used in a Muxt development workflow. +Install and update the tools used in a Muxt development workflow. Quick-reference list — copy/paste the install commands you need. ## Required Tools diff --git a/docs/skills/muxt_refactoring/SKILL.md b/docs/skills/muxt_refactoring/SKILL.md index c2cbda6..b1cdcd8 100644 --- a/docs/skills/muxt_refactoring/SKILL.md +++ b/docs/skills/muxt_refactoring/SKILL.md @@ -3,13 +3,21 @@ name: muxt-refactoring description: "Muxt: Use when renaming receiver methods, changing route patterns, moving templates between files, splitting packages, or adding/removing method parameters in a Muxt codebase. Guides safe refactoring through Muxt's template-method coupling chain." --- -# Refactoring Routes +# Refactoring Muxt Routes -Muxt couples templates, route patterns, and receiver methods by name. Refactoring any one of these requires updating the others. This skill guides safe refactoring through the coupling chain. +Muxt couples templates, route patterns, and receiver methods by name. Refactoring any one requires updating the others. This skill walks the coupling chain safely. -## Safe Refactoring Loop +## When to use this skill -After every change, run this loop: +- Renaming a receiver method. +- Changing a route pattern (path, method, params). +- Moving templates between `.gohtml` files. +- Splitting a Muxt package into separate packages. +- Adding/removing method parameters. + +## Safe refactoring loop + +After every change, run: ```bash go generate ./... # regenerate handler code @@ -17,161 +25,36 @@ muxt check # type-check templates against generated code go test ./... # run tests ``` -`muxt check` validates against the already-generated code, so it must run after `go generate`. Stop and fix any errors before proceeding to the next change. - -## Renaming a Receiver Method - -### 1. Find All References - -Find templates that call the method: - -```bash -muxt list-template-callers --match "GetArticle" -``` - -Find Go references to the method using gopls. Point at the method definition: - -```bash -# References to a concrete method (file:line:column) -gopls references main.go:19:20 - -# References to the generated interface method -gopls references template_routes.go:21:2 - -# Find all concrete implementations of the interface method -gopls implementation template_routes.go:21:2 -``` - -### 2. Update the Template Name +`muxt check` validates against already-generated code, so it must run after `go generate`. Stop and fix errors before the next change. -Change the method name in every template that calls it: +## Renaming a receiver method -```gotmpl -{{/* Before */}} -{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} +Order matters because gopls won't rename the concrete method while the generated interface still references the old name. -{{/* After */}} -{{define "GET /article/{id} FetchArticle(ctx, id)"}}...{{end}} -``` +1. Find references — `muxt list-template-callers --match "GetArticle"` and `gopls references` / `gopls implementation` on the method's file:line:column. +2. Update the template name(s) — `{{define "GET /article/{id} FetchArticle(ctx, id)"}}`. +3. **Regenerate first** — `go generate ./...` — so the interface uses the new name. +4. Rename the concrete method — `gopls rename -w main.go:19:20 FetchArticle` (or via the generated interface method to cascade to all implementations). +5. Update tests (counterfeiter call counts: `app.FetchArticleCallCount()`). +6. Regenerate + `go test`. -### 3. Regenerate First +Full commands and `gopls rename -d` dry-run example in `references/examples.md`. -Regenerate so the `RoutesReceiver` interface updates to match the new template name: +## Changing a route pattern -```bash -go generate ./... -``` - -### 4. Rename the Method with gopls +1. Update the template name (path, method, param names — change types where needed). +2. Update `$.Path` callers in templates that reference the route. +3. Update tests to use `TemplateRoutePaths` methods (`paths.GetArticle("my-post")`) instead of hardcoded paths. +4. Update the method signature if param types changed. +5. Regenerate + test. -Now rename the concrete method. Because the generated interface already uses the new name, gopls can safely rename the implementation: +Full before/after examples in `references/examples.md`. -```bash -# Dry-run: preview the rename diff -gopls rename -d main.go:19:20 FetchArticle +## Moving templates between files -# Apply the rename -gopls rename -w main.go:19:20 FetchArticle -``` - -If you rename the method *before* regenerating, gopls will refuse because the old generated interface still uses the old name and would break the interface constraint. Always update the template name and regenerate first. - -Alternatively, rename via the generated interface method to cascade to all implementations: - -```bash -gopls rename -w template_routes.go:21:2 FetchArticle -``` +Muxt is file-agnostic within a package. Move a `{{define}}` block between `.gohtml` files freely — no code change. **File order may affect template overriding.** Ensure `//go:embed *.gohtml` covers the new location; add subdirectory patterns if needed (`//go:embed *.gohtml partials/*.gohtml`). Run `muxt check`. -### 5. Update Tests - -Update test assertions that reference the method (e.g., counterfeiter call counts): - -```go -// Before -require.Equal(t, 1, app.GetArticleCallCount()) - -// After -require.Equal(t, 1, app.FetchArticleCallCount()) -``` - -### 6. Regenerate and Test - -```bash -go generate ./... -go test ./... -``` - -## Changing a Route Pattern - -### 1. Update the Template Name - -```gotmpl -{{/* Before */}} -{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} - -{{/* After */}} -{{define "GET /posts/{slug} GetArticle(ctx, slug)"}}...{{end}} -``` - -If the parameter name or type changes, update the method signature too. - -### 2. Update `$.Path` Callers - -Search templates for `$.Path.GetArticle` and update the arguments: - -```gotmpl -{{/* Before */}} -
View - -{{/* After — slug is now a string */}} -View -``` - -### 3. Update Tests - -Use `TemplateRoutePaths` methods in tests instead of hardcoded paths: - -```go -paths := TemplateRoutes(mux, receiver) - -// Before -req := httptest.NewRequest(http.MethodGet, "/article/1", nil) - -// After — uses the generated path method -req := httptest.NewRequest(http.MethodGet, paths.GetArticle("my-post"), nil) -``` - -### 4. Update the Method Signature (if types changed) - -```go -// Before -func (s Server) GetArticle(ctx context.Context, id int) (Article, error) { ... } - -// After -func (s Server) GetArticle(ctx context.Context, slug string) (Article, error) { ... } -``` - -## Moving Templates Between Files - -Muxt is file-agnostic within a package. Moving a `{{define}}` block between `.gohtml` files doesn't require any code changes — just move the block. -File order may impact overriding a template. -Ensure the `//go:embed` directive covers the new file: - -```go -//go:embed *.gohtml -var templateFS embed.FS -``` - -If you're adding a subdirectory, add it to the embed pattern: - -```go -//go:embed *.gohtml partials/*.gohtml -var templateFS embed.FS -``` - -After moving, run `muxt check` to verify nothing broke. - -## Splitting Into Multiple Packages +## Splitting into multiple packages Use `--use-receiver-type-package` for cross-package receivers: @@ -179,135 +62,38 @@ Use `--use-receiver-type-package` for cross-package receivers: muxt generate --use-receiver-type=Handler --use-receiver-type-package=example.com/internal/app ``` -This tells Muxt to look up the receiver type in a different package than the templates. - -### Steps - -1. Move the receiver type and methods to the target package -2. Update the `//go:generate` directive with the package flag -3. Run `go generate ./...` -4. Fix any import issues in the generated code - -## Adding or Removing Parameters - -### Adding a Parameter - -1. Add the parameter to the template call expression: - -```gotmpl -{{/* Before */}} -{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} - -{{/* After — added request parameter */}} -{{define "GET /article/{id} GetArticle(ctx, id, request)"}}...{{end}} -``` - -2. Add the parameter to the method signature: - -```go -func (s Server) GetArticle(ctx context.Context, id int, r *http.Request) (Article, error) { ... } -``` - -3. Regenerate and test. +Move the receiver type and methods → update `//go:generate` → `go generate` → fix import issues in generated code. -### Removing a Parameter +## Adding/removing parameters -1. Remove from the template call expression -2. Remove from the method signature -3. Update any test code that depends on the parameter -4. Regenerate and test +Add or remove the parameter in *both* the template call expression and the method signature, then regenerate. For tests that depended on the removed parameter, update or delete the assertion. Full before/after in `references/examples.md`. -## Cleanup After Refactoring +## Cleanup -When you change the generated output file name or switch between single-file and multi-file output, Muxt cleans up orphaned generated files. The `//go:generate` directive controls this: +Switching between single-file and multi-file output, or changing the output file name? Muxt cleans up orphaned generated files on the next `go generate`. If you switch strategies and want immediate cleanup, delete the old generated files manually. -```go -// Single file output (default) -//go:generate muxt generate --use-receiver-type=Server +## Analyzing coupling before a refactor -// Custom output file -//go:generate muxt generate --use-receiver-type=Server -o handlers_gen.go -``` - -If you switch output strategies, delete the old generated files manually or let Muxt's cleanup handle it on the next `go generate`. - -## Finding Duplicates and Coupling - -Use `muxt list-template-calls` and `muxt list-template-callers` with `--format json` and jq to analyze template relationships before refactoring. - -### List All Route Templates - -```bash -muxt list-template-callers --format json | jq '[.Templates[].Name]' -``` - -### Find Duplicate Route Patterns - -Two templates with the same HTTP method + path cause a panic at registration. Find them before `go generate`: - -```bash -muxt list-template-callers --format json | jq ' - [.Templates[].Name - | select(test("^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) ")) - ] | group_by(split(" ") | .[0:2] | join(" ")) - | map(select(length > 1)) - | .[] -' -``` +`muxt list-template-calls` and `muxt list-template-callers` with `--format json` + jq can find duplicate route patterns (same method + path), sub-templates shared by multiple routes (a hidden coupling), all callers of a specific sub-template, and what a given route calls. Worked jq queries in `references/examples.md`. -### Find Sub-templates Shared by Multiple Routes +## Reference files -Shared sub-templates create coupling between routes. Before renaming or removing a sub-template, check which routes depend on it: - -```bash -muxt list-template-calls --format json | jq ' - [.Templates[] - | select(.Name | test("^(GET|POST|PUT|PATCH|DELETE) ")) - | {route: .Name, refs: [.References[].Name]} - ] | [.[].refs[]] - | group_by(.) | map(select(length > 1)) | map(.[0]) -' -``` - -### Find All Callers of a Sub-template - -Once you know a sub-template is shared, find exactly which routes call it: - -```bash -muxt list-template-calls --format json | jq ' - [.Templates[] - | select(.References[]?.Name == "edit-row") - | .Name - ] -' -``` - -### Find What a Route Template Calls - -```bash -muxt list-template-calls --match "SubmitFormEditRow" --format json | jq ' - [.Templates[].References[] | {name: .Name, data: .Data}] -' -``` +- `references/examples.md` — full before/after for every refactoring (rename, route change, parameter add/remove), gopls command lines, embed-directive updates, jq queries for coupling analysis. -## Reference +## External reference -- [Call Parameters](../../reference/call-parameters.md) -- [Call Results](../../reference/call-results.md) -- [Template Name Syntax](../../reference/template-names.md) -- [Debug Generation Errors](../muxt_debug-generation-errors/SKILL.md) — When refactoring triggers errors +- [Call Parameters](../../reference/call-parameters.md), [Call Results](../../reference/call-results.md), [Template Name Syntax](../../reference/template-names.md) +- [Debug Generation Errors](../muxt_debug-generation-errors/SKILL.md) — when refactoring triggers errors. -### Test Cases (`cmd/muxt/testdata/`) +### Test cases (`cmd/muxt/testdata/`) -| Feature | Test File | +| Feature | Test file | |---------|-----------| | Receiver in different package | `reference_receiver_with_different_package.txt` | | Multiple generated route files | `reference_multiple_generated_routes.txt` | | Multiple template files | `reference_multiple_template_files.txt` | | Cleanup: orphaned files | `reference_cleanup_orphaned_files.txt` | -| Cleanup: routes func change | `reference_cleanup_routes_func_change.txt` | -| Cleanup: different routes func | `reference_cleanup_different_routes_func.txt` | -| Cleanup: switch to multiple files | `reference_cleanup_switch_to_multiple_files.txt` | -| Cleanup: switch to single file | `reference_cleanup_switch_to_single_file.txt` | +| Cleanup: routes-func change | `reference_cleanup_routes_func_change.txt`, `reference_cleanup_different_routes_func.txt` | +| Cleanup: switch single ↔ multi | `reference_cleanup_switch_to_multiple_files.txt`, `reference_cleanup_switch_to_single_file.txt` | | Receiver with pointer | `reference_receiver_with_pointer.txt` | | Receiver with embedded method | `reference_receiver_with_embedded_method.txt` | diff --git a/docs/skills/muxt_refactoring/references/examples.md b/docs/skills/muxt_refactoring/references/examples.md new file mode 100644 index 0000000..5863570 --- /dev/null +++ b/docs/skills/muxt_refactoring/references/examples.md @@ -0,0 +1,235 @@ +# muxt refactoring: examples + +## Renaming a receiver method + +### 1. Find references + +```bash +# Templates that call the method +muxt list-template-callers --match "GetArticle" + +# Go references via gopls (file:line:column points at the method definition) +gopls references main.go:19:20 + +# References to the generated interface method +gopls references template_routes.go:21:2 + +# All concrete implementations of the interface method +gopls implementation template_routes.go:21:2 +``` + +### 2. Update the template name(s) + +```gotmpl +{{/* Before */}} +{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} + +{{/* After */}} +{{define "GET /article/{id} FetchArticle(ctx, id)"}}...{{end}} +``` + +### 3. Regenerate first (so the interface updates to the new name) + +```bash +go generate ./... +``` + +### 4. Rename the method with gopls + +```bash +gopls rename -d main.go:19:20 FetchArticle # dry-run +gopls rename -w main.go:19:20 FetchArticle # apply +``` + +If you rename *before* regenerating, gopls refuses — the old generated interface still uses the old name. Always update template + regenerate first. + +Alternatively, rename via the generated interface method to cascade to all implementations: + +```bash +gopls rename -w template_routes.go:21:2 FetchArticle +``` + +### 5. Update tests + +```go +// Before +require.Equal(t, 1, app.GetArticleCallCount()) + +// After +require.Equal(t, 1, app.FetchArticleCallCount()) +``` + +### 6. Regenerate + test + +```bash +go generate ./... +go test ./... +``` + +## Changing a route pattern + +### 1. Update the template name + +```gotmpl +{{/* Before */}} +{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} + +{{/* After */}} +{{define "GET /posts/{slug} GetArticle(ctx, slug)"}}...{{end}} +``` + +If the param name or type changes, update the method signature too. + +### 2. Update `$.Path` callers in templates + +```gotmpl +{{/* Before */}} +View + +{{/* After — slug is now a string */}} +View +``` + +### 3. Update tests — use `TemplateRoutePaths` methods, not hardcoded paths + +```go +paths := TemplateRoutes(mux, receiver) + +// Before +req := httptest.NewRequest(http.MethodGet, "/article/1", nil) + +// After +req := httptest.NewRequest(http.MethodGet, paths.GetArticle("my-post"), nil) +``` + +### 4. Update the method signature (if types changed) + +```go +// Before +func (s Server) GetArticle(ctx context.Context, id int) (Article, error) { ... } + +// After +func (s Server) GetArticle(ctx context.Context, slug string) (Article, error) { ... } +``` + +## Moving templates between files + +Muxt is file-agnostic within a package. Moving a `{{define}}` block between `.gohtml` files needs no code change — just move the block. **File order may impact overriding a template.** Ensure `//go:embed` covers the new file: + +```go +//go:embed *.gohtml +var templateFS embed.FS +``` + +Subdirectory? Add it: + +```go +//go:embed *.gohtml partials/*.gohtml +var templateFS embed.FS +``` + +Run `muxt check` after moving. + +## Splitting into multiple packages + +`--use-receiver-type-package` for cross-package receivers: + +```bash +muxt generate --use-receiver-type=Handler --use-receiver-type-package=example.com/internal/app +``` + +Steps: +1. Move the receiver type and methods to the target package. +2. Update the `//go:generate` directive with the package flag. +3. `go generate ./...`. +4. Fix any import issues in the generated code. + +## Adding or removing parameters + +### Add + +```gotmpl +{{/* Before */}} +{{define "GET /article/{id} GetArticle(ctx, id)"}}...{{end}} + +{{/* After — added request parameter */}} +{{define "GET /article/{id} GetArticle(ctx, id, request)"}}...{{end}} +``` + +```go +func (s Server) GetArticle(ctx context.Context, id int, r *http.Request) (Article, error) { ... } +``` + +Regenerate + test. + +### Remove + +1. Remove from the template call expression. +2. Remove from the method signature. +3. Update any test code that depended on the parameter. +4. Regenerate + test. + +## Cleanup after refactoring + +Switching output strategies (single ↔ multi-file, custom output file): muxt cleans up orphaned generated files on the next `go generate`. The `//go:generate` directive controls it: + +```go +// Single file output (default) +//go:generate muxt generate --use-receiver-type=Server + +// Custom output file +//go:generate muxt generate --use-receiver-type=Server -o handlers_gen.go +``` + +If you change output strategies, delete the old generated files manually or let muxt's cleanup handle it. + +## Analyzing coupling with `--format json` + jq + +### List all route templates + +```bash +muxt list-template-callers --format json | jq '[.Templates[].Name]' +``` + +### Find duplicate route patterns (same method + path → panic at registration) + +```bash +muxt list-template-callers --format json | jq ' + [.Templates[].Name + | select(test("^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) ")) + ] | group_by(split(" ") | .[0:2] | join(" ")) + | map(select(length > 1)) + | .[] +' +``` + +### Find sub-templates shared by multiple routes + +```bash +muxt list-template-calls --format json | jq ' + [.Templates[] + | select(.Name | test("^(GET|POST|PUT|PATCH|DELETE) ")) + | {route: .Name, refs: [.References[].Name]} + ] | [.[].refs[]] + | group_by(.) | map(select(length > 1)) | map(.[0]) +' +``` + +### Find all callers of a specific sub-template + +```bash +muxt list-template-calls --format json | jq ' + [.Templates[] + | select(.References[]?.Name == "edit-row") + | .Name + ] +' +``` + +### Find what a route template calls + +```bash +muxt list-template-calls --match "SubmitFormEditRow" --format json | jq ' + [.Templates[].References[] | {name: .Name, data: .Data}] +' +``` diff --git a/docs/skills/muxt_sqlc/SKILL.md b/docs/skills/muxt_sqlc/SKILL.md index 76102f3..b5b47c2 100644 --- a/docs/skills/muxt_sqlc/SKILL.md +++ b/docs/skills/muxt_sqlc/SKILL.md @@ -5,479 +5,91 @@ description: "Muxt: Use when building a Muxt application backed by a SQL databas # Muxt with sqlc -Both Muxt and [sqlc](https://docs.sqlc.dev) share the same ethos: write in a declarative language (HTML templates, SQL queries) and get type-safe Go code generated from it. Together they create a workflow where your HTML, SQL, and Go code are all statically checked. +Muxt and [sqlc](https://docs.sqlc.dev) share an ethos: write in a declarative language (HTML templates, SQL queries) and get type-safe Go code generated. Together your HTML, SQL, and Go are all statically checked end to end. -## Project Layout +## When to use this skill -### Small Apps: Collocated Templates and Queries +- Building a Muxt app whose data layer uses sqlc-generated queries. +- Wiring form fields to sqlc params structs. +- Choosing transaction ownership (handler vs service vs caller). +- Wrapping DB errors so users see safe text, not internal details. +- Setting up real-database tests (Postgres or SQLite). -For tiny apps, you can keep `.gohtml` templates and `.sql` queries in the same directory. Both tools generate Go code alongside your source files: +## Project layout -``` -internal/app/ -├── templates.go # //go:generate muxt generate -├── sqlc.yaml # sqlc config pointing to this package -├── schema.sql -├── query.sql -├── index.gohtml -├── server.go # Server type, receiver methods -├── template_routes.go # Generated by muxt -├── index_template_routes_gen.go # Generated by muxt -├── db.go # Generated by sqlc -├── models.go # Generated by sqlc -└── query.sql.go # Generated by sqlc -``` - -This works but gets crowded. Prefer separate packages once you have more than a handful of queries or routes. - -### Recommended: Separate Packages - -``` -internal/ -├── database/ -│ ├── sqlc.yaml -│ ├── schema.sql -│ ├── query.sql -│ ├── db.go # Generated: DBTX interface, Queries struct -│ ├── models.go # Generated: row types -│ └── query.sql.go # Generated: query methods -└── hypertext/ - ├── templates.go # //go:generate muxt generate --use-receiver-type=Server - ├── server.go # Server type with *database.Queries - ├── errors.go # Domain error types with StatusCode() - ├── index.gohtml - ├── template_routes.go # Generated by muxt - └── index_template_routes_gen.go # Generated by muxt -``` - -## sqlc Configuration - -Set `query_parameter_limit` to control when sqlc generates parameter structs. The default is `1`, meaning queries with more than one parameter get a params struct automatically. Set it to `0` to always generate a params struct. - -See [sqlc configuration reference](https://docs.sqlc.dev/en/latest/reference/config.html). Engine-specific `sqlc.yaml` examples are shown in [Testing with a Real Database](#testing-with-a-real-database). - -## Embedding Queries - -Embed the sqlc-generated `*database.Queries` as a private `db` field on your receiver type. Keep a `*sql.DB` field for starting transactions: - -```go -package hypertext - -import ( - "database/sql" - - "example.com/internal/database" -) - -type Server struct { - DB *sql.DB - db *database.Queries -} - -func NewServer(conn *sql.DB) *Server { - return &Server{ - DB: conn, - db: database.New(conn), - } -} -``` - -For simple reads, call `s.db.GetArticle(ctx, id)` directly. For writes that need transactions, use `WithTx`: - -```go -func (s *Server) CreateArticle(ctx context.Context, form database.CreateArticleParams) (database.Article, error) { - tx, err := s.DB.BeginTx(ctx, nil) - if err != nil { - return database.Article{}, NewPublishableError(err) - } - defer tx.Rollback() - - article, err := s.db.WithTx(tx).CreateArticle(ctx, form) - if err != nil { - return database.Article{}, NewPublishableError(err) - } - - if err := tx.Commit(); err != nil { - return database.Article{}, NewPublishableError(err) - } - return article, nil -} -``` +- **Small apps:** collocated templates and queries in one directory. +- **Recommended:** separate `internal/database/` (sqlc) and `internal/hypertext/` (muxt) packages. -See [sqlc transactions documentation](https://docs.sqlc.dev/en/latest/howto/transactions.html). +Full directory layouts: `references/examples.md`. -## Aligning Form Fields with Query Parameters +## sqlc configuration -When `query_parameter_limit` is set to a low value (the default is `1`), sqlc generates a params struct for multi-parameter queries: +Set `query_parameter_limit` to control when sqlc generates a params struct. Default `1` → multi-parameter queries get a struct automatically. Set `0` to always generate one. -```sql --- name: CreateArticle :one -INSERT INTO articles (title, body, author_id) -VALUES ($1, $2, $3) -RETURNING *; -``` +See [sqlc configuration reference](https://docs.sqlc.dev/en/latest/reference/config.html). Engine-specific `sqlc.yaml` examples in `references/examples.md`. -sqlc generates: +## Embedding queries on the receiver -```go -type CreateArticleParams struct { - Title string - Body string - AuthorID int64 -} -``` +Embed `*database.Queries` as a private `db` field. Keep `*sql.DB` for transactions. Simple reads call `s.db.GetArticle(ctx, id)` directly. Writes use `WithTx`. Full pattern with `BeginTx`/`Commit`/`Rollback` in `references/examples.md`. -Name your HTML form `input` fields to match these struct fields. Muxt parses form data into the struct automatically: +See [sqlc transactions](https://docs.sqlc.dev/en/latest/howto/transactions.html). -```gotmpl -{{define "POST /article 201 CreateArticle(ctx, form)"}} -{{if .Err}} -
{{.Err.Error}}
-{{else}} -

{{.Result.Title}}

-{{end}} -{{end}} -``` +## Aligning form fields with sqlc params -```gotmpl -
- - - - -
-``` - -The receiver method can use the sqlc params struct directly as the form type: +For a multi-parameter `INSERT` query, sqlc generates a params struct (`CreateArticleParams`). Name HTML form `name="..."` attributes to match those struct fields, then use the params struct directly as the `form` parameter type: ```go func (s *Server) CreateArticle(ctx context.Context, form database.CreateArticleParams) (database.Article, error) { article, err := s.db.CreateArticle(ctx, form) - if err != nil { - return database.Article{}, NewPublishableError(err) - } - return article, nil -} -``` - -This creates connascence of name from SQL column names through sqlc-generated struct fields to HTML form field names. A rename in one place propagates through `go generate` and the compiler catches mismatches. - -## Returning Rows Directly - -For read-only handlers, return the sqlc-generated row type directly: - -```go -func (s *Server) GetArticle(ctx context.Context, id int64) (database.Article, error) { - article, err := s.db.GetArticle(ctx, id) - if err != nil { - return database.Article{}, NewPublishableError(err) - } - return article, nil -} - -func (s *Server) ListArticles(ctx context.Context) ([]database.Article, error) { - articles, err := s.db.ListArticles(ctx) - if err != nil { - return nil, NewPublishableError(err) - } - return articles, nil + return article, NewPublishableError(err) } ``` -Templates access the generated struct fields directly: - -```gotmpl -{{define "GET /article/{id} GetArticle(ctx, id)"}} -{{if .Err}} -
{{.Err.Error}}
-{{else}} -

{{.Result.Title}}

-

{{.Result.Body}}

- -{{end}} -{{end}} -``` +Connascence-of-name flows from SQL columns through generated struct fields to HTML form names. Renames propagate via `go generate`; the compiler catches mismatches. Full template + form HTML in `references/examples.md`. -The field names in `.Result.Title` come from your SQL column names via sqlc. If you rename a column, `sqlc generate` updates the struct, `muxt check` or `go generate` catches any template mismatches. +## Returning rows directly -## Wrapping Database Errors +For read-only handlers, return the sqlc-generated row type directly (`database.Article`, `[]database.Article`). Templates access fields with `.Result.Title`, `.Result.CreatedAt`. Renaming a column → `sqlc generate` updates the struct → `muxt check` / `go generate` catches template mismatches. -**Never return database errors directly to the user.** Raw database errors can leak table names, column names, query structure, and connection details. This is an information disclosure vulnerability. +## Wrapping database errors -See [OWASP Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html). +**Never return database errors directly to the user.** They leak table names, column names, query structure, connection details — an information disclosure vulnerability. See [OWASP Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html). -Create a domain error type with a `StatusCode()` method so Muxt sets the correct HTTP status automatically: +Create a domain error type with a `StatusCode()` method so muxt sets the HTTP status automatically. The `Error()` method should return safe text from `http.StatusText` (e.g. "Not Found"); the original error is preserved via `Unwrap()` for `errors.Is`/`errors.As` and logged with context server-side. Full `PublishableError` implementation in `references/examples.md`. -```go -type PublishableError struct { - err error - code int -} +For more patterns (validation errors, authorization, multiple states): [Domain Errors with HTTP Semantics](../../explanation/advanced-patterns.md). -func NewPublishableError(err error) error { - if err == nil { - return nil - } - var code int - switch { - case errors.Is(err, sql.ErrNoRows): - code = http.StatusNotFound - default: - code = http.StatusInternalServerError - } - return &PublishableError{err: err, code: code} -} - -func (e *PublishableError) Error() string { - return http.StatusText(e.StatusCode()) -} +## Testing with a real database -func (e *PublishableError) StatusCode() int { - return e.code -} +Test against a real database. Exercises your SQL queries, sqlc-generated code, receiver methods, generated handlers, and template rendering end to end. Fakes are for outside services, not your own database. -func (e *PublishableError) Unwrap() error { - return e.err -} -``` +- **Postgres** — [pgtestdb](https://github.com/peterldowns/pgtestdb) for isolated per-test DBs, or docker-compose for a consistent local + CI service. `docker-compose.yml` and `sqlc.yaml` snippets in `references/examples.md`. +- **SQLite** — [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) (pure Go, no CGo) with `sql.Open("sqlite", ":memory:")`. Each test gets a fresh in-memory DB. Walkthrough: [sqlc SQLite tutorial](https://docs.sqlc.dev/en/stable/tutorials/getting-started-sqlite.html). -**What this gives you:** -- `Error()` returns `http.StatusText` — safe, user-facing messages like "Not Found" or "Internal Server Error" (no internal details) -- `StatusCode()` returns the HTTP status code; the constructor uses `errors.Is`/`errors.As` to select the right code -- `Unwrap()` preserves the original error for logging with `errors.Is`/`errors.As` -- The constructor returns `nil` for `nil` input (ergonomic in the caller) -- Adding new error mappings is a single `case` in the `switch` +### End-to-end with domtest -Log the real error server-side with context: +Build up a small domain language of test helpers (`testApp`, `postForm`, `getPage`) so test bodies read like user behavior. Full helper definitions and example tests (workflow + 404 case) in `references/examples.md`. -```go -func (s *Server) GetArticle(ctx context.Context, id int64) (database.Article, error) { - article, err := s.db.GetArticle(ctx, id) - if err != nil { - slog.ErrorContext(ctx, "failed to get article", - slog.Int64("id", id), - slog.String("error", err.Error())) - return database.Article{}, NewPublishableError(err) - } - return article, nil -} -``` - -The user sees "Not Found" or "Internal Server Error". The log captures the real database error. Muxt sets the HTTP status code from `StatusCode()`. - -See [Domain Errors with HTTP Semantics](../../explanation/advanced-patterns.md) for more patterns (validation errors, authorization errors, multiple error states). - -## Testing with a Real Database - -Test against a real database. This exercises your SQL queries, sqlc-generated code, receiver methods, generated handlers, and template rendering end-to-end. Fakes are for outside services, third-party APIs, or hard-to-test services — your own database is not one of those. - -### PostgreSQL - -Use [pgtestdb](https://github.com/peterldowns/pgtestdb) for isolated per-test databases, or [docker-compose](https://docs.docker.com/compose/) for a consistent database service in both local development and CI. - -`docker-compose.yml`: -```yaml -services: - db: - image: postgres:17 - environment: - POSTGRES_DB: test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - ports: - - "5432:5432" -``` - -sqlc config: -```yaml -version: "2" -sql: - - engine: "postgresql" - schema: "schema.sql" - queries: "query.sql" - gen: - go: - package: "database" - out: "internal/database" - query_parameter_limit: 1 -``` - -### SQLite - -For simpler apps or fast tests without Docker, use [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — a pure Go SQLite driver (no CGo). In-memory databases make tests fast and isolated: - -```go -import ( - "database/sql" - _ "modernc.org/sqlite" -) - -func setupTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - require.NoError(t, err) - t.Cleanup(func() { db.Close() }) - - // Run migrations — embed your schema.sql and execute it - _, err = db.Exec(schemaSQL) - require.NoError(t, err) - return db -} -``` - -sqlc config: -```yaml -version: "2" -sql: - - engine: "sqlite" - schema: "schema.sql" - queries: "query.sql" - gen: - go: - package: "database" - out: "internal/database" - query_parameter_limit: 1 -``` - -Each test gets a fresh in-memory database — no cleanup needed, no port conflicts, no Docker dependency. See the [sqlc SQLite tutorial](https://docs.sqlc.dev/en/stable/tutorials/getting-started-sqlite.html) for a complete walkthrough. - -### End-to-End Tests with domtest - -Test at the HTTP level using `httptest` and `domtest`. This verifies the full stack: SQL queries, receiver methods, generated handlers, and template rendering. - -Build up a domain language of test helpers as needed to keep tests short and focused on intent: - -```go -// testApp sets up a real database, server, and mux for end-to-end tests. -func testApp(t *testing.T) (*http.ServeMux, TemplateRoutePaths) { - t.Helper() - db := setupTestDB(t) - srv := NewServer(db) - mux := http.NewServeMux() - paths := TemplateRoutes(mux, srv) - return mux, paths -} - -// postForm submits a form and returns the response. -func postForm(t *testing.T, mux *http.ServeMux, url string, values url.Values) *http.Response { - t.Helper() - req := httptest.NewRequest("POST", url, strings.NewReader(values.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - return rec.Result() -} - -// getPage fetches a page and parses it as a document. -func getPage(t *testing.T, mux *http.ServeMux, url string) *dom.Document { - t.Helper() - req := httptest.NewRequest("GET", url, nil) - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) - return domtest.ParseResponseDocument(t, rec.Result()) -} -``` - -Tests read like a description of user behavior: - -```go -func TestArticleWorkflow(t *testing.T) { - mux, paths := testApp(t) - - // Create an article - res := postForm(t, mux, paths.CreateArticle(), url.Values{ - "Title": []string{"Hello"}, - "Body": []string{"World"}, - }) - assert.Equal(t, http.StatusCreated, res.StatusCode) - - // Verify it appears on the list page - listPage := getPage(t, mux, paths.ListArticles()) - h2 := listPage.QuerySelector("h2") - require.NotNil(t, h2) - assert.Equal(t, "Hello", h2.TextContent()) -} - -func TestGetArticle_NotFound(t *testing.T) { - mux, paths := testApp(t) - - req := httptest.NewRequest("GET", paths.GetArticle(999), nil) - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusNotFound, rec.Code) - doc := domtest.ParseResponseDocument(t, rec.Result()) - errDiv := doc.QuerySelector(".error") - require.NotNil(t, errDiv) - assert.Equal(t, "Not Found", errDiv.TextContent()) -} -``` - -Add more helpers as patterns emerge (`getFragment` for HTMX requests, `loginAs` for authenticated tests, etc.). Each helper should be a thin wrapper — avoid hiding assertions inside helpers. - -### Testing HTMX Islands with chromedp - -For highly-dynamic [islands](https://htmx.org/essays/hypermedia-friendly-scripting/#islands) that load content via HTMX and integrate with complex libraries (echarts, sortable...) after the initial page render, `domtest` alone cannot verify the behavior because it doesn't execute JavaScript. Use [chromedp](https://github.com/chromedp/chromedp) to test these interactions in a real browser. - -Start from a non-fragment endpoint (full page with `