Files
indiekit-server/scripts/patch-tag-input-autocomplete.mjs
2026-04-09 21:28:54 +02:00

122 lines
4.3 KiB
JavaScript

/**
* 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)`);
}