feat: add search and tag filtering to /posts list

- patch-endpoint-micropub-source-filter: support ?category= and ?search=
  query params in the Micropub ?q=source endpoint, filtering MongoDB
  documents by properties.category and a case-insensitive regex across
  name/content fields
- patch-endpoint-posts-search-tags: forward category/search params from
  the posts controller to Micropub, expose tagLinks on each item, and
  replace the posts.njk cardGrid with a custom loop that renders clickable
  tag chips and a search form above the grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-03-15 11:58:36 +01:00
parent ee1313fb26
commit a52d93392f
3 changed files with 381 additions and 2 deletions

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-inbox-ignore-view-activity.mjs && node scripts/patch-inbox-skip-view-activity-parse.mjs && node scripts/patch-webmention-sender-content-scope.mjs",
"serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-inbox-ignore-view-activity.mjs && node scripts/patch-inbox-skip-view-activity-parse.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs",
"serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],

View File

@@ -0,0 +1,130 @@
/**
* Patch: add `category` and `search` filtering to the Micropub `?q=source`
* list endpoint so that /posts can filter by tag and full-text search.
*
* When `category` is provided:
* filter MongoDB documents where `properties.category` matches the value.
* When `search` is provided:
* filter by a case-insensitive regex across name / content fields.
*
* Filtered queries bypass getCursor (no cursor-based pagination needed for
* small result sets) and return all matching posts up to the current limit.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-micropub/lib/controllers/query.js",
"node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-micropub/lib/controllers/query.js",
"node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js",
];
const marker = "// filter-by-category-and-search patch";
const oldSnippet = ` } else {
// Return mf2 for published posts
let cursor = {
items: [],
hasNext: false,
hasPrev: false,
};
if (postsCollection) {
cursor = await getCursor(postsCollection, after, before, limit);
}`;
const newSnippet = ` } else {
// Return mf2 for published posts
// filter-by-category-and-search patch
const categoryParam = request.query.category;
const searchParam = request.query.search;
const hasExtraFilter = Boolean(categoryParam || searchParam);
let cursor = {
items: [],
hasNext: false,
hasPrev: false,
};
if (postsCollection) {
if (hasExtraFilter) {
const filterQuery = {};
if (categoryParam) {
filterQuery["properties.category"] = String(categoryParam);
}
if (searchParam) {
const re = String(searchParam).replace(
/[$()*+.?[\\\]^{|}]/g,
"\\$&",
);
filterQuery.$or = [
{ "properties.name": { $regex: re, $options: "i" } },
{
"properties.content.text": {
$regex: re,
$options: "i",
},
},
{ "properties.content": { $regex: re, $options: "i" } },
];
}
const findLimit = (limit && limit > 0) ? limit : 40;
const filteredItems = await postsCollection
.find(filterQuery, { limit: findLimit, sort: { _id: -1 } })
.toArray();
cursor = {
items: filteredItems,
hasNext: false,
hasPrev: false,
};
} else {
cursor = await getCursor(postsCollection, after, before, limit);
}
}`;
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) {
continue;
}
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(marker)) {
continue;
}
if (!source.includes(oldSnippet)) {
console.warn(
`[postinstall] Skipping micropub source-filter patch for ${filePath}: upstream format changed`,
);
continue;
}
const updated = source.replace(oldSnippet, newSnippet);
await writeFile(filePath, updated, "utf8");
patched += 1;
}
if (checked === 0) {
console.log("[postinstall] No micropub query controller files found");
} else if (patched === 0) {
console.log("[postinstall] micropub source-filter patch already applied");
} else {
console.log(
`[postinstall] Patched micropub source-filter in ${patched} file(s)`,
);
}

View File

@@ -0,0 +1,249 @@
/**
* Patch: add search input and tag chips to the /posts list page.
*
* Part A posts.js controller:
* - forward `category` and `search` query params to the Micropub source query
* - expose `item.tagLinks` (pre-encoded href + text) for each post
* - pass `activeCategory` and `activeSearch` to the template
*
* Part B posts.njk view:
* - replace the cardGrid call with a custom loop that appends clickable
* tag chips under each card
* - add a search form above the grid
*/
import { access, readFile, writeFile } from "node:fs/promises";
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
// ─── Part A: posts controller ────────────────────────────────────────────────
const controllerCandidates = [
"node_modules/@rmdes/indiekit-endpoint-posts/lib/controllers/posts.js",
"node_modules/@indiekit/endpoint-posts/lib/controllers/posts.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-posts/lib/controllers/posts.js",
"node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-posts/lib/controllers/posts.js",
];
const controllerMarker = "// search-tags controller patch";
// 1) forward category + search to the micropub URL
const oldForward = ` const { after, before, success } = request.query;
const limit = Number(request.query.limit) || 12;
const micropubUrl = new URL(application.micropubEndpoint);
micropubUrl.searchParams.append("q", "source");
micropubUrl.searchParams.append("limit", String(limit));
if (after) {
micropubUrl.searchParams.append("after", String(after));
}
if (before) {
micropubUrl.searchParams.append("before", String(before));
}`;
const newForward = ` // search-tags controller patch
const { after, before, category, search, success } = request.query;
const limit = Number(request.query.limit) || 12;
const micropubUrl = new URL(application.micropubEndpoint);
micropubUrl.searchParams.append("q", "source");
micropubUrl.searchParams.append("limit", String(limit));
if (after) {
micropubUrl.searchParams.append("after", String(after));
}
if (before) {
micropubUrl.searchParams.append("before", String(before));
}
if (category) {
micropubUrl.searchParams.append("category", String(category));
}
if (search) {
micropubUrl.searchParams.append("search", String(search));
}`;
// 2) add tagLinks to each post item
const oldBadges = ` item.badges = getPostStatusBadges(item, response);
return item;`;
const newBadges = ` item.badges = getPostStatusBadges(item, response);
const rawTags = Array.isArray(item.category)
? item.category
: item.category
? [item.category]
: [];
item.tagLinks = rawTags.map((t) => ({
text: t,
href: \`?category=\${encodeURIComponent(t)}\`,
}));
return item;`;
// 3) pass activeCategory + activeSearch to the render call
const oldRender = ` statusTypes,
success,
});`;
const newRender = ` statusTypes,
success,
activeCategory: category || "",
activeSearch: search || "",
});`;
// ─── Part B: posts view ───────────────────────────────────────────────────────
const viewCandidates = [
"node_modules/@rmdes/indiekit-endpoint-posts/views/posts.njk",
"node_modules/@indiekit/endpoint-posts/views/posts.njk",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-posts/views/posts.njk",
"node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-posts/views/posts.njk",
];
const viewMarker = "{# search-tags view patch #}";
const oldView = `{% extends "document.njk" %}
{% block content %}
{%- if posts.length > 0 %}
{{ cardGrid({
cardSize: "16rem",
items: posts
}) }}
{{ pagination(cursor) }}
{%- else -%}
{{ prose({ text: __("posts.posts.none") }) }}
{%- endif %}
{% endblock %}`;
const newView = `{% extends "document.njk" %}
{# search-tags view patch #}
{% block content %}
<form method="get" action="">
<div style="display:flex;gap:0.5rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1.5rem">
{{ input({
name: "search",
label: "Search",
value: activeSearch,
type: "search",
optional: true,
field: { classes: "-!-flex-grow" }
}) }}
{{ button({ text: "Search", type: "submit" }) }}
{%- if activeSearch or activeCategory %}
<a href="{{ parentUrl }}" class="button" style="align-self:flex-end">Clear</a>
{%- endif %}
</div>
</form>
{%- if activeCategory %}
<p style="margin-bottom:1rem">Filtered by tag: <strong>{{ activeCategory }}</strong></p>
{%- endif %}
{%- if posts.length > 0 %}
<ol class="card-grid" role="list" style="--min-card-size: 16rem">
{%- for item in posts %}
<li class="card-grid__item">
{{ card(item) | indent(4) }}
{%- if item.tagLinks and item.tagLinks.length > 0 %}
<dl class="prose--caption" style="padding:0 1rem 0.75rem">
<dt class="-!-visually-hidden">Tags</dt>
{%- for tagLink in item.tagLinks %}
<dd class="tag"><a href="{{ tagLink.href }}">{{ tagLink.text }}</a></dd>
{%- endfor %}
</dl>
{%- endif %}
</li>
{%- endfor %}
</ol>
{{ pagination(cursor) }}
{%- else -%}
{{ prose({ text: __("posts.posts.none") }) }}
{%- endif %}
{% endblock %}`;
// ─── Apply patches ────────────────────────────────────────────────────────────
let controllerChecked = 0;
let controllerPatched = 0;
for (const filePath of controllerCandidates) {
if (!(await exists(filePath))) continue;
controllerChecked += 1;
let source = await readFile(filePath, "utf8");
if (source.includes(controllerMarker)) continue;
let changed = false;
for (const [oldSnip, newSnip] of [
[oldForward, newForward],
[oldBadges, newBadges],
[oldRender, newRender],
]) {
if (!source.includes(oldSnip)) {
console.warn(
`[postinstall] posts search-tags: snippet not found in ${filePath}, skipping`,
);
changed = false;
break;
}
source = source.replace(oldSnip, newSnip);
changed = true;
}
if (changed) {
await writeFile(filePath, source, "utf8");
controllerPatched += 1;
}
}
let viewChecked = 0;
let viewPatched = 0;
for (const filePath of viewCandidates) {
if (!(await exists(filePath))) continue;
viewChecked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(viewMarker)) continue;
if (!source.includes(oldView)) {
console.warn(
`[postinstall] posts search-tags: view not found in ${filePath}, skipping`,
);
continue;
}
const updated = source.replace(oldView, newView);
await writeFile(filePath, updated, "utf8");
viewPatched += 1;
}
if (controllerChecked === 0 && viewChecked === 0) {
console.log("[postinstall] No endpoint-posts files found");
} else {
if (controllerPatched === 0 && controllerChecked > 0) {
console.log("[postinstall] posts search-tags controller already patched");
} else if (controllerPatched > 0) {
console.log(
`[postinstall] Patched posts search-tags controller in ${controllerPatched} file(s)`,
);
}
if (viewPatched === 0 && viewChecked > 0) {
console.log("[postinstall] posts search-tags view already patched");
} else if (viewPatched > 0) {
console.log(
`[postinstall] Patched posts search-tags view in ${viewPatched} file(s)`,
);
}
}