diff --git a/docs/superpowers/plans/2026-04-09-tag-search.md b/docs/superpowers/plans/2026-04-09-tag-search.md new file mode 100644 index 00000000..e15788bd --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-tag-search.md @@ -0,0 +1,387 @@ +# Tag Entry Autocomplete Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the category/tag input in the Indiekit post compose form searchable by wiring a native datalist autocomplete to a MongoDB-backed `q=category` API endpoint. + +**Architecture:** Two patch scripts following the project's marker pattern. Patch 1 overrides the Micropub `q=category` handler to query MongoDB `posts.distinct("properties.category")` instead of the empty config array. Patch 2 adds a `` and debounced fetch to the `` web component. + +**Tech Stack:** Node.js ESM patch scripts, MongoDB `distinct()`, browser `fetch` + ``, existing `queryConfig` utility. + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `scripts/patch-micropub-category-from-posts.mjs` | **Create** | Backend patch — MongoDB distinct query for `q=category` | +| `scripts/patch-tag-input-autocomplete.mjs` | **Create** | Frontend patch — datalist autocomplete on tag input | +| `package.json` | **Modify** | Register both patches in `postinstall` and `serve` scripts | + +Patch targets (read-only reference, patched in-place at startup): +- `node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js` — `case "categories":` inserted before `default:` +- `node_modules/@indiekit/frontend/components/tag-input/index.js` — datalist + event listeners appended before `return tagInput` + +--- + +## Task 1: Write the backend patch script + +**Files:** +- Create: `scripts/patch-micropub-category-from-posts.mjs` + +- [ ] **Step 1: Create the patch script** + +```js +/** + * Patch: make q=category return distinct tags from MongoDB posts collection. + * + * In queryController, line 28 rewrites q === "category" → "categories" before + * the switch, so the new case label must be "categories" (not "category"). + * The default branch returns config.categories (always []) because + * publication.categories is not configured. This patch inserts a + * case "categories": before default: that queries the posts collection instead. + * + * postsCollection may be undefined (no MongoDB). The ternary provides a [] + * fallback consistent with the guard pattern used throughout query.js. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const MARKER = "// [patch] micropub-category-from-posts"; + +const candidates = [ + "node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js", + "node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js", +]; + +const OLD_SNIPPET = ` break; + } + + default: { + // Query configuration value (can be filtered, limited and offset) + if (config[q]) {`; + +const NEW_SNIPPET = ` break; + } + + case "categories": { ${MARKER} + const cats = postsCollection + ? (await postsCollection.distinct("properties.category")).filter(Boolean).sort() + : []; + response.json({ + categories: queryConfig(cats, { filter: filter?.toLowerCase(), limit, offset }), + }); + break; + } + + default: { + // Query configuration value (can be filtered, limited and offset) + if (config[q]) {`; + +async function exists(p) { + try { await access(p); return true; } catch { return false; } +} + +let totalPatched = 0; +let totalChecked = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) continue; + totalChecked++; + + const source = await readFile(filePath, "utf8"); + if (source.includes(MARKER)) { + console.log(`[postinstall] patch-micropub-category-from-posts: already applied to ${filePath}`); + continue; + } + + if (!source.includes(OLD_SNIPPET)) { + console.warn(`[postinstall] patch-micropub-category-from-posts: snippet not found in ${filePath} (upstream changed?)`); + continue; + } + + const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); + await writeFile(filePath, updated, "utf8"); + console.log(`[postinstall] Applied patch-micropub-category-from-posts to ${filePath}`); + totalPatched++; +} + +if (totalChecked === 0) { + console.log("[postinstall] patch-micropub-category-from-posts: no target files found"); +} else if (totalPatched === 0) { + console.log("[postinstall] patch-micropub-category-from-posts: already up to date"); +} else { + console.log(`[postinstall] patch-micropub-category-from-posts: patched ${totalPatched} file(s)`); +} +``` + +- [ ] **Step 2: Run the patch script to verify it applies cleanly** + +```bash +node scripts/patch-micropub-category-from-posts.mjs +``` + +Expected output: `Applied patch-micropub-category-from-posts to node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js` + +If you see "snippet not found", the source file has changed upstream. Open `node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js`, find the `default:` block at the end of the switch, and update `OLD_SNIPPET` to match its actual content exactly (spaces, not tabs). + +- [ ] **Step 3: Verify the patch landed correctly** + +```bash +grep -A 10 "case .categories" node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js +``` + +Expected: the new `case "categories":` block appears before `default:`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/patch-micropub-category-from-posts.mjs +git commit -m "feat: patch q=category to return distinct tags from MongoDB posts" +``` + +--- + +## Task 2: Write the frontend patch script + +**Files:** +- Create: `scripts/patch-tag-input-autocomplete.mjs` + +- [ ] **Step 1: Create the patch script** + +```js +/** + * Patch: add datalist autocomplete to the web component. + * + * Fetches /micropub?q=category&filter= as the user types (debounced + * 300ms) and populates a native . Selecting a suggestion fills the + * text field; the user confirms with comma or blur as usual. + * + * Datalist is explicitly cleared in keydown(Comma) and blur handlers because + * the existing handlers clear the field via programmatic .value = "" which + * does NOT fire the input event (HTML Living Standard). + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const MARKER = "// [patch] tag-input-autocomplete"; + +const candidates = [ + "node_modules/@indiekit/frontend/components/tag-input/index.js", + "node_modules/@indiekit/indiekit/node_modules/@indiekit/frontend/components/tag-input/index.js", +]; + +const OLD_SNIPPET = ` // Capture any value in input not converted to tag (for example, by clicking + // outside component before pressing tab key) and add to list of tags. + $tagInputInput.addEventListener("blur", () => { + if ($tagInputInput.value) { + tagInput.addTag($tagInputInput.value, false); + $tagInputInput.value = ""; + } + }); + + return tagInput;`; + +const NEW_SNIPPET = ` // Capture any value in input not converted to tag (for example, by clicking + // outside component before pressing tab key) and add to list of tags. + $tagInputInput.addEventListener("blur", () => { + if ($tagInputInput.value) { + tagInput.addTag($tagInputInput.value, false); + $tagInputInput.value = ""; + } + }); + + // Autocomplete: wire datalist to Micropub q=category endpoint ${MARKER} + const _micropubHref = + document.querySelector('link[rel="micropub"]')?.href ?? "/micropub"; + const _datalistId = + "tag-suggestions-" + (this.$replacedInput?.getAttribute("name") ?? Math.random().toString(36).slice(2)); + const _$datalist = document.createElement("datalist"); + _$datalist.id = _datalistId; + this.appendChild(_$datalist); + $tagInputInput.setAttribute("list", _datalistId); + + let _autocompleteTimer; + $tagInputInput.addEventListener("input", () => { + clearTimeout(_autocompleteTimer); + const _val = $tagInputInput.value.trim(); + if (!_val) { _$datalist.replaceChildren(); return; } + _autocompleteTimer = setTimeout(async () => { + try { + const _res = await fetch( + \`\${_micropubHref}?q=category&filter=\${encodeURIComponent(_val)}\` + ); + if (!_res.ok) return; + const _data = await _res.json(); + _$datalist.replaceChildren( + ...(_data.categories ?? []).map((_cat) => { + const _opt = document.createElement("option"); + _opt.value = _cat; + return _opt; + }) + ); + } catch {} + }, 300); + }); + + // Clear datalist after tag confirmed via comma (programmatic clear doesn't fire input) + $tagInputInput.addEventListener("keydown", (_e) => { + if (_e.code === "Comma") _$datalist.replaceChildren(); + }); + + // Clear datalist after tag confirmed via blur + $tagInputInput.addEventListener("blur", () => { + _$datalist.replaceChildren(); + }); + + return tagInput;`; + +async function exists(p) { + try { await access(p); return true; } catch { return false; } +} + +let totalPatched = 0; +let totalChecked = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) continue; + totalChecked++; + + const source = await readFile(filePath, "utf8"); + if (source.includes(MARKER)) { + console.log(`[postinstall] patch-tag-input-autocomplete: already applied to ${filePath}`); + continue; + } + + if (!source.includes(OLD_SNIPPET)) { + console.warn(`[postinstall] patch-tag-input-autocomplete: snippet not found in ${filePath} (upstream changed?)`); + continue; + } + + const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); + await writeFile(filePath, updated, "utf8"); + console.log(`[postinstall] Applied patch-tag-input-autocomplete to ${filePath}`); + totalPatched++; +} + +if (totalChecked === 0) { + console.log("[postinstall] patch-tag-input-autocomplete: no target files found"); +} else if (totalPatched === 0) { + console.log("[postinstall] patch-tag-input-autocomplete: already up to date"); +} else { + console.log(`[postinstall] patch-tag-input-autocomplete: patched ${totalPatched} file(s)`); +} +``` + +- [ ] **Step 2: Run the patch script to verify it applies cleanly** + +```bash +node scripts/patch-tag-input-autocomplete.mjs +``` + +Expected output: `Applied patch-tag-input-autocomplete to node_modules/@indiekit/frontend/components/tag-input/index.js` + +If you see "snippet not found", open the target file and compare the actual closing of the `blur` handler and `return tagInput` — they must match `OLD_SNIPPET` byte-for-byte including spaces (not tabs). + +- [ ] **Step 3: Verify the patch landed correctly** + +```bash +grep -A 5 "tag-input-autocomplete" node_modules/@indiekit/frontend/components/tag-input/index.js +``` + +Expected: the datalist setup code appears before `return tagInput;`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/patch-tag-input-autocomplete.mjs +git commit -m "feat: patch tag-input component with datalist autocomplete" +``` + +--- + +## Task 3: Register both patches in package.json + +**Files:** +- Modify: `package.json` + +Both patches are safe for `postinstall` and `serve` — they are idempotent (marker check skips re-application). + +- [ ] **Step 1: Add both patches to the end of the `postinstall` script** + +In `package.json`, the `postinstall` script currently ends with: + +``` +node scripts/patch-ap-startup-gate-bypass.mjs +``` + +Append to the end of `postinstall` (after `patch-ap-startup-gate-bypass.mjs`): + +``` +&& node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs +``` + +- [ ] **Step 2: Add both patches to the `serve` script before the final node command** + +In the `serve` script, `patch-ap-startup-gate-bypass.mjs` appears near the **start** (after preflights), not the end. The script currently ends with: + +``` +node scripts/patch-ap-oauth-token-expiry-fix.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs +``` + +Change it so the two new patches come between `patch-ap-oauth-token-expiry-fix.mjs` and the final `node --require` invocation: + +``` +node scripts/patch-ap-oauth-token-expiry-fix.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs +``` + +- [ ] **Step 3: Verify JSON is valid** + +```bash +node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('OK')" +``` + +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "chore: register tag autocomplete patches in postinstall and serve" +``` + +--- + +## Task 4: End-to-end verification + +No automated test framework exists. Verify manually after `npm run serve`. + +- [ ] **Step 1: Confirm backend endpoint returns tags** + +```bash +# While the server is running, test with curl (replace token with a valid one, +# or just open this URL in the browser while logged into the admin UI): +curl -s "http://localhost:3000/micropub?q=category" | node -e "process.stdin|>require('stream/consumers').json()|>d=>console.log(JSON.stringify(d,null,2))" 2>/dev/null || \ + curl -s "http://localhost:3000/micropub?q=category" +``` + +Expected: `{ "categories": ["photography", "travel", ...] }` — actual tags from your posts, sorted alphabetically. + +- [ ] **Step 2: Confirm filter works** + +Open in browser (while logged in): +``` +http://localhost:3000/micropub?q=category&filter=photo +``` + +Expected: only tags containing "photo" (case-insensitive). + +- [ ] **Step 3: Verify autocomplete in the compose UI** + +1. Open `/posts/create` or the AP/Microsub compose form +2. Click the category/tag field +3. Type 2–3 characters of a known tag +4. Verify a dropdown appears with matching suggestions +5. Click a suggestion → text field fills with the tag name +6. Press comma → tag becomes a pill, dropdown clears +7. Type another tag partially → blur by clicking elsewhere → tag confirmed, dropdown clears