🛟 Updated.
bf66a66 added
.gitignore +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +blog | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +blog | ||
added
.vscode/extensions.json +9 -0 | new file mode 100644 | ||
| @@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "recommendations": [ | |
| 3 | + "ms-azuretools.vscode-docker", | |
| 4 | + "pkief.material-icon-theme", | |
| 5 | + "pkief.material-product-icons", | |
| 6 | + "aaron-bond.better-comments", | |
| 7 | + "bierner.markdown-mermaid", | |
| 8 | + ] | |
| 9 | +} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | +{ | ||
| 2 | + "recommendations": [ | ||
| 3 | + "ms-azuretools.vscode-docker", | ||
| 4 | + "pkief.material-icon-theme", | ||
| 5 | + "pkief.material-product-icons", | ||
| 6 | + "aaron-bond.better-comments", | ||
| 7 | + "bierner.markdown-mermaid", | ||
| 8 | + ] | ||
| 9 | +} | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
.vscode/settings.json +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +{ | |
| 2 | + "workbench.iconTheme": "material-icon-theme", | |
| 3 | + "workbench.colorTheme": "Light Modern", | |
| 4 | + "editor.fontSize": 14, | |
| 5 | + "terminal.integrated.fontSize": 16, | |
| 6 | + "editor.insertSpaces": true, | |
| 7 | + "editor.tabSize": 4, | |
| 8 | + "editor.detectIndentation": true, | |
| 9 | + "files.autoSave": "afterDelay", | |
| 10 | + "files.autoSaveDelay": 1000, | |
| 11 | + // "editor.defaultFormatter": "esbenp.prettier-vscode", | |
| 12 | + "editor.formatOnSave": true, | |
| 13 | + "workbench.tree.indent": 20, | |
| 14 | + //"workbench.activityBar.location": "top", | |
| 15 | + "workbench.editor.showTabs": "multiple", | |
| 16 | + "window.zoomLevel": 0.0, | |
| 17 | + "[markdown]": { | |
| 18 | + "editor.unicodeHighlight.ambiguousCharacters": false, | |
| 19 | + "editor.unicodeHighlight.invisibleCharacters": false, | |
| 20 | + "diffEditor.ignoreTrimWhitespace": false, | |
| 21 | + "editor.fontWeight": "normal", | |
| 22 | + "editor.fontFamily": "'Droid Sans Mono', 'monospace', monospace", | |
| 23 | + "editor.fontSize": 14, | |
| 24 | + "editor.wordWrap": "on", | |
| 25 | + "editor.quickSuggestions": { | |
| 26 | + "comments": "off", | |
| 27 | + "strings": "off", | |
| 28 | + "other": "off" | |
| 29 | + } | |
| 30 | + }, | |
| 31 | + "markdown.preview.fontSize": 14, | |
| 32 | + // "workbench.editorAssociations": { | |
| 33 | + // "*.md": "vscode.markdown.preview.editor" | |
| 34 | + // }, | |
| 35 | + "markdown.marp.html": "all", | |
| 36 | + "[dockerfile]": { | |
| 37 | + "editor.fontSize": 14 | |
| 38 | + }, | |
| 39 | + "[dockercompose]": { | |
| 40 | + "editor.fontSize": 14 | |
| 41 | + }, | |
| 42 | + "[json]": { | |
| 43 | + "editor.fontSize": 14 | |
| 44 | + }, | |
| 45 | + "[yaml]": { | |
| 46 | + "editor.fontSize": 14 | |
| 47 | + }, | |
| 48 | + "[go]": { | |
| 49 | + "editor.fontSize": 14, | |
| 50 | + "editor.codeActionsOnSave": { | |
| 51 | + "source.organizeImports": "explicit" | |
| 52 | + } | |
| 53 | + }, | |
| 54 | + "go.lintTool": "golangci-lint", | |
| 55 | + "go.lintOnSave": "package", | |
| 56 | + "go.formatTool": "goimports", | |
| 57 | + "go.useLanguageServer": true, | |
| 58 | + "gopls": { | |
| 59 | + "ui.semanticTokens": true, | |
| 60 | + "ui.completion.usePlaceholders": true | |
| 61 | + }, | |
| 62 | + "issuespec.groupBy": "milestone" | |
| 63 | +} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +{ | ||
| 2 | + "workbench.iconTheme": "material-icon-theme", | ||
| 3 | + "workbench.colorTheme": "Light Modern", | ||
| 4 | + "editor.fontSize": 14, | ||
| 5 | + "terminal.integrated.fontSize": 16, | ||
| 6 | + "editor.insertSpaces": true, | ||
| 7 | + "editor.tabSize": 4, | ||
| 8 | + "editor.detectIndentation": true, | ||
| 9 | + "files.autoSave": "afterDelay", | ||
| 10 | + "files.autoSaveDelay": 1000, | ||
| 11 | + // "editor.defaultFormatter": "esbenp.prettier-vscode", | ||
| 12 | + "editor.formatOnSave": true, | ||
| 13 | + "workbench.tree.indent": 20, | ||
| 14 | + //"workbench.activityBar.location": "top", | ||
| 15 | + "workbench.editor.showTabs": "multiple", | ||
| 16 | + "window.zoomLevel": 0.0, | ||
| 17 | + "[markdown]": { | ||
| 18 | + "editor.unicodeHighlight.ambiguousCharacters": false, | ||
| 19 | + "editor.unicodeHighlight.invisibleCharacters": false, | ||
| 20 | + "diffEditor.ignoreTrimWhitespace": false, | ||
| 21 | + "editor.fontWeight": "normal", | ||
| 22 | + "editor.fontFamily": "'Droid Sans Mono', 'monospace', monospace", | ||
| 23 | + "editor.fontSize": 14, | ||
| 24 | + "editor.wordWrap": "on", | ||
| 25 | + "editor.quickSuggestions": { | ||
| 26 | + "comments": "off", | ||
| 27 | + "strings": "off", | ||
| 28 | + "other": "off" | ||
| 29 | + } | ||
| 30 | + }, | ||
| 31 | + "markdown.preview.fontSize": 14, | ||
| 32 | + // "workbench.editorAssociations": { | ||
| 33 | + // "*.md": "vscode.markdown.preview.editor" | ||
| 34 | + // }, | ||
| 35 | + "markdown.marp.html": "all", | ||
| 36 | + "[dockerfile]": { | ||
| 37 | + "editor.fontSize": 14 | ||
| 38 | + }, | ||
| 39 | + "[dockercompose]": { | ||
| 40 | + "editor.fontSize": 14 | ||
| 41 | + }, | ||
| 42 | + "[json]": { | ||
| 43 | + "editor.fontSize": 14 | ||
| 44 | + }, | ||
| 45 | + "[yaml]": { | ||
| 46 | + "editor.fontSize": 14 | ||
| 47 | + }, | ||
| 48 | + "[go]": { | ||
| 49 | + "editor.fontSize": 14, | ||
| 50 | + "editor.codeActionsOnSave": { | ||
| 51 | + "source.organizeImports": "explicit" | ||
| 52 | + } | ||
| 53 | + }, | ||
| 54 | + "go.lintTool": "golangci-lint", | ||
| 55 | + "go.lintOnSave": "package", | ||
| 56 | + "go.formatTool": "goimports", | ||
| 57 | + "go.useLanguageServer": true, | ||
| 58 | + "gopls": { | ||
| 59 | + "ui.semanticTokens": true, | ||
| 60 | + "ui.completion.usePlaceholders": true | ||
| 61 | + }, | ||
| 62 | + "issuespec.groupBy": "milestone" | ||
| 63 | +} | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
README.md +22 -0 | new file mode 100644 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +# Mini Me Editor demo | |
| 2 | + | |
| 3 | + | |
| 4 | +```bash | |
| 5 | +mkdir demo | |
| 6 | +cd demo | |
| 7 | +``` | |
| 8 | + | |
| 9 | +```bash | |
| 10 | +git clone --depth 1 --filter=blob:none --sparse https://git.rickub.com/bots-garden/squadron.git /tmp/squadron && | |
| 11 | +git -C /tmp/squadron sparse-checkout set little-coder && | |
| 12 | +cp -a /tmp/squadron/little-coder/. . && | |
| 13 | +rm -rf /tmp/squadron | |
| 14 | +``` | |
| 15 | + | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +sbx run -d --name mm-little-coder -p 8080:3000 docker.io/k33g/mini-me-editor-kit:0.0.2 \ | |
| 19 | +--kit docker.io/k33g/go-toolchain-kit:0.0.2 | |
| 20 | +``` | |
| 21 | + | |
| 22 | +open the editor on: http://localhost:8080 | |
| new file mode 100644 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +# Mini Me Editor demo | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +```bash | ||
| 5 | +mkdir demo | ||
| 6 | +cd demo | ||
| 7 | +``` | ||
| 8 | + | ||
| 9 | +```bash | ||
| 10 | +git clone --depth 1 --filter=blob:none --sparse https://git.rickub.com/bots-garden/squadron.git /tmp/squadron && | ||
| 11 | +git -C /tmp/squadron sparse-checkout set little-coder && | ||
| 12 | +cp -a /tmp/squadron/little-coder/. . && | ||
| 13 | +rm -rf /tmp/squadron | ||
| 14 | +``` | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +sbx run -d --name mm-little-coder -p 8080:3000 docker.io/k33g/mini-me-editor-kit:0.0.2 \ | ||
| 19 | +--kit docker.io/k33g/go-toolchain-kit:0.0.2 | ||
| 20 | +``` | ||
| 21 | + | ||
| 22 | +open the editor on: http://localhost:8080 | ||
added
demo/.gitignore +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +# Apps generated by the agent: one directory each, next to skills/. | |
| 2 | +/*/ | |
| 3 | +!/skills/ | |
| 4 | +# Files a small model sometimes writes here instead of inside the app. | |
| 5 | +/*.go | |
| 6 | +/*.html | |
| 7 | +/go.mod | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +# Apps generated by the agent: one directory each, next to skills/. | ||
| 2 | +/*/ | ||
| 3 | +!/skills/ | ||
| 4 | +# Files a small model sometimes writes here instead of inside the app. | ||
| 5 | +/*.go | ||
| 6 | +/*.html | ||
| 7 | +/go.mod | ||
added
demo/AGENTS.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +# Little Coder workspace — rules for generated apps | |
| 2 | + | |
| 3 | +## Stack | |
| 4 | +- Go, standard library only: `net/http`, `html/template`, `embed`, `log/slog`. No framework, no router library, no database driver unless asked. | |
| 5 | +- htmx 2 from the CDN: `<script src="https://unpkg.com/htmx.org@2.0.4"></script>`. | |
| 6 | +- Pico CSS for a responsive page with no CSS to write: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">`, plus `<meta name="viewport" content="width=device-width, initial-scale=1">`. | |
| 7 | +- Data in memory (a slice or map behind a `sync.Mutex`) unless the user asks for storage. | |
| 8 | + | |
| 9 | +## Modern Go | |
| 10 | +- Routing with method and path patterns: `mux.HandleFunc("POST /items/{id}/toggle", h)`, read with `r.PathValue("id")`. | |
| 11 | +- Templates and static files embedded with `//go:embed`. | |
| 12 | +- `http.Server` with `ReadHeaderTimeout`; graceful shutdown with `signal.NotifyContext`. | |
| 13 | +- Every error checked; `slog` for logs; port from the `PORT` environment variable, default `8080`. | |
| 14 | + | |
| 15 | +## Layout of an app | |
| 16 | +Each app is a NEW directory here, named in kebab-case: `go.mod`, `main.go`, `app.go` (texts and fields), `appinfo.go`, `store.go`, `handlers.go`, `templates.go`, `handlers_test.go`, `templates/index.html`, `check.sh`, `README.md`. A new app starts from the tested starter of the `go-htmx-app` skill. | |
| 17 | + | |
| 18 | +## Done means | |
| 19 | +`sh check.sh` in the app's directory ends with `CHECK OK`: gofmt, go vet and go test pass, and the running app answered `curl` on `/` and on an htmx route. | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +# Little Coder workspace — rules for generated apps | ||
| 2 | + | ||
| 3 | +## Stack | ||
| 4 | +- Go, standard library only: `net/http`, `html/template`, `embed`, `log/slog`. No framework, no router library, no database driver unless asked. | ||
| 5 | +- htmx 2 from the CDN: `<script src="https://unpkg.com/htmx.org@2.0.4"></script>`. | ||
| 6 | +- Pico CSS for a responsive page with no CSS to write: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">`, plus `<meta name="viewport" content="width=device-width, initial-scale=1">`. | ||
| 7 | +- Data in memory (a slice or map behind a `sync.Mutex`) unless the user asks for storage. | ||
| 8 | + | ||
| 9 | +## Modern Go | ||
| 10 | +- Routing with method and path patterns: `mux.HandleFunc("POST /items/{id}/toggle", h)`, read with `r.PathValue("id")`. | ||
| 11 | +- Templates and static files embedded with `//go:embed`. | ||
| 12 | +- `http.Server` with `ReadHeaderTimeout`; graceful shutdown with `signal.NotifyContext`. | ||
| 13 | +- Every error checked; `slog` for logs; port from the `PORT` environment variable, default `8080`. | ||
| 14 | + | ||
| 15 | +## Layout of an app | ||
| 16 | +Each app is a NEW directory here, named in kebab-case: `go.mod`, `main.go`, `app.go` (texts and fields), `appinfo.go`, `store.go`, `handlers.go`, `templates.go`, `handlers_test.go`, `templates/index.html`, `check.sh`, `README.md`. A new app starts from the tested starter of the `go-htmx-app` skill. | ||
| 17 | + | ||
| 18 | +## Done means | ||
| 19 | +`sh check.sh` in the app's directory ends with `CHECK OK`: gofmt, go vet and go test pass, and the running app answered `curl` on `/` and on an htmx route. | ||
added
demo/README.agent.md +96 -0 | new file mode 100644 | ||
| @@ -0,0 +1,96 @@ | ||
| 1 | +# Little Coder — a Go + htmx agent that fits on a MacBook Air | |
| 2 | + | |
| 3 | +An `mm` agent specialised in **modern Go + htmx** web applications, built for a small local model: **JetBrains Mellum2 12B-A2.5B** (Q4_K_M) served by **llmman**, on a machine with **32 GB of RAM**. | |
| 4 | + | |
| 5 | +Ask it *"Generate a sample application in modern Golang, responsive web and HTMX"* (or the same in French): it creates the application, checks it (gofmt, vet, tests, start-up, curl requests) and tells you how to run it. | |
| 6 | + | |
| 7 | +| | | | |
| 8 | +|---|---| | |
| 9 | +| Model | `hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` — MoE, 12.1B parameters, ~2.5B active per token, no reasoning | | |
| 10 | +| Server | llmman (OpenAI API under `/v1`) | | |
| 11 | +| Tools | `bash`, `read_file` / `write_file` / `edit_file`, `read_skill` — nothing else | | |
| 12 | +| Skills | `go-htmx-app`, `go-htmx-subject-app`, `run-and-check`, `htmx-patterns` | | |
| 13 | +| Project rules | `AGENTS.md` (stack, modern Go, definition of done) | | |
| 14 | + | |
| 15 | +## Prerequisites | |
| 16 | + | |
| 17 | +- **llmman** and the model: | |
| 18 | + | |
| 19 | + ```bash | |
| 20 | + llmman pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 21 | + ``` | |
| 22 | + | |
| 23 | +### Copie the content of the `little-coder` directory from the repository to the current directory | |
| 24 | + | |
| 25 | +```bash | |
| 26 | +git clone --depth 1 --filter=blob:none --sparse https://git.rickub.com/bots-garden/squadron.git /tmp/squadron && | |
| 27 | +git -C /tmp/squadron sparse-checkout set little-coder && | |
| 28 | +cp -a /tmp/squadron/little-coder/. . && | |
| 29 | +rm -rf /tmp/squadron | |
| 30 | +``` | |
| 31 | + | |
| 32 | +## Run | |
| 33 | + | |
| 34 | +**From this directory**: the skills (`skills/`) and `AGENTS.md` are read from the directory `mm` starts in, and each generated application is created there, in its own sub-directory. | |
| 35 | + | |
| 36 | +```bash | |
| 37 | +cd demos/little-coder | |
| 38 | +RELEASE=v0.0.1 | |
| 39 | +REPOSITORY=git+https://git.rickub.com/bots-garden/mini-me-kit.git | |
| 40 | +sbx run --name mm-demo-remote "${REPOSITORY}#ref=${RELEASE}&dir=mini-me" | |
| 41 | +``` | |
| 42 | + | |
| 43 | +## Examples | |
| 44 | + | |
| 45 | +### 1. A sample application | |
| 46 | + | |
| 47 | +```text | |
| 48 | +Generate a sample application in modern Golang, responsive web and HTMX | |
| 49 | +``` | |
| 50 | + | |
| 51 | +### 2. An application about a given subject | |
| 52 | + | |
| 53 | +```text | |
| 54 | +Create a small Go and HTMX web application to manage my library: books with a title and an author, mark a book as read, delete, search. | |
| 55 | +``` | |
| 56 | + | |
| 57 | +### 3. In a conversation | |
| 58 | + | |
| 59 | +``` | |
| 60 | +> Generate a sample Go + htmx application | |
| 61 | +> Check htmx-tasks | |
| 62 | +> What is the difference between hx-swap="outerHTML" and "innerHTML"? | |
| 63 | +> How do I refresh a counter after every add with htmx? | |
| 64 | +> In htmx-tasks, add a counter of remaining tasks ← unreliable, see Limits | |
| 65 | +``` | |
| 66 | + | |
| 67 | +## Notable settings (`agent.little-coder.yaml`) | |
| 68 | + | |
| 69 | +| Key | Value | Why | | |
| 70 | +|---|---|---| | |
| 71 | +| `contextWindow` | 32768 | Matches `LLMMAN_CONTEXT_LENGTH`; if llmman serves more, mm simply compresses earlier. | | |
| 72 | +| `context.threshold` | 60 | Compression around ~20k tokens, before the model loses track. | | |
| 73 | +| `sampling.temperature` | 0.2 | Precise without being greedy (0.0 makes small models loop on long tool calls). | | |
| 74 | +| `sampling.max_tokens` | 8192 | One whole file per `write_file`, without letting a runaway reply fill the window. | | |
| 75 | +| `maxOutput` | 8000 | `go test` output fits without flooding a 32k window. | | |
| 76 | +| `bashTimeout` / `watchdogTimeout` | 2m | A first `go build` of `net/http` on an Air. | | |
| 77 | +| `agentsMd.maxChars` | 4000 | `AGENTS.md` goes out with every request: a guard rail if it grows. | | |
| 78 | + | |
| 79 | +## Limits | |
| 80 | + | |
| 81 | +- **Changing an existing application is not reliable with this model.** Out of 3 attempts at adding a counter: once the files were written outside the application (without the `htmx-tasks/` prefix) with an answer claiming success, once 40 turns without a change, once a broken build. `htmx-patterns` does hold the exact pattern (verified). A skill dedicated to changes (`go-htmx-change`) was tried then removed: it did no better, and its mere presence in the catalogue made one generation in three fail and drew htmx questions to it. 2.5B active parameters are not enough to chain several code files without a mistake. For this work: always check with `sh check.sh` yourself, go one small step per message, or switch to a bigger model (for instance the configuration in `../mini-mes/gopher/`). | |
| 82 | +- The starter covers a **list of items** (main text + text fields + a checkbox), which covers many sample applications. Several entities, relations or persistence require changing the code — same limit as above. | |
| 83 | +- Extra fields are free text, not validated; data is in memory, lost when the server stops. | |
| 84 | +- Run it from this directory (relative paths to the skills and the starter). The `.gitignore` here ignores generated applications and the files a model might write at the root by mistake. | |
| 85 | + | |
| 86 | +## Files | |
| 87 | + | |
| 88 | +| File | Role | | |
| 89 | +|---|---| | |
| 90 | +| `agent.little-coder.yaml` | The agent's configuration, commented. | | |
| 91 | +| `AGENTS.md` | The workspace rules, appended to the system prompt. | | |
| 92 | +| `README.md` | This document in French. | | |
| 93 | +| `skills/go-htmx-app/` | Create a sample application; holds the `starter/`. | | |
| 94 | +| `skills/go-htmx-subject-app/` | Create an application about a given subject (adapt `app.go`). | | |
| 95 | +| `skills/run-and-check/` | Check, run, diagnose and fix. | | |
| 96 | +| `skills/htmx-patterns/` | htmx 2 reference, HTML side and Go handler side. | | |
| new file mode 100644 | |||
| @@ -0,0 +1,96 @@ | |||
| 1 | +# Little Coder — a Go + htmx agent that fits on a MacBook Air | ||
| 2 | + | ||
| 3 | +An `mm` agent specialised in **modern Go + htmx** web applications, built for a small local model: **JetBrains Mellum2 12B-A2.5B** (Q4_K_M) served by **llmman**, on a machine with **32 GB of RAM**. | ||
| 4 | + | ||
| 5 | +Ask it *"Generate a sample application in modern Golang, responsive web and HTMX"* (or the same in French): it creates the application, checks it (gofmt, vet, tests, start-up, curl requests) and tells you how to run it. | ||
| 6 | + | ||
| 7 | +| | | | ||
| 8 | +|---|---| | ||
| 9 | +| Model | `hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` — MoE, 12.1B parameters, ~2.5B active per token, no reasoning | | ||
| 10 | +| Server | llmman (OpenAI API under `/v1`) | | ||
| 11 | +| Tools | `bash`, `read_file` / `write_file` / `edit_file`, `read_skill` — nothing else | | ||
| 12 | +| Skills | `go-htmx-app`, `go-htmx-subject-app`, `run-and-check`, `htmx-patterns` | | ||
| 13 | +| Project rules | `AGENTS.md` (stack, modern Go, definition of done) | | ||
| 14 | + | ||
| 15 | +## Prerequisites | ||
| 16 | + | ||
| 17 | +- **llmman** and the model: | ||
| 18 | + | ||
| 19 | + ```bash | ||
| 20 | + llmman pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 21 | + ``` | ||
| 22 | + | ||
| 23 | +### Copie the content of the `little-coder` directory from the repository to the current directory | ||
| 24 | + | ||
| 25 | +```bash | ||
| 26 | +git clone --depth 1 --filter=blob:none --sparse https://git.rickub.com/bots-garden/squadron.git /tmp/squadron && | ||
| 27 | +git -C /tmp/squadron sparse-checkout set little-coder && | ||
| 28 | +cp -a /tmp/squadron/little-coder/. . && | ||
| 29 | +rm -rf /tmp/squadron | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +## Run | ||
| 33 | + | ||
| 34 | +**From this directory**: the skills (`skills/`) and `AGENTS.md` are read from the directory `mm` starts in, and each generated application is created there, in its own sub-directory. | ||
| 35 | + | ||
| 36 | +```bash | ||
| 37 | +cd demos/little-coder | ||
| 38 | +RELEASE=v0.0.1 | ||
| 39 | +REPOSITORY=git+https://git.rickub.com/bots-garden/mini-me-kit.git | ||
| 40 | +sbx run --name mm-demo-remote "${REPOSITORY}#ref=${RELEASE}&dir=mini-me" | ||
| 41 | +``` | ||
| 42 | + | ||
| 43 | +## Examples | ||
| 44 | + | ||
| 45 | +### 1. A sample application | ||
| 46 | + | ||
| 47 | +```text | ||
| 48 | +Generate a sample application in modern Golang, responsive web and HTMX | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +### 2. An application about a given subject | ||
| 52 | + | ||
| 53 | +```text | ||
| 54 | +Create a small Go and HTMX web application to manage my library: books with a title and an author, mark a book as read, delete, search. | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +### 3. In a conversation | ||
| 58 | + | ||
| 59 | +``` | ||
| 60 | +> Generate a sample Go + htmx application | ||
| 61 | +> Check htmx-tasks | ||
| 62 | +> What is the difference between hx-swap="outerHTML" and "innerHTML"? | ||
| 63 | +> How do I refresh a counter after every add with htmx? | ||
| 64 | +> In htmx-tasks, add a counter of remaining tasks ← unreliable, see Limits | ||
| 65 | +``` | ||
| 66 | + | ||
| 67 | +## Notable settings (`agent.little-coder.yaml`) | ||
| 68 | + | ||
| 69 | +| Key | Value | Why | | ||
| 70 | +|---|---|---| | ||
| 71 | +| `contextWindow` | 32768 | Matches `LLMMAN_CONTEXT_LENGTH`; if llmman serves more, mm simply compresses earlier. | | ||
| 72 | +| `context.threshold` | 60 | Compression around ~20k tokens, before the model loses track. | | ||
| 73 | +| `sampling.temperature` | 0.2 | Precise without being greedy (0.0 makes small models loop on long tool calls). | | ||
| 74 | +| `sampling.max_tokens` | 8192 | One whole file per `write_file`, without letting a runaway reply fill the window. | | ||
| 75 | +| `maxOutput` | 8000 | `go test` output fits without flooding a 32k window. | | ||
| 76 | +| `bashTimeout` / `watchdogTimeout` | 2m | A first `go build` of `net/http` on an Air. | | ||
| 77 | +| `agentsMd.maxChars` | 4000 | `AGENTS.md` goes out with every request: a guard rail if it grows. | | ||
| 78 | + | ||
| 79 | +## Limits | ||
| 80 | + | ||
| 81 | +- **Changing an existing application is not reliable with this model.** Out of 3 attempts at adding a counter: once the files were written outside the application (without the `htmx-tasks/` prefix) with an answer claiming success, once 40 turns without a change, once a broken build. `htmx-patterns` does hold the exact pattern (verified). A skill dedicated to changes (`go-htmx-change`) was tried then removed: it did no better, and its mere presence in the catalogue made one generation in three fail and drew htmx questions to it. 2.5B active parameters are not enough to chain several code files without a mistake. For this work: always check with `sh check.sh` yourself, go one small step per message, or switch to a bigger model (for instance the configuration in `../mini-mes/gopher/`). | ||
| 82 | +- The starter covers a **list of items** (main text + text fields + a checkbox), which covers many sample applications. Several entities, relations or persistence require changing the code — same limit as above. | ||
| 83 | +- Extra fields are free text, not validated; data is in memory, lost when the server stops. | ||
| 84 | +- Run it from this directory (relative paths to the skills and the starter). The `.gitignore` here ignores generated applications and the files a model might write at the root by mistake. | ||
| 85 | + | ||
| 86 | +## Files | ||
| 87 | + | ||
| 88 | +| File | Role | | ||
| 89 | +|---|---| | ||
| 90 | +| `agent.little-coder.yaml` | The agent's configuration, commented. | | ||
| 91 | +| `AGENTS.md` | The workspace rules, appended to the system prompt. | | ||
| 92 | +| `README.md` | This document in French. | | ||
| 93 | +| `skills/go-htmx-app/` | Create a sample application; holds the `starter/`. | | ||
| 94 | +| `skills/go-htmx-subject-app/` | Create an application about a given subject (adapt `app.go`). | | ||
| 95 | +| `skills/run-and-check/` | Check, run, diagnose and fix. | | ||
| 96 | +| `skills/htmx-patterns/` | htmx 2 reference, HTML side and Go handler side. | | ||
added
demo/agent.little-coder.yaml +169 -0 | new file mode 100644 | ||
| @@ -0,0 +1,169 @@ | ||
| 1 | +# Little Coder — a small Go + htmx web developer that fits on a MacBook Air. | |
| 2 | +# | |
| 3 | +# Model: JetBrains Mellum2 12B-A2.5B Instruct, Q4_K_M, served by llmman. | |
| 4 | +# - a mixture of experts: 12.1B parameters, ~2.5B active per token, so it | |
| 5 | +# answers at the speed of a 2–3B model; | |
| 6 | +# - no reasoning (its chat template has no thinking block): every token | |
| 7 | +# is code or a tool call; | |
| 8 | +# - trained on 8,192 tokens, stretched to 131,072 with YaRN: it is at its | |
| 9 | +# best on SHORT prompts. Everything below is sized for that. | |
| 10 | +# | |
| 11 | +# Memory on a 32 GB MacBook Air (Q4_K_M, f16 KV cache): | |
| 12 | +# weights ≈ 7.3 GB + KV cache ≈ 57 KB per token | |
| 13 | +# → 32k tokens ≈ 1.8 GB → ~9.5 GB in all (recommended) | |
| 14 | +# → 131k tokens ≈ 7.5 GB → ~15 GB in all (llmman's default for this model) | |
| 15 | +# So start llmman with a 32k window, which also leaves room for the browser, | |
| 16 | +# `go build` and the app being tested: | |
| 17 | +# | |
| 18 | +# llmman pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 19 | +# LLMMAN_CONTEXT_LENGTH=32768 llmman serve | |
| 20 | +# | |
| 21 | +# Run it FROM THIS DIRECTORY: the skills (skills/) and the project rules | |
| 22 | +# (AGENTS.md) are read from the directory mm is started in, and the apps it | |
| 23 | +# generates land here, one sub-directory each. | |
| 24 | +# | |
| 25 | +# cd demos/little-coder | |
| 26 | +# ../../mm -config agent.little-coder.yaml | |
| 27 | +# | |
| 28 | +# Every key is optional; reference: docs/en/reference/configuration.md. | |
| 29 | + | |
| 30 | +# ─── Model and server ──────────────────────────────────────────────────────── | |
| 31 | + | |
| 32 | +provider: llmman | |
| 33 | +model: hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 34 | + | |
| 35 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 36 | +fallback: http://host.docker.internal:17434/v1 | |
| 37 | + | |
| 38 | +# The window mm budgets for, whatever llmman serves: compression starts at | |
| 39 | +# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if | |
| 40 | +# llmman serves more, mm simply compresses earlier — never set it higher than | |
| 41 | +# what llmman serves. | |
| 42 | +contextWindow: 32768 | |
| 43 | + | |
| 44 | +# ─── Generation ────────────────────────────────────────────────────────────── | |
| 45 | + | |
| 46 | +sampling: | |
| 47 | + # Code wants precision. Not 0.0: greedy decoding is what sends a small | |
| 48 | + # model into repetition loops on long tool calls. | |
| 49 | + temperature: 0.2 | |
| 50 | + top_p: 0.9 | |
| 51 | + | |
| 52 | + # ONE reply = one tool call. A whole file in one write_file (main.go, | |
| 53 | + # a template) is 1–3k tokens; 8192 leaves margin without letting a runaway | |
| 54 | + # reply fill the window. | |
| 55 | + max_tokens: 8192 | |
| 56 | + | |
| 57 | + # One file, one command at a time. | |
| 58 | + parallel_tool_calls: false | |
| 59 | + | |
| 60 | +# Mellum does not reason; nothing to show. | |
| 61 | +showThinking: false | |
| 62 | + | |
| 63 | +# ─── Timeouts ──────────────────────────────────────────────────────────────── | |
| 64 | + | |
| 65 | +# Loading ~7 GB on the first question, then prompts of 10–20k tokens. | |
| 66 | +firstTokenTimeout: 3m | |
| 67 | + | |
| 68 | +# A first `go build` / `go test` downloads nothing (standard library only) | |
| 69 | +# but compiles net/http: under a minute on an M-series Air. | |
| 70 | +bashTimeout: 2m | |
| 71 | + | |
| 72 | +# Counts while a bash command runs: at least bashTimeout. | |
| 73 | +watchdogTimeout: 2m | |
| 74 | + | |
| 75 | +# ─── Agent loop ────────────────────────────────────────────────────────────── | |
| 76 | + | |
| 77 | +# A sample app is ~6 files + build + test + run + check + fixes. | |
| 78 | +maxTurns: 40 | |
| 79 | + | |
| 80 | +# go vet / go test failures are short; 8000 characters keep a whole report | |
| 81 | +# without filling a 32k window with logs. | |
| 82 | +maxOutput: 8000 | |
| 83 | + | |
| 84 | +previewLines: 15 | |
| 85 | +displayCommands: true | |
| 86 | + | |
| 87 | +# ─── Tools ─────────────────────────────────────────────────────────────────── | |
| 88 | + | |
| 89 | +# true: no question before each command or file write — generating an app is | |
| 90 | +# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one. | |
| 91 | +yolo: true | |
| 92 | + | |
| 93 | +bashTool: true | |
| 94 | +editTools: true | |
| 95 | + | |
| 96 | +# Every tool description is sent with every request: fewer tools = a shorter | |
| 97 | +# prompt and fewer wrong picks for a small model. The skills' numbered steps | |
| 98 | +# are the plan; no web access; no gopls (Mellum cannot compose lsp calls | |
| 99 | +# reliably — measured on rename — and gopls costs memory). | |
| 100 | +planTool: false | |
| 101 | +fetchContentTool: false | |
| 102 | + | |
| 103 | +# Four short procedures, relative to the directory mm starts in; the | |
| 104 | +# starter app (and its check.sh) lives in skills/go-htmx-app/starter. | |
| 105 | +skillsDir: skills | |
| 106 | + | |
| 107 | +# The rules of this workspace (Go version, stack, layout), appended to the | |
| 108 | +# system prompt. Kept short: it is sent with every request. | |
| 109 | +agentsMd: | |
| 110 | + enabled: true | |
| 111 | + path: AGENTS.md | |
| 112 | + # Guard rail: a file grown past ~1,000 tokens is skipped, not sent. | |
| 113 | + maxChars: 4000 | |
| 114 | + | |
| 115 | +# ─── Memory ────────────────────────────────────────────────────────────────── | |
| 116 | + | |
| 117 | +sessions: | |
| 118 | + enabled: true | |
| 119 | + dir: .mm/sessions | |
| 120 | + | |
| 121 | +context: | |
| 122 | + enabled: true | |
| 123 | + # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough | |
| 124 | + # for the model to lose track of the files it wrote. | |
| 125 | + threshold: 60 | |
| 126 | + maxMessages: 60 | |
| 127 | + keepLastTurns: 2 | |
| 128 | + summaryMaxTokens: 1200 | |
| 129 | + showStats: true | |
| 130 | + | |
| 131 | +# ─── Identity ──────────────────────────────────────────────────────────────── | |
| 132 | + | |
| 133 | +acp: | |
| 134 | + name: little-coder | |
| 135 | + title: Little Coder (Go + htmx) | |
| 136 | + | |
| 137 | +# Short on purpose: measured on this model, a longer prompt costs more in | |
| 138 | +# tool-call mistakes than it gives in guidance. The details live in the | |
| 139 | +# skills, loaded only when needed. | |
| 140 | +system: | | |
| 141 | + You are Little Coder, a Go and htmx web developer working in a terminal, | |
| 142 | + in the current directory. | |
| 143 | + | |
| 144 | + TOOLS | |
| 145 | + - `read_skill` loads a procedure. Any request to create a web app, add a | |
| 146 | + page or an htmx interaction, or run and check an app has one: call | |
| 147 | + `read_skill` FIRST, then follow its steps in order. | |
| 148 | + - `write_file` creates a file: ONE complete file per call. | |
| 149 | + - `read_file` then `edit_file` change an existing file; `old` is copied | |
| 150 | + exactly from what read_file returned. | |
| 151 | + - `bash` runs commands: go mod, gofmt, go vet, go build, go test, | |
| 152 | + curl. Each call is a new shell: start with `cd <dir> &&`. | |
| 153 | + | |
| 154 | + PATHS | |
| 155 | + The current directory never changes: a `cd` lasts one bash command. File | |
| 156 | + paths always start with the app's directory: `htmx-tasks/main.go`, never | |
| 157 | + `main.go`. | |
| 158 | + | |
| 159 | + RULES | |
| 160 | + - Facts come from tools: never say the code builds, the tests pass or a | |
| 161 | + page works without the command output in this answer. | |
| 162 | + - When a command fails, read the error, fix the file it names, run the | |
| 163 | + command again. Never delete a file to make an error go away. | |
| 164 | + - Never start a server with `go run .` in bash: it never returns. An | |
| 165 | + app's `check.sh` starts, queries and stops it. | |
| 166 | + - Write files with tools, never as code blocks in your answer. | |
| 167 | + - Answer in the language the user wrote in — French if they wrote French, | |
| 168 | + English if they wrote English — briefly: the files created, the commands | |
| 169 | + run and their result, how to start the app. | |
| new file mode 100644 | |||
| @@ -0,0 +1,169 @@ | |||
| 1 | +# Little Coder — a small Go + htmx web developer that fits on a MacBook Air. | ||
| 2 | +# | ||
| 3 | +# Model: JetBrains Mellum2 12B-A2.5B Instruct, Q4_K_M, served by llmman. | ||
| 4 | +# - a mixture of experts: 12.1B parameters, ~2.5B active per token, so it | ||
| 5 | +# answers at the speed of a 2–3B model; | ||
| 6 | +# - no reasoning (its chat template has no thinking block): every token | ||
| 7 | +# is code or a tool call; | ||
| 8 | +# - trained on 8,192 tokens, stretched to 131,072 with YaRN: it is at its | ||
| 9 | +# best on SHORT prompts. Everything below is sized for that. | ||
| 10 | +# | ||
| 11 | +# Memory on a 32 GB MacBook Air (Q4_K_M, f16 KV cache): | ||
| 12 | +# weights ≈ 7.3 GB + KV cache ≈ 57 KB per token | ||
| 13 | +# → 32k tokens ≈ 1.8 GB → ~9.5 GB in all (recommended) | ||
| 14 | +# → 131k tokens ≈ 7.5 GB → ~15 GB in all (llmman's default for this model) | ||
| 15 | +# So start llmman with a 32k window, which also leaves room for the browser, | ||
| 16 | +# `go build` and the app being tested: | ||
| 17 | +# | ||
| 18 | +# llmman pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 19 | +# LLMMAN_CONTEXT_LENGTH=32768 llmman serve | ||
| 20 | +# | ||
| 21 | +# Run it FROM THIS DIRECTORY: the skills (skills/) and the project rules | ||
| 22 | +# (AGENTS.md) are read from the directory mm is started in, and the apps it | ||
| 23 | +# generates land here, one sub-directory each. | ||
| 24 | +# | ||
| 25 | +# cd demos/little-coder | ||
| 26 | +# ../../mm -config agent.little-coder.yaml | ||
| 27 | +# | ||
| 28 | +# Every key is optional; reference: docs/en/reference/configuration.md. | ||
| 29 | + | ||
| 30 | +# ─── Model and server ──────────────────────────────────────────────────────── | ||
| 31 | + | ||
| 32 | +provider: llmman | ||
| 33 | +model: hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 34 | + | ||
| 35 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 36 | +fallback: http://host.docker.internal:17434/v1 | ||
| 37 | + | ||
| 38 | +# The window mm budgets for, whatever llmman serves: compression starts at | ||
| 39 | +# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if | ||
| 40 | +# llmman serves more, mm simply compresses earlier — never set it higher than | ||
| 41 | +# what llmman serves. | ||
| 42 | +contextWindow: 32768 | ||
| 43 | + | ||
| 44 | +# ─── Generation ────────────────────────────────────────────────────────────── | ||
| 45 | + | ||
| 46 | +sampling: | ||
| 47 | + # Code wants precision. Not 0.0: greedy decoding is what sends a small | ||
| 48 | + # model into repetition loops on long tool calls. | ||
| 49 | + temperature: 0.2 | ||
| 50 | + top_p: 0.9 | ||
| 51 | + | ||
| 52 | + # ONE reply = one tool call. A whole file in one write_file (main.go, | ||
| 53 | + # a template) is 1–3k tokens; 8192 leaves margin without letting a runaway | ||
| 54 | + # reply fill the window. | ||
| 55 | + max_tokens: 8192 | ||
| 56 | + | ||
| 57 | + # One file, one command at a time. | ||
| 58 | + parallel_tool_calls: false | ||
| 59 | + | ||
| 60 | +# Mellum does not reason; nothing to show. | ||
| 61 | +showThinking: false | ||
| 62 | + | ||
| 63 | +# ─── Timeouts ──────────────────────────────────────────────────────────────── | ||
| 64 | + | ||
| 65 | +# Loading ~7 GB on the first question, then prompts of 10–20k tokens. | ||
| 66 | +firstTokenTimeout: 3m | ||
| 67 | + | ||
| 68 | +# A first `go build` / `go test` downloads nothing (standard library only) | ||
| 69 | +# but compiles net/http: under a minute on an M-series Air. | ||
| 70 | +bashTimeout: 2m | ||
| 71 | + | ||
| 72 | +# Counts while a bash command runs: at least bashTimeout. | ||
| 73 | +watchdogTimeout: 2m | ||
| 74 | + | ||
| 75 | +# ─── Agent loop ────────────────────────────────────────────────────────────── | ||
| 76 | + | ||
| 77 | +# A sample app is ~6 files + build + test + run + check + fixes. | ||
| 78 | +maxTurns: 40 | ||
| 79 | + | ||
| 80 | +# go vet / go test failures are short; 8000 characters keep a whole report | ||
| 81 | +# without filling a 32k window with logs. | ||
| 82 | +maxOutput: 8000 | ||
| 83 | + | ||
| 84 | +previewLines: 15 | ||
| 85 | +displayCommands: true | ||
| 86 | + | ||
| 87 | +# ─── Tools ─────────────────────────────────────────────────────────────────── | ||
| 88 | + | ||
| 89 | +# true: no question before each command or file write — generating an app is | ||
| 90 | +# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one. | ||
| 91 | +yolo: true | ||
| 92 | + | ||
| 93 | +bashTool: true | ||
| 94 | +editTools: true | ||
| 95 | + | ||
| 96 | +# Every tool description is sent with every request: fewer tools = a shorter | ||
| 97 | +# prompt and fewer wrong picks for a small model. The skills' numbered steps | ||
| 98 | +# are the plan; no web access; no gopls (Mellum cannot compose lsp calls | ||
| 99 | +# reliably — measured on rename — and gopls costs memory). | ||
| 100 | +planTool: false | ||
| 101 | +fetchContentTool: false | ||
| 102 | + | ||
| 103 | +# Four short procedures, relative to the directory mm starts in; the | ||
| 104 | +# starter app (and its check.sh) lives in skills/go-htmx-app/starter. | ||
| 105 | +skillsDir: skills | ||
| 106 | + | ||
| 107 | +# The rules of this workspace (Go version, stack, layout), appended to the | ||
| 108 | +# system prompt. Kept short: it is sent with every request. | ||
| 109 | +agentsMd: | ||
| 110 | + enabled: true | ||
| 111 | + path: AGENTS.md | ||
| 112 | + # Guard rail: a file grown past ~1,000 tokens is skipped, not sent. | ||
| 113 | + maxChars: 4000 | ||
| 114 | + | ||
| 115 | +# ─── Memory ────────────────────────────────────────────────────────────────── | ||
| 116 | + | ||
| 117 | +sessions: | ||
| 118 | + enabled: true | ||
| 119 | + dir: .mm/sessions | ||
| 120 | + | ||
| 121 | +context: | ||
| 122 | + enabled: true | ||
| 123 | + # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough | ||
| 124 | + # for the model to lose track of the files it wrote. | ||
| 125 | + threshold: 60 | ||
| 126 | + maxMessages: 60 | ||
| 127 | + keepLastTurns: 2 | ||
| 128 | + summaryMaxTokens: 1200 | ||
| 129 | + showStats: true | ||
| 130 | + | ||
| 131 | +# ─── Identity ──────────────────────────────────────────────────────────────── | ||
| 132 | + | ||
| 133 | +acp: | ||
| 134 | + name: little-coder | ||
| 135 | + title: Little Coder (Go + htmx) | ||
| 136 | + | ||
| 137 | +# Short on purpose: measured on this model, a longer prompt costs more in | ||
| 138 | +# tool-call mistakes than it gives in guidance. The details live in the | ||
| 139 | +# skills, loaded only when needed. | ||
| 140 | +system: | | ||
| 141 | + You are Little Coder, a Go and htmx web developer working in a terminal, | ||
| 142 | + in the current directory. | ||
| 143 | + | ||
| 144 | + TOOLS | ||
| 145 | + - `read_skill` loads a procedure. Any request to create a web app, add a | ||
| 146 | + page or an htmx interaction, or run and check an app has one: call | ||
| 147 | + `read_skill` FIRST, then follow its steps in order. | ||
| 148 | + - `write_file` creates a file: ONE complete file per call. | ||
| 149 | + - `read_file` then `edit_file` change an existing file; `old` is copied | ||
| 150 | + exactly from what read_file returned. | ||
| 151 | + - `bash` runs commands: go mod, gofmt, go vet, go build, go test, | ||
| 152 | + curl. Each call is a new shell: start with `cd <dir> &&`. | ||
| 153 | + | ||
| 154 | + PATHS | ||
| 155 | + The current directory never changes: a `cd` lasts one bash command. File | ||
| 156 | + paths always start with the app's directory: `htmx-tasks/main.go`, never | ||
| 157 | + `main.go`. | ||
| 158 | + | ||
| 159 | + RULES | ||
| 160 | + - Facts come from tools: never say the code builds, the tests pass or a | ||
| 161 | + page works without the command output in this answer. | ||
| 162 | + - When a command fails, read the error, fix the file it names, run the | ||
| 163 | + command again. Never delete a file to make an error go away. | ||
| 164 | + - Never start a server with `go run .` in bash: it never returns. An | ||
| 165 | + app's `check.sh` starts, queries and stops it. | ||
| 166 | + - Write files with tools, never as code blocks in your answer. | ||
| 167 | + - Answer in the language the user wrote in — French if they wrote French, | ||
| 168 | + English if they wrote English — briefly: the files created, the commands | ||
| 169 | + run and their result, how to start the app. | ||
added
demo/skills/go-htmx-app/SKILL.md +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +--- | |
| 2 | +name: go-htmx-app | |
| 3 | +description: Create a sample (demo, example) web application in modern Go with htmx and a responsive page, when the user gives no particular subject — or asks for a todo / task list. | |
| 4 | +keywords: exemple, démo, sample, demo, todo, tâches, génère, application | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Create a sample Go + htmx web app | |
| 8 | + | |
| 9 | +Every file path starts with the app directory: `<name>/README.md` — never `README.md` alone. | |
| 10 | + | |
| 11 | +## 1. Name | |
| 12 | + | |
| 13 | +From the request, kebab-case. Nothing given: `htmx-tasks`. | |
| 14 | + | |
| 15 | +## 2. Copy the starter | |
| 16 | + | |
| 17 | +The starter is a COMPLETE app, already tested: a task list with add, mark done, delete, live search, a responsive page, graceful shutdown, a table-driven test of every route, and `check.sh`. Its texts and fields are in `app.go`. It IS the app: do not read or change its files. Call your `bash` tool with this command: | |
| 18 | + | |
| 19 | + mkdir -p <name> && cp -rn skills/go-htmx-app/starter/. <name>/ && cd <name> && go mod edit -module <name> && ls -R | |
| 20 | + | |
| 21 | +## 3. Check | |
| 22 | + | |
| 23 | +Call your `bash` tool with this command: | |
| 24 | + | |
| 25 | + cd <name> && sh check.sh | |
| 26 | + | |
| 27 | +It formats, vets, tests, starts the app, queries it and stops it. It ends with `CHECK OK`. Anything else: read the `run-and-check` skill. | |
| 28 | + | |
| 29 | +## 4. README | |
| 30 | + | |
| 31 | +The starter has an English `<name>/README.md`. Request not in English: one `write_file` call on `<name>/README.md` with it translated into the user's language: what the app does, the stack (Go standard library, htmx 2, Pico CSS), the routes, how to run it (`go run .` then http://localhost:8080), test it (`go test ./...`), check it (`sh check.sh`), and that `app.go` holds the texts and fields. | |
| 32 | + | |
| 33 | +Routes: `GET /` page · `POST /items` add (fragment `item`) · `GET /items/search?q=` filter (fragment `items`) · `POST /items/{id}/toggle` done/undone (fragment `item`) · `DELETE /items/{id}` remove (empty answer). | |
| 34 | + | |
| 35 | +## 5. Answer | |
| 36 | + | |
| 37 | +A few lines in the user's language — not the README again: the files, the routes, the `CHECK OK` result, and how to start it: `cd <name> && go run .`, then open http://localhost:8080. | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +--- | ||
| 2 | +name: go-htmx-app | ||
| 3 | +description: Create a sample (demo, example) web application in modern Go with htmx and a responsive page, when the user gives no particular subject — or asks for a todo / task list. | ||
| 4 | +keywords: exemple, démo, sample, demo, todo, tâches, génère, application | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Create a sample Go + htmx web app | ||
| 8 | + | ||
| 9 | +Every file path starts with the app directory: `<name>/README.md` — never `README.md` alone. | ||
| 10 | + | ||
| 11 | +## 1. Name | ||
| 12 | + | ||
| 13 | +From the request, kebab-case. Nothing given: `htmx-tasks`. | ||
| 14 | + | ||
| 15 | +## 2. Copy the starter | ||
| 16 | + | ||
| 17 | +The starter is a COMPLETE app, already tested: a task list with add, mark done, delete, live search, a responsive page, graceful shutdown, a table-driven test of every route, and `check.sh`. Its texts and fields are in `app.go`. It IS the app: do not read or change its files. Call your `bash` tool with this command: | ||
| 18 | + | ||
| 19 | + mkdir -p <name> && cp -rn skills/go-htmx-app/starter/. <name>/ && cd <name> && go mod edit -module <name> && ls -R | ||
| 20 | + | ||
| 21 | +## 3. Check | ||
| 22 | + | ||
| 23 | +Call your `bash` tool with this command: | ||
| 24 | + | ||
| 25 | + cd <name> && sh check.sh | ||
| 26 | + | ||
| 27 | +It formats, vets, tests, starts the app, queries it and stops it. It ends with `CHECK OK`. Anything else: read the `run-and-check` skill. | ||
| 28 | + | ||
| 29 | +## 4. README | ||
| 30 | + | ||
| 31 | +The starter has an English `<name>/README.md`. Request not in English: one `write_file` call on `<name>/README.md` with it translated into the user's language: what the app does, the stack (Go standard library, htmx 2, Pico CSS), the routes, how to run it (`go run .` then http://localhost:8080), test it (`go test ./...`), check it (`sh check.sh`), and that `app.go` holds the texts and fields. | ||
| 32 | + | ||
| 33 | +Routes: `GET /` page · `POST /items` add (fragment `item`) · `GET /items/search?q=` filter (fragment `items`) · `POST /items/{id}/toggle` done/undone (fragment `item`) · `DELETE /items/{id}` remove (empty answer). | ||
| 34 | + | ||
| 35 | +## 5. Answer | ||
| 36 | + | ||
| 37 | +A few lines in the user's language — not the README again: the files, the routes, the `CHECK OK` result, and how to start it: `cd <name> && go run .`, then open http://localhost:8080. | ||
added
demo/skills/go-htmx-app/starter/README.md +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +# htmx list app | |
| 2 | + | |
| 3 | +A small web application in modern Go with htmx: a list you can add to, tick, delete from and search live, on a responsive page. Every action swaps one HTML fragment — no JSON, no JavaScript to write. | |
| 4 | + | |
| 5 | +## Stack | |
| 6 | + | |
| 7 | +- Go standard library only: `net/http` (method + path routing), `html/template`, `embed`, `log/slog`. | |
| 8 | +- htmx 2 and Pico CSS from a CDN. | |
| 9 | +- Data in memory, behind a `sync.Mutex` (lost when the server stops). | |
| 10 | + | |
| 11 | +## Run | |
| 12 | + | |
| 13 | + go run . # http://localhost:8080 | |
| 14 | + PORT=9000 go run . # another port | |
| 15 | + | |
| 16 | +## Test and check | |
| 17 | + | |
| 18 | + go test ./... | |
| 19 | + sh check.sh # gofmt, vet, tests, then starts the app, queries it with curl, stops it: ends with CHECK OK | |
| 20 | + | |
| 21 | +## Routes | |
| 22 | + | |
| 23 | +| Route | Answer | | |
| 24 | +|---|---| | |
| 25 | +| `GET /` | the page | | |
| 26 | +| `POST /items` | add an item — fragment `item` | | |
| 27 | +| `GET /items/search?q=` | filter — fragment `items` | | |
| 28 | +| `POST /items/{id}/toggle` | done / not done — fragment `item` | | |
| 29 | +| `DELETE /items/{id}` | remove — empty answer, htmx removes the line | | |
| 30 | + | |
| 31 | +## Adapt it | |
| 32 | + | |
| 33 | +`app.go` holds the texts, the extra fields and the start-up data: the page, the handlers and the tests all read it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +# htmx list app | ||
| 2 | + | ||
| 3 | +A small web application in modern Go with htmx: a list you can add to, tick, delete from and search live, on a responsive page. Every action swaps one HTML fragment — no JSON, no JavaScript to write. | ||
| 4 | + | ||
| 5 | +## Stack | ||
| 6 | + | ||
| 7 | +- Go standard library only: `net/http` (method + path routing), `html/template`, `embed`, `log/slog`. | ||
| 8 | +- htmx 2 and Pico CSS from a CDN. | ||
| 9 | +- Data in memory, behind a `sync.Mutex` (lost when the server stops). | ||
| 10 | + | ||
| 11 | +## Run | ||
| 12 | + | ||
| 13 | + go run . # http://localhost:8080 | ||
| 14 | + PORT=9000 go run . # another port | ||
| 15 | + | ||
| 16 | +## Test and check | ||
| 17 | + | ||
| 18 | + go test ./... | ||
| 19 | + sh check.sh # gofmt, vet, tests, then starts the app, queries it with curl, stops it: ends with CHECK OK | ||
| 20 | + | ||
| 21 | +## Routes | ||
| 22 | + | ||
| 23 | +| Route | Answer | | ||
| 24 | +|---|---| | ||
| 25 | +| `GET /` | the page | | ||
| 26 | +| `POST /items` | add an item — fragment `item` | | ||
| 27 | +| `GET /items/search?q=` | filter — fragment `items` | | ||
| 28 | +| `POST /items/{id}/toggle` | done / not done — fragment `item` | | ||
| 29 | +| `DELETE /items/{id}` | remove — empty answer, htmx removes the line | | ||
| 30 | + | ||
| 31 | +## Adapt it | ||
| 32 | + | ||
| 33 | +`app.go` holds the texts, the extra fields and the start-up data: the page, the handlers and the tests all read it. | ||
added
demo/skills/go-htmx-app/starter/app.go +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +// app describes what this list holds. To adapt the app to another subject, | |
| 4 | +// rewrite only this file: the page, the handlers and the tests all read it. | |
| 5 | +// The types are in appinfo.go. | |
| 6 | +var app = appInfo{ | |
| 7 | + Lang: "en", | |
| 8 | + Title: "htmx tasks", | |
| 9 | + TitleLabel: "Task", | |
| 10 | + Fields: []string{}, | |
| 11 | + DoneLabel: "Done", | |
| 12 | + AddLabel: "Add", | |
| 13 | + SearchLabel: "Search", | |
| 14 | + DeleteConfirm: "Delete this task?", | |
| 15 | + Seeds: []seed{ | |
| 16 | + {Title: "Read the htmx docs"}, | |
| 17 | + {Title: "Write a Go handler"}, | |
| 18 | + }, | |
| 19 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +// app describes what this list holds. To adapt the app to another subject, | ||
| 4 | +// rewrite only this file: the page, the handlers and the tests all read it. | ||
| 5 | +// The types are in appinfo.go. | ||
| 6 | +var app = appInfo{ | ||
| 7 | + Lang: "en", | ||
| 8 | + Title: "htmx tasks", | ||
| 9 | + TitleLabel: "Task", | ||
| 10 | + Fields: []string{}, | ||
| 11 | + DoneLabel: "Done", | ||
| 12 | + AddLabel: "Add", | ||
| 13 | + SearchLabel: "Search", | ||
| 14 | + DeleteConfirm: "Delete this task?", | ||
| 15 | + Seeds: []seed{ | ||
| 16 | + {Title: "Read the htmx docs"}, | ||
| 17 | + {Title: "Write a Go handler"}, | ||
| 18 | + }, | ||
| 19 | +} | ||
added
demo/skills/go-htmx-app/starter/appinfo.go +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +// appInfo holds the texts of the page and the shape of an item. | |
| 4 | +type appInfo struct { | |
| 5 | + Lang string // html lang attribute: "en", "fr"… | |
| 6 | + Title string // page title and heading | |
| 7 | + TitleLabel string // label of the main text of an item | |
| 8 | + Fields []string // labels of the extra text fields, e.g. {"Author", "Year"} | |
| 9 | + DoneLabel string // label of the done checkbox: "Done", "Read"…; "" = no checkbox | |
| 10 | + AddLabel string // text of the add button | |
| 11 | + SearchLabel string // placeholder of the search box | |
| 12 | + DeleteConfirm string // question asked before a delete | |
| 13 | + Seeds []seed // items present at start-up | |
| 14 | +} | |
| 15 | + | |
| 16 | +// seed is an item present at start-up: one value per entry of Fields. | |
| 17 | +type seed struct { | |
| 18 | + Title string | |
| 19 | + Fields []string | |
| 20 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +// appInfo holds the texts of the page and the shape of an item. | ||
| 4 | +type appInfo struct { | ||
| 5 | + Lang string // html lang attribute: "en", "fr"… | ||
| 6 | + Title string // page title and heading | ||
| 7 | + TitleLabel string // label of the main text of an item | ||
| 8 | + Fields []string // labels of the extra text fields, e.g. {"Author", "Year"} | ||
| 9 | + DoneLabel string // label of the done checkbox: "Done", "Read"…; "" = no checkbox | ||
| 10 | + AddLabel string // text of the add button | ||
| 11 | + SearchLabel string // placeholder of the search box | ||
| 12 | + DeleteConfirm string // question asked before a delete | ||
| 13 | + Seeds []seed // items present at start-up | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// seed is an item present at start-up: one value per entry of Fields. | ||
| 17 | +type seed struct { | ||
| 18 | + Title string | ||
| 19 | + Fields []string | ||
| 20 | +} | ||
added
demo/skills/go-htmx-app/starter/check.sh +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# check.sh — format (gofmt -w), vet, test, then build the app, start it in the | |
| 3 | +# background, query it like a browser and like htmx, and stop it. | |
| 4 | +# Usage: sh check.sh [port] (default port 8099) | |
| 5 | +# Prints "CHECK OK" at the end when everything passed. | |
| 6 | +port="${1:-8099}" | |
| 7 | +bin="$(mktemp -d)/app" | |
| 8 | + | |
| 9 | +echo "== gofmt" | |
| 10 | +unformatted="$(gofmt -l .)" | |
| 11 | +if [ -n "$unformatted" ]; then | |
| 12 | + gofmt -w . && echo "formatted: $unformatted" | |
| 13 | +fi | |
| 14 | +echo "== go vet" | |
| 15 | +go vet ./... || exit 1 | |
| 16 | +echo "== go test" | |
| 17 | +go test ./... 2>&1 | tail -30 | |
| 18 | +go test ./... >/dev/null 2>&1 || exit 1 | |
| 19 | +echo "== go build" | |
| 20 | +go build -o "$bin" . || exit 1 | |
| 21 | + | |
| 22 | +echo "== run on port $port" | |
| 23 | +if curl -s -o /dev/null "localhost:$port/"; then | |
| 24 | + echo "CHECK FAILED: port $port is already used by another server — run: sh check.sh 8098"; exit 1 | |
| 25 | +fi | |
| 26 | +PORT="$port" "$bin" > "$bin.log" 2>&1 & | |
| 27 | +app=$! | |
| 28 | +sleep 2 | |
| 29 | +if ! kill -0 "$app" 2>/dev/null; then | |
| 30 | + echo "CHECK FAILED: the app stopped at start-up:"; cat "$bin.log"; exit 1 | |
| 31 | +fi | |
| 32 | +page="$(curl -s -o /dev/null -w '%{http_code}' "localhost:$port/")" | |
| 33 | +title="$(curl -s "localhost:$port/" | sed -n 's:.*<title>\(.*\)</title>.*:\1:p')" | |
| 34 | +echo "GET / -> $page, page title: $title" | |
| 35 | +echo "POST /items (htmx) ->" | |
| 36 | +fragment="$(curl -s -X POST -H 'HX-Request: true' -d 'title=check' "localhost:$port/items")" | |
| 37 | +echo "$fragment" | head -5 | |
| 38 | +kill "$app" 2>/dev/null | |
| 39 | +wait "$app" 2>/dev/null | |
| 40 | +echo "== server log" | |
| 41 | +cat "$bin.log" | |
| 42 | + | |
| 43 | +[ "$page" = "200" ] || { echo "CHECK FAILED: GET / answered $page"; exit 1; } | |
| 44 | +case "$fragment" in | |
| 45 | + *"<li"*) echo "CHECK OK" ;; | |
| 46 | + *) echo "CHECK FAILED: POST /items did not answer an <li> fragment"; exit 1 ;; | |
| 47 | +esac | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# check.sh — format (gofmt -w), vet, test, then build the app, start it in the | ||
| 3 | +# background, query it like a browser and like htmx, and stop it. | ||
| 4 | +# Usage: sh check.sh [port] (default port 8099) | ||
| 5 | +# Prints "CHECK OK" at the end when everything passed. | ||
| 6 | +port="${1:-8099}" | ||
| 7 | +bin="$(mktemp -d)/app" | ||
| 8 | + | ||
| 9 | +echo "== gofmt" | ||
| 10 | +unformatted="$(gofmt -l .)" | ||
| 11 | +if [ -n "$unformatted" ]; then | ||
| 12 | + gofmt -w . && echo "formatted: $unformatted" | ||
| 13 | +fi | ||
| 14 | +echo "== go vet" | ||
| 15 | +go vet ./... || exit 1 | ||
| 16 | +echo "== go test" | ||
| 17 | +go test ./... 2>&1 | tail -30 | ||
| 18 | +go test ./... >/dev/null 2>&1 || exit 1 | ||
| 19 | +echo "== go build" | ||
| 20 | +go build -o "$bin" . || exit 1 | ||
| 21 | + | ||
| 22 | +echo "== run on port $port" | ||
| 23 | +if curl -s -o /dev/null "localhost:$port/"; then | ||
| 24 | + echo "CHECK FAILED: port $port is already used by another server — run: sh check.sh 8098"; exit 1 | ||
| 25 | +fi | ||
| 26 | +PORT="$port" "$bin" > "$bin.log" 2>&1 & | ||
| 27 | +app=$! | ||
| 28 | +sleep 2 | ||
| 29 | +if ! kill -0 "$app" 2>/dev/null; then | ||
| 30 | + echo "CHECK FAILED: the app stopped at start-up:"; cat "$bin.log"; exit 1 | ||
| 31 | +fi | ||
| 32 | +page="$(curl -s -o /dev/null -w '%{http_code}' "localhost:$port/")" | ||
| 33 | +title="$(curl -s "localhost:$port/" | sed -n 's:.*<title>\(.*\)</title>.*:\1:p')" | ||
| 34 | +echo "GET / -> $page, page title: $title" | ||
| 35 | +echo "POST /items (htmx) ->" | ||
| 36 | +fragment="$(curl -s -X POST -H 'HX-Request: true' -d 'title=check' "localhost:$port/items")" | ||
| 37 | +echo "$fragment" | head -5 | ||
| 38 | +kill "$app" 2>/dev/null | ||
| 39 | +wait "$app" 2>/dev/null | ||
| 40 | +echo "== server log" | ||
| 41 | +cat "$bin.log" | ||
| 42 | + | ||
| 43 | +[ "$page" = "200" ] || { echo "CHECK FAILED: GET / answered $page"; exit 1; } | ||
| 44 | +case "$fragment" in | ||
| 45 | + *"<li"*) echo "CHECK OK" ;; | ||
| 46 | + *) echo "CHECK FAILED: POST /items did not answer an <li> fragment"; exit 1 ;; | ||
| 47 | +esac | ||
added
demo/skills/go-htmx-app/starter/go.mod +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +module htmx-app | |
| 2 | + | |
| 3 | +go 1.22 | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +module htmx-app | ||
| 2 | + | ||
| 3 | +go 1.22 | ||
added
demo/skills/go-htmx-app/starter/handlers.go +81 -0 | new file mode 100644 | ||
| @@ -0,0 +1,81 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "html/template" | |
| 5 | + "log/slog" | |
| 6 | + "net/http" | |
| 7 | + "strconv" | |
| 8 | + "strings" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type handlers struct { | |
| 12 | + store *store | |
| 13 | + tmpl *template.Template | |
| 14 | +} | |
| 15 | + | |
| 16 | +func (h *handlers) routes() http.Handler { | |
| 17 | + mux := http.NewServeMux() | |
| 18 | + mux.HandleFunc("GET /{$}", h.index) | |
| 19 | + mux.HandleFunc("POST /items", h.create) | |
| 20 | + mux.HandleFunc("GET /items/search", h.search) | |
| 21 | + mux.HandleFunc("POST /items/{id}/toggle", h.toggle) | |
| 22 | + mux.HandleFunc("DELETE /items/{id}", h.delete) | |
| 23 | + return mux | |
| 24 | +} | |
| 25 | + | |
| 26 | +// render writes a template: "index.html" for the page, "items" or "item" for htmx fragments. | |
| 27 | +func (h *handlers) render(w http.ResponseWriter, name string, data any) { | |
| 28 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 29 | + if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { | |
| 30 | + slog.Error("render", "template", name, "err", err) | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +func (h *handlers) index(w http.ResponseWriter, r *http.Request) { | |
| 35 | + h.render(w, "index.html", map[string]any{"Items": h.store.List()}) | |
| 36 | +} | |
| 37 | + | |
| 38 | +func (h *handlers) create(w http.ResponseWriter, r *http.Request) { | |
| 39 | + title := strings.TrimSpace(r.FormValue("title")) | |
| 40 | + if title == "" { | |
| 41 | + http.Error(w, "title is required", http.StatusBadRequest) | |
| 42 | + return | |
| 43 | + } | |
| 44 | + fields := make([]string, len(app.Fields)) | |
| 45 | + for i := range app.Fields { | |
| 46 | + fields[i] = strings.TrimSpace(r.FormValue("field" + strconv.Itoa(i))) | |
| 47 | + } | |
| 48 | + h.render(w, "item", h.store.Add(title, fields)) | |
| 49 | +} | |
| 50 | + | |
| 51 | +func (h *handlers) search(w http.ResponseWriter, r *http.Request) { | |
| 52 | + h.render(w, "items", h.store.Search(r.URL.Query().Get("q"))) | |
| 53 | +} | |
| 54 | + | |
| 55 | +func (h *handlers) toggle(w http.ResponseWriter, r *http.Request) { | |
| 56 | + id, err := strconv.Atoi(r.PathValue("id")) | |
| 57 | + if err != nil { | |
| 58 | + http.Error(w, "bad id", http.StatusBadRequest) | |
| 59 | + return | |
| 60 | + } | |
| 61 | + it, ok := h.store.Toggle(id) | |
| 62 | + if !ok { | |
| 63 | + http.NotFound(w, r) | |
| 64 | + return | |
| 65 | + } | |
| 66 | + h.render(w, "item", it) | |
| 67 | +} | |
| 68 | + | |
| 69 | +// delete answers 200 with an empty body: htmx swaps the <li> for nothing. | |
| 70 | +func (h *handlers) delete(w http.ResponseWriter, r *http.Request) { | |
| 71 | + id, err := strconv.Atoi(r.PathValue("id")) | |
| 72 | + if err != nil { | |
| 73 | + http.Error(w, "bad id", http.StatusBadRequest) | |
| 74 | + return | |
| 75 | + } | |
| 76 | + if !h.store.Delete(id) { | |
| 77 | + http.NotFound(w, r) | |
| 78 | + return | |
| 79 | + } | |
| 80 | + w.WriteHeader(http.StatusOK) | |
| 81 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,81 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "html/template" | ||
| 5 | + "log/slog" | ||
| 6 | + "net/http" | ||
| 7 | + "strconv" | ||
| 8 | + "strings" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +type handlers struct { | ||
| 12 | + store *store | ||
| 13 | + tmpl *template.Template | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +func (h *handlers) routes() http.Handler { | ||
| 17 | + mux := http.NewServeMux() | ||
| 18 | + mux.HandleFunc("GET /{$}", h.index) | ||
| 19 | + mux.HandleFunc("POST /items", h.create) | ||
| 20 | + mux.HandleFunc("GET /items/search", h.search) | ||
| 21 | + mux.HandleFunc("POST /items/{id}/toggle", h.toggle) | ||
| 22 | + mux.HandleFunc("DELETE /items/{id}", h.delete) | ||
| 23 | + return mux | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// render writes a template: "index.html" for the page, "items" or "item" for htmx fragments. | ||
| 27 | +func (h *handlers) render(w http.ResponseWriter, name string, data any) { | ||
| 28 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| 29 | + if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { | ||
| 30 | + slog.Error("render", "template", name, "err", err) | ||
| 31 | + } | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func (h *handlers) index(w http.ResponseWriter, r *http.Request) { | ||
| 35 | + h.render(w, "index.html", map[string]any{"Items": h.store.List()}) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +func (h *handlers) create(w http.ResponseWriter, r *http.Request) { | ||
| 39 | + title := strings.TrimSpace(r.FormValue("title")) | ||
| 40 | + if title == "" { | ||
| 41 | + http.Error(w, "title is required", http.StatusBadRequest) | ||
| 42 | + return | ||
| 43 | + } | ||
| 44 | + fields := make([]string, len(app.Fields)) | ||
| 45 | + for i := range app.Fields { | ||
| 46 | + fields[i] = strings.TrimSpace(r.FormValue("field" + strconv.Itoa(i))) | ||
| 47 | + } | ||
| 48 | + h.render(w, "item", h.store.Add(title, fields)) | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +func (h *handlers) search(w http.ResponseWriter, r *http.Request) { | ||
| 52 | + h.render(w, "items", h.store.Search(r.URL.Query().Get("q"))) | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +func (h *handlers) toggle(w http.ResponseWriter, r *http.Request) { | ||
| 56 | + id, err := strconv.Atoi(r.PathValue("id")) | ||
| 57 | + if err != nil { | ||
| 58 | + http.Error(w, "bad id", http.StatusBadRequest) | ||
| 59 | + return | ||
| 60 | + } | ||
| 61 | + it, ok := h.store.Toggle(id) | ||
| 62 | + if !ok { | ||
| 63 | + http.NotFound(w, r) | ||
| 64 | + return | ||
| 65 | + } | ||
| 66 | + h.render(w, "item", it) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// delete answers 200 with an empty body: htmx swaps the <li> for nothing. | ||
| 70 | +func (h *handlers) delete(w http.ResponseWriter, r *http.Request) { | ||
| 71 | + id, err := strconv.Atoi(r.PathValue("id")) | ||
| 72 | + if err != nil { | ||
| 73 | + http.Error(w, "bad id", http.StatusBadRequest) | ||
| 74 | + return | ||
| 75 | + } | ||
| 76 | + if !h.store.Delete(id) { | ||
| 77 | + http.NotFound(w, r) | ||
| 78 | + return | ||
| 79 | + } | ||
| 80 | + w.WriteHeader(http.StatusOK) | ||
| 81 | +} | ||
added
demo/skills/go-htmx-app/starter/handlers_test.go +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "html/template" | |
| 5 | + "net/http" | |
| 6 | + "net/http/httptest" | |
| 7 | + "net/url" | |
| 8 | + "strconv" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func newTestServer(t *testing.T) http.Handler { | |
| 14 | + t.Helper() | |
| 15 | + h := &handlers{store: newStore(), tmpl: parseTemplates()} | |
| 16 | + return h.routes() | |
| 17 | +} | |
| 18 | + | |
| 19 | +// The expected texts come from app.go, so these tests keep passing when the | |
| 20 | +// app is adapted to another subject. | |
| 21 | +func TestRoutes(t *testing.T) { | |
| 22 | + if len(app.Seeds) == 0 { | |
| 23 | + t.Fatal("app.Seeds must hold at least one item") | |
| 24 | + } | |
| 25 | + created := url.Values{"title": {"New entry"}} | |
| 26 | + for i := range app.Fields { | |
| 27 | + created.Set("field"+strconv.Itoa(i), "value "+strconv.Itoa(i)) | |
| 28 | + } | |
| 29 | + first := app.Seeds[0].Title | |
| 30 | + | |
| 31 | + tests := []struct { | |
| 32 | + name string | |
| 33 | + method string | |
| 34 | + path string | |
| 35 | + form url.Values | |
| 36 | + wantCode int | |
| 37 | + wantBody string | |
| 38 | + }{ | |
| 39 | + {"page", "GET", "/", nil, http.StatusOK, "htmx.org"}, | |
| 40 | + {"page title", "GET", "/", nil, http.StatusOK, template.HTMLEscapeString(app.Title)}, | |
| 41 | + {"create", "POST", "/items", created, http.StatusOK, "New entry"}, | |
| 42 | + {"create empty", "POST", "/items", url.Values{"title": {" "}}, http.StatusBadRequest, ""}, | |
| 43 | + {"search", "GET", "/items/search?q=" + url.QueryEscape(first), nil, http.StatusOK, template.HTMLEscapeString(first)}, | |
| 44 | + {"toggle", "POST", "/items/1/toggle", nil, http.StatusOK, `class="done"`}, | |
| 45 | + {"toggle unknown", "POST", "/items/99/toggle", nil, http.StatusNotFound, ""}, | |
| 46 | + {"delete", "DELETE", "/items/1", nil, http.StatusOK, ""}, | |
| 47 | + {"delete unknown", "DELETE", "/items/99", nil, http.StatusNotFound, ""}, | |
| 48 | + } | |
| 49 | + for _, tt := range tests { | |
| 50 | + t.Run(tt.name, func(t *testing.T) { | |
| 51 | + srv := newTestServer(t) | |
| 52 | + req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.form.Encode())) | |
| 53 | + if tt.form != nil { | |
| 54 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 55 | + } | |
| 56 | + rec := httptest.NewRecorder() | |
| 57 | + srv.ServeHTTP(rec, req) | |
| 58 | + if rec.Code != tt.wantCode { | |
| 59 | + t.Fatalf("status = %d, want %d", rec.Code, tt.wantCode) | |
| 60 | + } | |
| 61 | + if !strings.Contains(rec.Body.String(), tt.wantBody) { | |
| 62 | + t.Errorf("body does not contain %q:\n%s", tt.wantBody, rec.Body) | |
| 63 | + } | |
| 64 | + }) | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +func TestCreateKeepsFields(t *testing.T) { | |
| 69 | + if len(app.Fields) == 0 { | |
| 70 | + t.Skip("no extra fields in app.go") | |
| 71 | + } | |
| 72 | + form := url.Values{"title": {"With fields"}, "field0": {"first field value"}} | |
| 73 | + req := httptest.NewRequest("POST", "/items", strings.NewReader(form.Encode())) | |
| 74 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 75 | + rec := httptest.NewRecorder() | |
| 76 | + newTestServer(t).ServeHTTP(rec, req) | |
| 77 | + if !strings.Contains(rec.Body.String(), "first field value") { | |
| 78 | + t.Errorf("the fragment does not show the field:\n%s", rec.Body) | |
| 79 | + } | |
| 80 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "html/template" | ||
| 5 | + "net/http" | ||
| 6 | + "net/http/httptest" | ||
| 7 | + "net/url" | ||
| 8 | + "strconv" | ||
| 9 | + "strings" | ||
| 10 | + "testing" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func newTestServer(t *testing.T) http.Handler { | ||
| 14 | + t.Helper() | ||
| 15 | + h := &handlers{store: newStore(), tmpl: parseTemplates()} | ||
| 16 | + return h.routes() | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// The expected texts come from app.go, so these tests keep passing when the | ||
| 20 | +// app is adapted to another subject. | ||
| 21 | +func TestRoutes(t *testing.T) { | ||
| 22 | + if len(app.Seeds) == 0 { | ||
| 23 | + t.Fatal("app.Seeds must hold at least one item") | ||
| 24 | + } | ||
| 25 | + created := url.Values{"title": {"New entry"}} | ||
| 26 | + for i := range app.Fields { | ||
| 27 | + created.Set("field"+strconv.Itoa(i), "value "+strconv.Itoa(i)) | ||
| 28 | + } | ||
| 29 | + first := app.Seeds[0].Title | ||
| 30 | + | ||
| 31 | + tests := []struct { | ||
| 32 | + name string | ||
| 33 | + method string | ||
| 34 | + path string | ||
| 35 | + form url.Values | ||
| 36 | + wantCode int | ||
| 37 | + wantBody string | ||
| 38 | + }{ | ||
| 39 | + {"page", "GET", "/", nil, http.StatusOK, "htmx.org"}, | ||
| 40 | + {"page title", "GET", "/", nil, http.StatusOK, template.HTMLEscapeString(app.Title)}, | ||
| 41 | + {"create", "POST", "/items", created, http.StatusOK, "New entry"}, | ||
| 42 | + {"create empty", "POST", "/items", url.Values{"title": {" "}}, http.StatusBadRequest, ""}, | ||
| 43 | + {"search", "GET", "/items/search?q=" + url.QueryEscape(first), nil, http.StatusOK, template.HTMLEscapeString(first)}, | ||
| 44 | + {"toggle", "POST", "/items/1/toggle", nil, http.StatusOK, `class="done"`}, | ||
| 45 | + {"toggle unknown", "POST", "/items/99/toggle", nil, http.StatusNotFound, ""}, | ||
| 46 | + {"delete", "DELETE", "/items/1", nil, http.StatusOK, ""}, | ||
| 47 | + {"delete unknown", "DELETE", "/items/99", nil, http.StatusNotFound, ""}, | ||
| 48 | + } | ||
| 49 | + for _, tt := range tests { | ||
| 50 | + t.Run(tt.name, func(t *testing.T) { | ||
| 51 | + srv := newTestServer(t) | ||
| 52 | + req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.form.Encode())) | ||
| 53 | + if tt.form != nil { | ||
| 54 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 55 | + } | ||
| 56 | + rec := httptest.NewRecorder() | ||
| 57 | + srv.ServeHTTP(rec, req) | ||
| 58 | + if rec.Code != tt.wantCode { | ||
| 59 | + t.Fatalf("status = %d, want %d", rec.Code, tt.wantCode) | ||
| 60 | + } | ||
| 61 | + if !strings.Contains(rec.Body.String(), tt.wantBody) { | ||
| 62 | + t.Errorf("body does not contain %q:\n%s", tt.wantBody, rec.Body) | ||
| 63 | + } | ||
| 64 | + }) | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +func TestCreateKeepsFields(t *testing.T) { | ||
| 69 | + if len(app.Fields) == 0 { | ||
| 70 | + t.Skip("no extra fields in app.go") | ||
| 71 | + } | ||
| 72 | + form := url.Values{"title": {"With fields"}, "field0": {"first field value"}} | ||
| 73 | + req := httptest.NewRequest("POST", "/items", strings.NewReader(form.Encode())) | ||
| 74 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 75 | + rec := httptest.NewRecorder() | ||
| 76 | + newTestServer(t).ServeHTTP(rec, req) | ||
| 77 | + if !strings.Contains(rec.Body.String(), "first field value") { | ||
| 78 | + t.Errorf("the fragment does not show the field:\n%s", rec.Body) | ||
| 79 | + } | ||
| 80 | +} | ||
added
demo/skills/go-htmx-app/starter/main.go +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "log/slog" | |
| 7 | + "net/http" | |
| 8 | + "os" | |
| 9 | + "os/signal" | |
| 10 | + "syscall" | |
| 11 | + "time" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func main() { | |
| 15 | + h := &handlers{store: newStore(), tmpl: parseTemplates()} | |
| 16 | + | |
| 17 | + port := os.Getenv("PORT") | |
| 18 | + if port == "" { | |
| 19 | + port = "8080" | |
| 20 | + } | |
| 21 | + srv := &http.Server{ | |
| 22 | + Addr: ":" + port, | |
| 23 | + Handler: h.routes(), | |
| 24 | + ReadHeaderTimeout: 5 * time.Second, | |
| 25 | + } | |
| 26 | + | |
| 27 | + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 28 | + defer stop() | |
| 29 | + | |
| 30 | + go func() { | |
| 31 | + slog.Info("listening", "url", "http://localhost:"+port) | |
| 32 | + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 33 | + slog.Error("server stopped", "err", err) | |
| 34 | + stop() | |
| 35 | + } | |
| 36 | + }() | |
| 37 | + | |
| 38 | + <-ctx.Done() | |
| 39 | + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 40 | + defer cancel() | |
| 41 | + if err := srv.Shutdown(shutdownCtx); err != nil { | |
| 42 | + slog.Error("shutdown", "err", err) | |
| 43 | + } | |
| 44 | + slog.Info("bye") | |
| 45 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "log/slog" | ||
| 7 | + "net/http" | ||
| 8 | + "os" | ||
| 9 | + "os/signal" | ||
| 10 | + "syscall" | ||
| 11 | + "time" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +func main() { | ||
| 15 | + h := &handlers{store: newStore(), tmpl: parseTemplates()} | ||
| 16 | + | ||
| 17 | + port := os.Getenv("PORT") | ||
| 18 | + if port == "" { | ||
| 19 | + port = "8080" | ||
| 20 | + } | ||
| 21 | + srv := &http.Server{ | ||
| 22 | + Addr: ":" + port, | ||
| 23 | + Handler: h.routes(), | ||
| 24 | + ReadHeaderTimeout: 5 * time.Second, | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| 28 | + defer stop() | ||
| 29 | + | ||
| 30 | + go func() { | ||
| 31 | + slog.Info("listening", "url", "http://localhost:"+port) | ||
| 32 | + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 33 | + slog.Error("server stopped", "err", err) | ||
| 34 | + stop() | ||
| 35 | + } | ||
| 36 | + }() | ||
| 37 | + | ||
| 38 | + <-ctx.Done() | ||
| 39 | + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| 40 | + defer cancel() | ||
| 41 | + if err := srv.Shutdown(shutdownCtx); err != nil { | ||
| 42 | + slog.Error("shutdown", "err", err) | ||
| 43 | + } | ||
| 44 | + slog.Info("bye") | ||
| 45 | +} | ||
added
demo/skills/go-htmx-app/starter/store.go +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "sync" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// Item is one entry of the list. Fields holds one value per app.Fields label. | |
| 9 | +type Item struct { | |
| 10 | + ID int | |
| 11 | + Title string | |
| 12 | + Fields []string | |
| 13 | + Done bool | |
| 14 | +} | |
| 15 | + | |
| 16 | +// store keeps the items in memory, safe for concurrent handlers. | |
| 17 | +type store struct { | |
| 18 | + mu sync.Mutex | |
| 19 | + items []Item | |
| 20 | + nextID int | |
| 21 | +} | |
| 22 | + | |
| 23 | +func newStore() *store { | |
| 24 | + s := &store{nextID: 1} | |
| 25 | + for _, sd := range app.Seeds { | |
| 26 | + s.Add(sd.Title, sd.Fields) | |
| 27 | + } | |
| 28 | + return s | |
| 29 | +} | |
| 30 | + | |
| 31 | +func (s *store) List() []Item { | |
| 32 | + s.mu.Lock() | |
| 33 | + defer s.mu.Unlock() | |
| 34 | + return append([]Item(nil), s.items...) | |
| 35 | +} | |
| 36 | + | |
| 37 | +func (s *store) Add(title string, fields []string) Item { | |
| 38 | + s.mu.Lock() | |
| 39 | + defer s.mu.Unlock() | |
| 40 | + it := Item{ID: s.nextID, Title: title, Fields: fields} | |
| 41 | + s.nextID++ | |
| 42 | + s.items = append(s.items, it) | |
| 43 | + return it | |
| 44 | +} | |
| 45 | + | |
| 46 | +// Toggle flips Done and returns the updated item; ok is false for an unknown id. | |
| 47 | +func (s *store) Toggle(id int) (Item, bool) { | |
| 48 | + s.mu.Lock() | |
| 49 | + defer s.mu.Unlock() | |
| 50 | + for i := range s.items { | |
| 51 | + if s.items[i].ID == id { | |
| 52 | + s.items[i].Done = !s.items[i].Done | |
| 53 | + return s.items[i], true | |
| 54 | + } | |
| 55 | + } | |
| 56 | + return Item{}, false | |
| 57 | +} | |
| 58 | + | |
| 59 | +func (s *store) Delete(id int) bool { | |
| 60 | + s.mu.Lock() | |
| 61 | + defer s.mu.Unlock() | |
| 62 | + for i := range s.items { | |
| 63 | + if s.items[i].ID == id { | |
| 64 | + s.items = append(s.items[:i], s.items[i+1:]...) | |
| 65 | + return true | |
| 66 | + } | |
| 67 | + } | |
| 68 | + return false | |
| 69 | +} | |
| 70 | + | |
| 71 | +// Search returns the items whose title or fields contain q, ignoring case. | |
| 72 | +func (s *store) Search(q string) []Item { | |
| 73 | + q = strings.ToLower(strings.TrimSpace(q)) | |
| 74 | + var found []Item | |
| 75 | + for _, it := range s.List() { | |
| 76 | + text := strings.ToLower(it.Title + " " + strings.Join(it.Fields, " ")) | |
| 77 | + if strings.Contains(text, q) { | |
| 78 | + found = append(found, it) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + return found | |
| 82 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "sync" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// Item is one entry of the list. Fields holds one value per app.Fields label. | ||
| 9 | +type Item struct { | ||
| 10 | + ID int | ||
| 11 | + Title string | ||
| 12 | + Fields []string | ||
| 13 | + Done bool | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// store keeps the items in memory, safe for concurrent handlers. | ||
| 17 | +type store struct { | ||
| 18 | + mu sync.Mutex | ||
| 19 | + items []Item | ||
| 20 | + nextID int | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +func newStore() *store { | ||
| 24 | + s := &store{nextID: 1} | ||
| 25 | + for _, sd := range app.Seeds { | ||
| 26 | + s.Add(sd.Title, sd.Fields) | ||
| 27 | + } | ||
| 28 | + return s | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +func (s *store) List() []Item { | ||
| 32 | + s.mu.Lock() | ||
| 33 | + defer s.mu.Unlock() | ||
| 34 | + return append([]Item(nil), s.items...) | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func (s *store) Add(title string, fields []string) Item { | ||
| 38 | + s.mu.Lock() | ||
| 39 | + defer s.mu.Unlock() | ||
| 40 | + it := Item{ID: s.nextID, Title: title, Fields: fields} | ||
| 41 | + s.nextID++ | ||
| 42 | + s.items = append(s.items, it) | ||
| 43 | + return it | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// Toggle flips Done and returns the updated item; ok is false for an unknown id. | ||
| 47 | +func (s *store) Toggle(id int) (Item, bool) { | ||
| 48 | + s.mu.Lock() | ||
| 49 | + defer s.mu.Unlock() | ||
| 50 | + for i := range s.items { | ||
| 51 | + if s.items[i].ID == id { | ||
| 52 | + s.items[i].Done = !s.items[i].Done | ||
| 53 | + return s.items[i], true | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + return Item{}, false | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +func (s *store) Delete(id int) bool { | ||
| 60 | + s.mu.Lock() | ||
| 61 | + defer s.mu.Unlock() | ||
| 62 | + for i := range s.items { | ||
| 63 | + if s.items[i].ID == id { | ||
| 64 | + s.items = append(s.items[:i], s.items[i+1:]...) | ||
| 65 | + return true | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | + return false | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// Search returns the items whose title or fields contain q, ignoring case. | ||
| 72 | +func (s *store) Search(q string) []Item { | ||
| 73 | + q = strings.ToLower(strings.TrimSpace(q)) | ||
| 74 | + var found []Item | ||
| 75 | + for _, it := range s.List() { | ||
| 76 | + text := strings.ToLower(it.Title + " " + strings.Join(it.Fields, " ")) | ||
| 77 | + if strings.Contains(text, q) { | ||
| 78 | + found = append(found, it) | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + return found | ||
| 82 | +} | ||
added
demo/skills/go-htmx-app/starter/templates.go +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "embed" | |
| 5 | + "html/template" | |
| 6 | +) | |
| 7 | + | |
| 8 | +//go:embed templates/*.html | |
| 9 | +var templatesFS embed.FS | |
| 10 | + | |
| 11 | +// parseTemplates loads the page and its fragments; `app` gives them the | |
| 12 | +// texts of app.go. | |
| 13 | +func parseTemplates() *template.Template { | |
| 14 | + funcs := template.FuncMap{"app": func() appInfo { return app }} | |
| 15 | + return template.Must(template.New("").Funcs(funcs).ParseFS(templatesFS, "templates/*.html")) | |
| 16 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "embed" | ||
| 5 | + "html/template" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +//go:embed templates/*.html | ||
| 9 | +var templatesFS embed.FS | ||
| 10 | + | ||
| 11 | +// parseTemplates loads the page and its fragments; `app` gives them the | ||
| 12 | +// texts of app.go. | ||
| 13 | +func parseTemplates() *template.Template { | ||
| 14 | + funcs := template.FuncMap{"app": func() appInfo { return app }} | ||
| 15 | + return template.Must(template.New("").Funcs(funcs).ParseFS(templatesFS, "templates/*.html")) | ||
| 16 | +} | ||
added
demo/skills/go-htmx-app/starter/templates/index.html +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="{{(app).Lang}}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="utf-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + <title>{{(app).Title}}</title> | |
| 7 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> | |
| 8 | + <script src="https://unpkg.com/htmx.org@2.0.4"></script> | |
| 9 | + <style> | |
| 10 | + #items li { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; } | |
| 11 | + #items li.done span { text-decoration: line-through; opacity: .6; } | |
| 12 | + #items li button { margin-left: auto; width: auto; padding: .25rem .75rem; } | |
| 13 | + @media (max-width: 576px) { form fieldset[role=group] { flex-direction: column; } } | |
| 14 | + </style> | |
| 15 | +</head> | |
| 16 | +<body> | |
| 17 | + <main class="container"> | |
| 18 | + <h1>{{(app).Title}}</h1> | |
| 19 | + <form hx-post="/items" hx-target="#items" hx-swap="beforeend" hx-on::after-request="this.reset()"> | |
| 20 | + <fieldset role="group"> | |
| 21 | + <input name="title" required placeholder="{{(app).TitleLabel}}" aria-label="{{(app).TitleLabel}}"> | |
| 22 | + {{range $i, $label := (app).Fields}}<input name="field{{$i}}" placeholder="{{$label}}" aria-label="{{$label}}"> | |
| 23 | + {{end}}<button>{{(app).AddLabel}}</button> | |
| 24 | + </fieldset> | |
| 25 | + </form> | |
| 26 | + <input type="search" name="q" placeholder="{{(app).SearchLabel}}" aria-label="{{(app).SearchLabel}}" | |
| 27 | + hx-get="/items/search" hx-trigger="input changed delay:300ms, search" hx-target="#items"> | |
| 28 | + <ul id="items">{{template "items" .Items}}</ul> | |
| 29 | + </main> | |
| 30 | +</body> | |
| 31 | +</html> | |
| 32 | + | |
| 33 | +{{define "items"}}{{range .}}{{template "item" .}}{{end}}{{end}} | |
| 34 | + | |
| 35 | +{{define "item"}}<li id="item-{{.ID}}"{{if .Done}} class="done"{{end}}> | |
| 36 | + {{if (app).DoneLabel}}<input type="checkbox" aria-label="{{(app).DoneLabel}}" title="{{(app).DoneLabel}}" hx-post="/items/{{.ID}}/toggle" hx-target="#item-{{.ID}}" hx-swap="outerHTML"{{if .Done}} checked{{end}}>{{end}} | |
| 37 | + <span>{{.Title}}</span> | |
| 38 | + {{range .Fields}}{{if .}}<small>{{.}}</small>{{end}}{{end}} | |
| 39 | + <button class="secondary outline" hx-delete="/items/{{.ID}}" hx-target="#item-{{.ID}}" hx-swap="outerHTML" hx-confirm="{{(app).DeleteConfirm}}">✕</button> | |
| 40 | +</li>{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +<!doctype html> | ||
| 2 | +<html lang="{{(app).Lang}}"> | ||
| 3 | +<head> | ||
| 4 | + <meta charset="utf-8"> | ||
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | + <title>{{(app).Title}}</title> | ||
| 7 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> | ||
| 8 | + <script src="https://unpkg.com/htmx.org@2.0.4"></script> | ||
| 9 | + <style> | ||
| 10 | + #items li { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; } | ||
| 11 | + #items li.done span { text-decoration: line-through; opacity: .6; } | ||
| 12 | + #items li button { margin-left: auto; width: auto; padding: .25rem .75rem; } | ||
| 13 | + @media (max-width: 576px) { form fieldset[role=group] { flex-direction: column; } } | ||
| 14 | + </style> | ||
| 15 | +</head> | ||
| 16 | +<body> | ||
| 17 | + <main class="container"> | ||
| 18 | + <h1>{{(app).Title}}</h1> | ||
| 19 | + <form hx-post="/items" hx-target="#items" hx-swap="beforeend" hx-on::after-request="this.reset()"> | ||
| 20 | + <fieldset role="group"> | ||
| 21 | + <input name="title" required placeholder="{{(app).TitleLabel}}" aria-label="{{(app).TitleLabel}}"> | ||
| 22 | + {{range $i, $label := (app).Fields}}<input name="field{{$i}}" placeholder="{{$label}}" aria-label="{{$label}}"> | ||
| 23 | + {{end}}<button>{{(app).AddLabel}}</button> | ||
| 24 | + </fieldset> | ||
| 25 | + </form> | ||
| 26 | + <input type="search" name="q" placeholder="{{(app).SearchLabel}}" aria-label="{{(app).SearchLabel}}" | ||
| 27 | + hx-get="/items/search" hx-trigger="input changed delay:300ms, search" hx-target="#items"> | ||
| 28 | + <ul id="items">{{template "items" .Items}}</ul> | ||
| 29 | + </main> | ||
| 30 | +</body> | ||
| 31 | +</html> | ||
| 32 | + | ||
| 33 | +{{define "items"}}{{range .}}{{template "item" .}}{{end}}{{end}} | ||
| 34 | + | ||
| 35 | +{{define "item"}}<li id="item-{{.ID}}"{{if .Done}} class="done"{{end}}> | ||
| 36 | + {{if (app).DoneLabel}}<input type="checkbox" aria-label="{{(app).DoneLabel}}" title="{{(app).DoneLabel}}" hx-post="/items/{{.ID}}/toggle" hx-target="#item-{{.ID}}" hx-swap="outerHTML"{{if .Done}} checked{{end}}>{{end}} | ||
| 37 | + <span>{{.Title}}</span> | ||
| 38 | + {{range .Fields}}{{if .}}<small>{{.}}</small>{{end}}{{end}} | ||
| 39 | + <button class="secondary outline" hx-delete="/items/{{.ID}}" hx-target="#item-{{.ID}}" hx-swap="outerHTML" hx-confirm="{{(app).DeleteConfirm}}">✕</button> | ||
| 40 | +</li>{{end}} | ||
added
demo/skills/go-htmx-subject-app/SKILL.md +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +--- | |
| 2 | +name: go-htmx-subject-app | |
| 3 | +description: Create a web application in modern Go with htmx about a given subject (books, contacts, recipes, movies…) with its own fields, from the tested starter. | |
| 4 | +keywords: gérer, manage, livres, contacts, recettes, champs, fields, liste | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Create a Go + htmx web app about a subject | |
| 8 | + | |
| 9 | +Every file path starts with the app directory: `<name>/app.go` — never `app.go` alone. | |
| 10 | + | |
| 11 | +## 1. Name | |
| 12 | + | |
| 13 | +`<name>`: the subject in kebab-case (`book-library`). | |
| 14 | + | |
| 15 | +## 2. Copy the starter | |
| 16 | + | |
| 17 | +The starter is a complete, tested list app: add, mark done, delete, live search, a responsive page. Call your `bash` tool with this command: | |
| 18 | + | |
| 19 | + mkdir -p <name> && cp -rn skills/go-htmx-app/starter/. <name>/ && cd <name> && go mod edit -module <name> | |
| 20 | + | |
| 21 | +## 3. Describe the subject in `<name>/app.go` — the ONLY Go file to change | |
| 22 | + | |
| 23 | +The page, the handlers and the tests all read `app.go`: do not touch the other files, do not read `app.go`. Call `write_file` (not edit_file) on `<name>/app.go` with the WHOLE file, texts in the language of the user's request. Example for « des livres avec un titre et un auteur, marquer comme lu », in French: | |
| 24 | + | |
| 25 | + package main | |
| 26 | + | |
| 27 | + var app = appInfo{ | |
| 28 | + Lang: "fr", | |
| 29 | + Title: "Ma bibliothèque", | |
| 30 | + TitleLabel: "Titre", | |
| 31 | + Fields: []string{"Auteur"}, | |
| 32 | + DoneLabel: "Lu", | |
| 33 | + AddLabel: "Ajouter", | |
| 34 | + SearchLabel: "Rechercher", | |
| 35 | + DeleteConfirm: "Supprimer ce livre ?", | |
| 36 | + Seeds: []seed{ | |
| 37 | + {Title: "L'Étranger", Fields: []string{"Albert Camus"}}, | |
| 38 | + {Title: "Dune", Fields: []string{"Frank Herbert"}}, | |
| 39 | + }, | |
| 40 | + } | |
| 41 | + | |
| 42 | +Rules: `Title` is the main text of an item; `Fields` lists the OTHER things the user named (author, year, email…), and every seed has one value per field, in the same order; `DoneLabel` names the checkbox (read, bought, watched, done) — `""` when the subject has no such state (contacts): the checkbox disappears. Everything the user asked fits in `app.go`: after this ONE write_file, go straight to step 4. | |
| 43 | + | |
| 44 | +## 4. Check | |
| 45 | + | |
| 46 | +Call your `bash` tool with this command: | |
| 47 | + | |
| 48 | + cd <name> && sh check.sh | |
| 49 | + | |
| 50 | +It ends with `CHECK OK`. Anything else: read the `run-and-check` skill, fix `app.go`, run it again. It also prints the page title: it must be YOUR title, not `htmx tasks` — otherwise `app.go` was not written: write it again. | |
| 51 | + | |
| 52 | +## 5. README | |
| 53 | + | |
| 54 | +One `write_file` call on `<name>/README.md` (it replaces the starter's), in the language of the user's request: what the app does, the fields, the stack (Go standard library, htmx 2, Pico CSS), how to run it (`go run .` then http://localhost:8080), test it (`go test ./...`), check it (`sh check.sh`), and that `app.go` holds the texts and fields. | |
| 55 | + | |
| 56 | +## 6. Answer | |
| 57 | + | |
| 58 | +A few lines in the user's language — not the README again: what the app holds, the `CHECK OK` result, how to start it: `cd <name> && go run .`, then open http://localhost:8080. | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +--- | ||
| 2 | +name: go-htmx-subject-app | ||
| 3 | +description: Create a web application in modern Go with htmx about a given subject (books, contacts, recipes, movies…) with its own fields, from the tested starter. | ||
| 4 | +keywords: gérer, manage, livres, contacts, recettes, champs, fields, liste | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Create a Go + htmx web app about a subject | ||
| 8 | + | ||
| 9 | +Every file path starts with the app directory: `<name>/app.go` — never `app.go` alone. | ||
| 10 | + | ||
| 11 | +## 1. Name | ||
| 12 | + | ||
| 13 | +`<name>`: the subject in kebab-case (`book-library`). | ||
| 14 | + | ||
| 15 | +## 2. Copy the starter | ||
| 16 | + | ||
| 17 | +The starter is a complete, tested list app: add, mark done, delete, live search, a responsive page. Call your `bash` tool with this command: | ||
| 18 | + | ||
| 19 | + mkdir -p <name> && cp -rn skills/go-htmx-app/starter/. <name>/ && cd <name> && go mod edit -module <name> | ||
| 20 | + | ||
| 21 | +## 3. Describe the subject in `<name>/app.go` — the ONLY Go file to change | ||
| 22 | + | ||
| 23 | +The page, the handlers and the tests all read `app.go`: do not touch the other files, do not read `app.go`. Call `write_file` (not edit_file) on `<name>/app.go` with the WHOLE file, texts in the language of the user's request. Example for « des livres avec un titre et un auteur, marquer comme lu », in French: | ||
| 24 | + | ||
| 25 | + package main | ||
| 26 | + | ||
| 27 | + var app = appInfo{ | ||
| 28 | + Lang: "fr", | ||
| 29 | + Title: "Ma bibliothèque", | ||
| 30 | + TitleLabel: "Titre", | ||
| 31 | + Fields: []string{"Auteur"}, | ||
| 32 | + DoneLabel: "Lu", | ||
| 33 | + AddLabel: "Ajouter", | ||
| 34 | + SearchLabel: "Rechercher", | ||
| 35 | + DeleteConfirm: "Supprimer ce livre ?", | ||
| 36 | + Seeds: []seed{ | ||
| 37 | + {Title: "L'Étranger", Fields: []string{"Albert Camus"}}, | ||
| 38 | + {Title: "Dune", Fields: []string{"Frank Herbert"}}, | ||
| 39 | + }, | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | +Rules: `Title` is the main text of an item; `Fields` lists the OTHER things the user named (author, year, email…), and every seed has one value per field, in the same order; `DoneLabel` names the checkbox (read, bought, watched, done) — `""` when the subject has no such state (contacts): the checkbox disappears. Everything the user asked fits in `app.go`: after this ONE write_file, go straight to step 4. | ||
| 43 | + | ||
| 44 | +## 4. Check | ||
| 45 | + | ||
| 46 | +Call your `bash` tool with this command: | ||
| 47 | + | ||
| 48 | + cd <name> && sh check.sh | ||
| 49 | + | ||
| 50 | +It ends with `CHECK OK`. Anything else: read the `run-and-check` skill, fix `app.go`, run it again. It also prints the page title: it must be YOUR title, not `htmx tasks` — otherwise `app.go` was not written: write it again. | ||
| 51 | + | ||
| 52 | +## 5. README | ||
| 53 | + | ||
| 54 | +One `write_file` call on `<name>/README.md` (it replaces the starter's), in the language of the user's request: what the app does, the fields, the stack (Go standard library, htmx 2, Pico CSS), how to run it (`go run .` then http://localhost:8080), test it (`go test ./...`), check it (`sh check.sh`), and that `app.go` holds the texts and fields. | ||
| 55 | + | ||
| 56 | +## 6. Answer | ||
| 57 | + | ||
| 58 | +A few lines in the user's language — not the README again: what the app holds, the `CHECK OK` result, how to start it: `cd <name> && go run .`, then open http://localhost:8080. | ||
added
demo/skills/htmx-patterns/SKILL.md +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +--- | |
| 2 | +name: htmx-patterns | |
| 3 | +description: Reference of the htmx 2 attributes and the Go handler side for the usual interactions — create, toggle, delete, live search, inline edit, partial pages. | |
| 4 | +keywords: htmx, hx-post, hx-get, hx-swap, hx-target, fragment, partial, search | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# htmx patterns with Go templates | |
| 8 | + | |
| 9 | +The server answers HTML, never JSON. A full page for a normal request, a fragment (`{{define "item"}}`) for an htmx request. | |
| 10 | + | |
| 11 | +## Attributes | |
| 12 | + | |
| 13 | +| Attribute | Use | | |
| 14 | +|---|---| | |
| 15 | +| `hx-get`, `hx-post`, `hx-put`, `hx-delete` | request sent on the element's natural event (click, submit, change) | | |
| 16 | +| `hx-target="#items"` | element that receives the answer (default: the element itself) | | |
| 17 | +| `hx-swap` | `innerHTML` (default), `outerHTML` (replace the element), `beforeend` (append), `afterbegin`, `delete` | | |
| 18 | +| `hx-trigger="input changed delay:300ms"` | live search; `"load"` loads once at page load | | |
| 19 | +| `hx-confirm="Delete?"` | browser confirmation first | | |
| 20 | +| `hx-on::after-request="this.reset()"` | clear a form after it was sent | | |
| 21 | +| `hx-indicator="#spin"` | element shown while a request runs (`class="htmx-indicator"`) | | |
| 22 | + | |
| 23 | +## Create — append the new item | |
| 24 | + | |
| 25 | +```html | |
| 26 | +<form hx-post="/items" hx-target="#items" hx-swap="beforeend" hx-on::after-request="this.reset()"> | |
| 27 | + <input name="title" required placeholder="New item"> | |
| 28 | + <button>Add</button> | |
| 29 | +</form> | |
| 30 | +<ul id="items">{{template "items" .Items}}</ul> | |
| 31 | +``` | |
| 32 | +Handler: validate, add, `ExecuteTemplate(w, "item", it)`. | |
| 33 | + | |
| 34 | +## Toggle or edit — replace one item | |
| 35 | + | |
| 36 | +```html | |
| 37 | +{{define "item"}}<li id="item-{{.ID}}"> | |
| 38 | + <input type="checkbox" hx-post="/items/{{.ID}}/toggle" hx-target="#item-{{.ID}}" hx-swap="outerHTML" {{if .Done}}checked{{end}}> | |
| 39 | + {{.Title}} | |
| 40 | + <button hx-delete="/items/{{.ID}}" hx-target="#item-{{.ID}}" hx-swap="outerHTML" hx-confirm="Delete?">✕</button> | |
| 41 | +</li>{{end}} | |
| 42 | +``` | |
| 43 | +Toggle handler: answer the updated `"item"`. Delete handler: `w.WriteHeader(http.StatusOK)` with an EMPTY body — htmx replaces the `<li>` with nothing. (htmx ignores a 204 answer: use 200.) | |
| 44 | + | |
| 45 | +## Live search — replace the list | |
| 46 | + | |
| 47 | +```html | |
| 48 | +<input type="search" name="q" hx-get="/items/search" hx-trigger="input changed delay:300ms, search" hx-target="#items"> | |
| 49 | +``` | |
| 50 | +Handler: `q := r.URL.Query().Get("q")`, answer `"items"` with the filtered slice. | |
| 51 | + | |
| 52 | +## Refresh another part of the page (a counter, a total) | |
| 53 | + | |
| 54 | +Every change announces itself; the counter reloads itself when it hears it. | |
| 55 | + | |
| 56 | +- `handlers.go`, in `routes()`: `mux.HandleFunc("GET /items/count", h.count)`, and wrap the mux so every non-GET request sends the event: | |
| 57 | + | |
| 58 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 59 | + if r.Method != http.MethodGet { | |
| 60 | + w.Header().Set("HX-Trigger", "items-changed") | |
| 61 | + } | |
| 62 | + mux.ServeHTTP(w, r) | |
| 63 | + }) | |
| 64 | + | |
| 65 | +- the handler: `h.render(w, "count", h.store.Remaining())` — `Remaining()` is a store method counting the items not done. | |
| 66 | +- the page: `<p id="count" hx-get="/items/count" hx-trigger="load, items-changed from:body">…</p>` and the fragment `{{define "count"}}{{.}} remaining{{end}}`. | |
| 67 | + | |
| 68 | +## One route, two answers | |
| 69 | + | |
| 70 | +`r.Header.Get("HX-Request") == "true"` → the fragment; otherwise the full page. Use it when a link can be opened directly too. | |
| 71 | + | |
| 72 | +## Rules | |
| 73 | + | |
| 74 | +- `html/template` escapes values: never build HTML with `fmt.Sprintf`. | |
| 75 | +- `{{template "items" .Items}}` passes the data on; `{{template "items"}}` alone passes nothing. | |
| 76 | +- Every `hx-target` id exists in the page, and fragments keep the same id as the element they replace. | |
| 77 | +- DELETE and PUT bodies: read ids from the path (`r.PathValue("id")`), not from a form. | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +--- | ||
| 2 | +name: htmx-patterns | ||
| 3 | +description: Reference of the htmx 2 attributes and the Go handler side for the usual interactions — create, toggle, delete, live search, inline edit, partial pages. | ||
| 4 | +keywords: htmx, hx-post, hx-get, hx-swap, hx-target, fragment, partial, search | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# htmx patterns with Go templates | ||
| 8 | + | ||
| 9 | +The server answers HTML, never JSON. A full page for a normal request, a fragment (`{{define "item"}}`) for an htmx request. | ||
| 10 | + | ||
| 11 | +## Attributes | ||
| 12 | + | ||
| 13 | +| Attribute | Use | | ||
| 14 | +|---|---| | ||
| 15 | +| `hx-get`, `hx-post`, `hx-put`, `hx-delete` | request sent on the element's natural event (click, submit, change) | | ||
| 16 | +| `hx-target="#items"` | element that receives the answer (default: the element itself) | | ||
| 17 | +| `hx-swap` | `innerHTML` (default), `outerHTML` (replace the element), `beforeend` (append), `afterbegin`, `delete` | | ||
| 18 | +| `hx-trigger="input changed delay:300ms"` | live search; `"load"` loads once at page load | | ||
| 19 | +| `hx-confirm="Delete?"` | browser confirmation first | | ||
| 20 | +| `hx-on::after-request="this.reset()"` | clear a form after it was sent | | ||
| 21 | +| `hx-indicator="#spin"` | element shown while a request runs (`class="htmx-indicator"`) | | ||
| 22 | + | ||
| 23 | +## Create — append the new item | ||
| 24 | + | ||
| 25 | +```html | ||
| 26 | +<form hx-post="/items" hx-target="#items" hx-swap="beforeend" hx-on::after-request="this.reset()"> | ||
| 27 | + <input name="title" required placeholder="New item"> | ||
| 28 | + <button>Add</button> | ||
| 29 | +</form> | ||
| 30 | +<ul id="items">{{template "items" .Items}}</ul> | ||
| 31 | +``` | ||
| 32 | +Handler: validate, add, `ExecuteTemplate(w, "item", it)`. | ||
| 33 | + | ||
| 34 | +## Toggle or edit — replace one item | ||
| 35 | + | ||
| 36 | +```html | ||
| 37 | +{{define "item"}}<li id="item-{{.ID}}"> | ||
| 38 | + <input type="checkbox" hx-post="/items/{{.ID}}/toggle" hx-target="#item-{{.ID}}" hx-swap="outerHTML" {{if .Done}}checked{{end}}> | ||
| 39 | + {{.Title}} | ||
| 40 | + <button hx-delete="/items/{{.ID}}" hx-target="#item-{{.ID}}" hx-swap="outerHTML" hx-confirm="Delete?">✕</button> | ||
| 41 | +</li>{{end}} | ||
| 42 | +``` | ||
| 43 | +Toggle handler: answer the updated `"item"`. Delete handler: `w.WriteHeader(http.StatusOK)` with an EMPTY body — htmx replaces the `<li>` with nothing. (htmx ignores a 204 answer: use 200.) | ||
| 44 | + | ||
| 45 | +## Live search — replace the list | ||
| 46 | + | ||
| 47 | +```html | ||
| 48 | +<input type="search" name="q" hx-get="/items/search" hx-trigger="input changed delay:300ms, search" hx-target="#items"> | ||
| 49 | +``` | ||
| 50 | +Handler: `q := r.URL.Query().Get("q")`, answer `"items"` with the filtered slice. | ||
| 51 | + | ||
| 52 | +## Refresh another part of the page (a counter, a total) | ||
| 53 | + | ||
| 54 | +Every change announces itself; the counter reloads itself when it hears it. | ||
| 55 | + | ||
| 56 | +- `handlers.go`, in `routes()`: `mux.HandleFunc("GET /items/count", h.count)`, and wrap the mux so every non-GET request sends the event: | ||
| 57 | + | ||
| 58 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 59 | + if r.Method != http.MethodGet { | ||
| 60 | + w.Header().Set("HX-Trigger", "items-changed") | ||
| 61 | + } | ||
| 62 | + mux.ServeHTTP(w, r) | ||
| 63 | + }) | ||
| 64 | + | ||
| 65 | +- the handler: `h.render(w, "count", h.store.Remaining())` — `Remaining()` is a store method counting the items not done. | ||
| 66 | +- the page: `<p id="count" hx-get="/items/count" hx-trigger="load, items-changed from:body">…</p>` and the fragment `{{define "count"}}{{.}} remaining{{end}}`. | ||
| 67 | + | ||
| 68 | +## One route, two answers | ||
| 69 | + | ||
| 70 | +`r.Header.Get("HX-Request") == "true"` → the fragment; otherwise the full page. Use it when a link can be opened directly too. | ||
| 71 | + | ||
| 72 | +## Rules | ||
| 73 | + | ||
| 74 | +- `html/template` escapes values: never build HTML with `fmt.Sprintf`. | ||
| 75 | +- `{{template "items" .Items}}` passes the data on; `{{template "items"}}` alone passes nothing. | ||
| 76 | +- Every `hx-target` id exists in the page, and fragments keep the same id as the element they replace. | ||
| 77 | +- DELETE and PUT bodies: read ids from the path (`r.PathValue("id")`), not from a form. | ||
added
demo/skills/run-and-check/SKILL.md +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +--- | |
| 2 | +name: run-and-check | |
| 3 | +description: Check a Go + htmx web app and fix what fails — gofmt, go vet, go test, then start it, query it with curl and stop it. | |
| 4 | +keywords: vérifie, teste, corrige, erreur, lance, check, fix, curl | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Check and fix a Go + htmx web app | |
| 8 | + | |
| 9 | +`<dir>` is the app's directory. | |
| 10 | + | |
| 11 | +## 1. Run the check | |
| 12 | + | |
| 13 | +If `<dir>/check.sh` exists (apps made from the starter), call your `bash` tool with this command: | |
| 14 | + | |
| 15 | + cd <dir> && sh check.sh | |
| 16 | + | |
| 17 | +Otherwise, call your `bash` tool with this command: | |
| 18 | + | |
| 19 | + cd <dir> && gofmt -l . && go vet ./... && go test ./... 2>&1 | tail -30 | |
| 20 | + | |
| 21 | +## 2. Read what failed — the section printed last | |
| 22 | + | |
| 23 | +- `formatted: <file>`: check.sh fixed the formatting itself — nothing to do. | |
| 24 | +- In an app made from the starter, texts and fields live in `app.go`: every seed needs one value per entry of `Fields`. | |
| 25 | +- `file.go:12:5: …` (vet, build or test): `read_file` that file, fix that line with `edit_file`. | |
| 26 | +- `--- FAIL: TestRoutes/<case>`: the test case named; decide whether the code or the test is wrong — the test describes what the user asked. | |
| 27 | +- `GET / -> 500` or a template error in the server log: `read_file` `<dir>/templates/index.html`, fix the `{{…}}` the log names (a field that does not exist, a `{{define}}` without `{{end}}`). | |
| 28 | +- `GET / -> 000`: the app did not start, the server log says why; `address already in use` → run `sh check.sh 8098`. | |
| 29 | + | |
| 30 | +Fix ONE cause, run the check again. Never delete a file or a test to make an error go away. | |
| 31 | + | |
| 32 | +## 3. Never | |
| 33 | + | |
| 34 | +`go run .` with your bash tool: a server never returns and the command times out. `check.sh` starts and stops it for you. | |
| 35 | + | |
| 36 | +## 4. Report | |
| 37 | + | |
| 38 | +Quote the last line of the check (`CHECK OK`, or `ok` for go test). | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +--- | ||
| 2 | +name: run-and-check | ||
| 3 | +description: Check a Go + htmx web app and fix what fails — gofmt, go vet, go test, then start it, query it with curl and stop it. | ||
| 4 | +keywords: vérifie, teste, corrige, erreur, lance, check, fix, curl | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Check and fix a Go + htmx web app | ||
| 8 | + | ||
| 9 | +`<dir>` is the app's directory. | ||
| 10 | + | ||
| 11 | +## 1. Run the check | ||
| 12 | + | ||
| 13 | +If `<dir>/check.sh` exists (apps made from the starter), call your `bash` tool with this command: | ||
| 14 | + | ||
| 15 | + cd <dir> && sh check.sh | ||
| 16 | + | ||
| 17 | +Otherwise, call your `bash` tool with this command: | ||
| 18 | + | ||
| 19 | + cd <dir> && gofmt -l . && go vet ./... && go test ./... 2>&1 | tail -30 | ||
| 20 | + | ||
| 21 | +## 2. Read what failed — the section printed last | ||
| 22 | + | ||
| 23 | +- `formatted: <file>`: check.sh fixed the formatting itself — nothing to do. | ||
| 24 | +- In an app made from the starter, texts and fields live in `app.go`: every seed needs one value per entry of `Fields`. | ||
| 25 | +- `file.go:12:5: …` (vet, build or test): `read_file` that file, fix that line with `edit_file`. | ||
| 26 | +- `--- FAIL: TestRoutes/<case>`: the test case named; decide whether the code or the test is wrong — the test describes what the user asked. | ||
| 27 | +- `GET / -> 500` or a template error in the server log: `read_file` `<dir>/templates/index.html`, fix the `{{…}}` the log names (a field that does not exist, a `{{define}}` without `{{end}}`). | ||
| 28 | +- `GET / -> 000`: the app did not start, the server log says why; `address already in use` → run `sh check.sh 8098`. | ||
| 29 | + | ||
| 30 | +Fix ONE cause, run the check again. Never delete a file or a test to make an error go away. | ||
| 31 | + | ||
| 32 | +## 3. Never | ||
| 33 | + | ||
| 34 | +`go run .` with your bash tool: a server never returns and the command times out. `check.sh` starts and stops it for you. | ||
| 35 | + | ||
| 36 | +## 4. Report | ||
| 37 | + | ||
| 38 | +Quote the last line of the check (`CHECK OK`, or `ok` for go test). | ||
added
git.sh +136 -0 | new file mode 100755 | ||
| @@ -0,0 +1,136 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +message="" | |
| 3 | +case $1 in | |
| 4 | + | |
| 5 | + # 🎨: art | |
| 6 | + art) | |
| 7 | + message="Improve structure / format of the code" | |
| 8 | + emoji="🎨" | |
| 9 | + ;; | |
| 10 | + | |
| 11 | + # 🐛: bug | |
| 12 | + bug|fix) | |
| 13 | + message="Fix a bug" | |
| 14 | + emoji="🐛" | |
| 15 | + ;; | |
| 16 | + | |
| 17 | + # 🤓: geek | |
| 18 | + human|human-fixed) | |
| 19 | + message="Human Fixed" | |
| 20 | + emoji="🤓" | |
| 21 | + ;; | |
| 22 | + | |
| 23 | + # 🤖: robot | |
| 24 | + ai|ai-generated) | |
| 25 | + message="AI generated" | |
| 26 | + emoji="🤖" | |
| 27 | + ;; | |
| 28 | + | |
| 29 | + # ✨: sparkles | |
| 30 | + sparkles|feature) | |
| 31 | + message="Introduce new feature(s)" | |
| 32 | + emoji="✨" | |
| 33 | + ;; | |
| 34 | + | |
| 35 | + # 🧩: jigsaw | |
| 36 | + jigsaw|example|examples|demo|demos) | |
| 37 | + message="Introduce new example(s)" | |
| 38 | + emoji="🧩" | |
| 39 | + ;; | |
| 40 | + | |
| 41 | + | |
| 42 | + # 📝: memo | |
| 43 | + memo|doc|documentation) | |
| 44 | + message="Add or update documentation" | |
| 45 | + emoji="📝" | |
| 46 | + ;; | |
| 47 | + | |
| 48 | + # 🌸: cherry_blossom | |
| 49 | + gardening|garden|clean|cleaning) | |
| 50 | + message="Gardening" | |
| 51 | + emoji="🌸" | |
| 52 | + ;; | |
| 53 | + | |
| 54 | + # 🚀: rocket | |
| 55 | + rocket|deploy) | |
| 56 | + message="Deploy stuff" | |
| 57 | + emoji="🚀" | |
| 58 | + ;; | |
| 59 | + | |
| 60 | + # 🎉: tada | |
| 61 | + tada|first) | |
| 62 | + message="Begin a project" | |
| 63 | + emoji="🎉" | |
| 64 | + ;; | |
| 65 | + | |
| 66 | + # 🚧: construction | |
| 67 | + construction|wip) | |
| 68 | + message="Work in progress" | |
| 69 | + emoji="🚧" | |
| 70 | + ;; | |
| 71 | + | |
| 72 | + # 📦️: package | |
| 73 | + package|build) | |
| 74 | + message="Add or update compiled files or packages" | |
| 75 | + emoji="📦️" | |
| 76 | + ;; | |
| 77 | + | |
| 78 | + # 📦️: package | |
| 79 | + release) | |
| 80 | + message="Create a release" | |
| 81 | + emoji="📦️" | |
| 82 | + ;; | |
| 83 | + | |
| 84 | + # 👽️: alien | |
| 85 | + alien|api) | |
| 86 | + message="Update code due to external API changes" | |
| 87 | + emoji="👽️" | |
| 88 | + ;; | |
| 89 | + | |
| 90 | + # 🐳: whale | |
| 91 | + docker|container) | |
| 92 | + message="Docker" | |
| 93 | + emoji="🐳" | |
| 94 | + ;; | |
| 95 | + | |
| 96 | + # 🍊: tangerine | |
| 97 | + gitpod|gitpodify) | |
| 98 | + message="Gitpodify" | |
| 99 | + emoji="🍊" | |
| 100 | + ;; | |
| 101 | + | |
| 102 | + # 🧪: test tube | |
| 103 | + alembic|experiments|experiment|xp) | |
| 104 | + message="Perform experiments" | |
| 105 | + emoji="🧪" | |
| 106 | + ;; | |
| 107 | + | |
| 108 | + # ✅: check mark | |
| 109 | + test|tests|testing) | |
| 110 | + message="Add or update tests" | |
| 111 | + emoji="✅" | |
| 112 | + ;; | |
| 113 | + | |
| 114 | + # 💾: floppy-disk | |
| 115 | + save) | |
| 116 | + message="Saved" | |
| 117 | + emoji="💾" | |
| 118 | + ;; | |
| 119 | + | |
| 120 | + *) | |
| 121 | + message="Updated" | |
| 122 | + emoji="🛟" | |
| 123 | + ;; | |
| 124 | + | |
| 125 | +esac | |
| 126 | + | |
| 127 | +find . -name '.DS_Store' -type f -delete | |
| 128 | + | |
| 129 | +if [ -z "$2" ] | |
| 130 | +then | |
| 131 | + # empty | |
| 132 | + git add .; git commit -m "$emoji $message."; git push | |
| 133 | +else | |
| 134 | + # not empty | |
| 135 | + git add .; git commit -m "$emoji $message: $2"; git push | |
| 136 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | +#!/bin/bash | ||
| 2 | +message="" | ||
| 3 | +case $1 in | ||
| 4 | + | ||
| 5 | + # 🎨: art | ||
| 6 | + art) | ||
| 7 | + message="Improve structure / format of the code" | ||
| 8 | + emoji="🎨" | ||
| 9 | + ;; | ||
| 10 | + | ||
| 11 | + # 🐛: bug | ||
| 12 | + bug|fix) | ||
| 13 | + message="Fix a bug" | ||
| 14 | + emoji="🐛" | ||
| 15 | + ;; | ||
| 16 | + | ||
| 17 | + # 🤓: geek | ||
| 18 | + human|human-fixed) | ||
| 19 | + message="Human Fixed" | ||
| 20 | + emoji="🤓" | ||
| 21 | + ;; | ||
| 22 | + | ||
| 23 | + # 🤖: robot | ||
| 24 | + ai|ai-generated) | ||
| 25 | + message="AI generated" | ||
| 26 | + emoji="🤖" | ||
| 27 | + ;; | ||
| 28 | + | ||
| 29 | + # ✨: sparkles | ||
| 30 | + sparkles|feature) | ||
| 31 | + message="Introduce new feature(s)" | ||
| 32 | + emoji="✨" | ||
| 33 | + ;; | ||
| 34 | + | ||
| 35 | + # 🧩: jigsaw | ||
| 36 | + jigsaw|example|examples|demo|demos) | ||
| 37 | + message="Introduce new example(s)" | ||
| 38 | + emoji="🧩" | ||
| 39 | + ;; | ||
| 40 | + | ||
| 41 | + | ||
| 42 | + # 📝: memo | ||
| 43 | + memo|doc|documentation) | ||
| 44 | + message="Add or update documentation" | ||
| 45 | + emoji="📝" | ||
| 46 | + ;; | ||
| 47 | + | ||
| 48 | + # 🌸: cherry_blossom | ||
| 49 | + gardening|garden|clean|cleaning) | ||
| 50 | + message="Gardening" | ||
| 51 | + emoji="🌸" | ||
| 52 | + ;; | ||
| 53 | + | ||
| 54 | + # 🚀: rocket | ||
| 55 | + rocket|deploy) | ||
| 56 | + message="Deploy stuff" | ||
| 57 | + emoji="🚀" | ||
| 58 | + ;; | ||
| 59 | + | ||
| 60 | + # 🎉: tada | ||
| 61 | + tada|first) | ||
| 62 | + message="Begin a project" | ||
| 63 | + emoji="🎉" | ||
| 64 | + ;; | ||
| 65 | + | ||
| 66 | + # 🚧: construction | ||
| 67 | + construction|wip) | ||
| 68 | + message="Work in progress" | ||
| 69 | + emoji="🚧" | ||
| 70 | + ;; | ||
| 71 | + | ||
| 72 | + # 📦️: package | ||
| 73 | + package|build) | ||
| 74 | + message="Add or update compiled files or packages" | ||
| 75 | + emoji="📦️" | ||
| 76 | + ;; | ||
| 77 | + | ||
| 78 | + # 📦️: package | ||
| 79 | + release) | ||
| 80 | + message="Create a release" | ||
| 81 | + emoji="📦️" | ||
| 82 | + ;; | ||
| 83 | + | ||
| 84 | + # 👽️: alien | ||
| 85 | + alien|api) | ||
| 86 | + message="Update code due to external API changes" | ||
| 87 | + emoji="👽️" | ||
| 88 | + ;; | ||
| 89 | + | ||
| 90 | + # 🐳: whale | ||
| 91 | + docker|container) | ||
| 92 | + message="Docker" | ||
| 93 | + emoji="🐳" | ||
| 94 | + ;; | ||
| 95 | + | ||
| 96 | + # 🍊: tangerine | ||
| 97 | + gitpod|gitpodify) | ||
| 98 | + message="Gitpodify" | ||
| 99 | + emoji="🍊" | ||
| 100 | + ;; | ||
| 101 | + | ||
| 102 | + # 🧪: test tube | ||
| 103 | + alembic|experiments|experiment|xp) | ||
| 104 | + message="Perform experiments" | ||
| 105 | + emoji="🧪" | ||
| 106 | + ;; | ||
| 107 | + | ||
| 108 | + # ✅: check mark | ||
| 109 | + test|tests|testing) | ||
| 110 | + message="Add or update tests" | ||
| 111 | + emoji="✅" | ||
| 112 | + ;; | ||
| 113 | + | ||
| 114 | + # 💾: floppy-disk | ||
| 115 | + save) | ||
| 116 | + message="Saved" | ||
| 117 | + emoji="💾" | ||
| 118 | + ;; | ||
| 119 | + | ||
| 120 | + *) | ||
| 121 | + message="Updated" | ||
| 122 | + emoji="🛟" | ||
| 123 | + ;; | ||
| 124 | + | ||
| 125 | +esac | ||
| 126 | + | ||
| 127 | +find . -name '.DS_Store' -type f -delete | ||
| 128 | + | ||
| 129 | +if [ -z "$2" ] | ||
| 130 | +then | ||
| 131 | + # empty | ||
| 132 | + git add .; git commit -m "$emoji $message."; git push | ||
| 133 | +else | ||
| 134 | + # not empty | ||
| 135 | + git add .; git commit -m "$emoji $message: $2"; git push | ||
| 136 | +fi | ||