docs: tag search implementation plan

This commit is contained in:
Sven
2026-04-09 21:21:41 +02:00
parent f2629a4ac3
commit cb259d7b86
@@ -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 `<datalist>` and debounced fetch to the `<tag-input-field>` web component.
**Tech Stack:** Node.js ESM patch scripts, MongoDB `distinct()`, browser `fetch` + `<datalist>`, 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 <tag-input-field> web component.
*
* Fetches /micropub?q=category&filter=<value> as the user types (debounced
* 300ms) and populates a native <datalist>. 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 23 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