docs: tag search implementation plan
This commit is contained in:
@@ -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 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
|
||||
Reference in New Issue
Block a user