From 3a9b0d5dd2deb7aed1645136c8374a499a794a78 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 24 Feb 2026 22:37:00 +0100 Subject: [PATCH] docs: add per-category feeds implementation plan --- docs/plans/2026-02-24-category-feeds-plan.md | 442 +++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 docs/plans/2026-02-24-category-feeds-plan.md diff --git a/docs/plans/2026-02-24-category-feeds-plan.md b/docs/plans/2026-02-24-category-feeds-plan.md new file mode 100644 index 0000000..3fafc12 --- /dev/null +++ b/docs/plans/2026-02-24-category-feeds-plan.md @@ -0,0 +1,442 @@ +# Per-Category RSS and JSON Feeds — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Generate `/categories/{slug}/feed.xml` and `/categories/{slug}/feed.json` for every category so readers and AI agents can subscribe to specific topics. + +**Architecture:** A pre-built `categoryFeeds` collection in `eleventy.config.js` groups posts by category in a single O(posts) pass. Two pagination templates iterate over this collection to produce feed files. WebSub notifications are extended to cover category feed URLs. + +**Tech Stack:** Eleventy 3.x, Nunjucks, @11ty/eleventy-plugin-rss, WebSub + +--- + +### Task 1: Add `categoryFeeds` collection to eleventy.config.js + +**Files:** +- Modify: `eleventy.config.js:729` (after the existing `categories` collection, before `recentPosts`) + +**Step 1: Add the collection** + +Insert this code at `eleventy.config.js:730` (the blank line between the `categories` collection closing `});` and the `// Recent posts for sidebar` comment): + +```javascript + // Category feeds — pre-grouped posts for per-category RSS/JSON feeds + eleventyConfig.addCollection("categoryFeeds", function (collectionApi) { + const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); + const grouped = new Map(); // slug -> { name, slug, posts[] } + + collectionApi + .getFilteredByGlob("content/**/*.md") + .filter(isPublished) + .sort((a, b) => b.date - a.date) + .forEach((item) => { + if (!item.data.category) return; + const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category]; + for (const cat of cats) { + if (!cat || typeof cat !== "string" || !cat.trim()) continue; + const slug = slugify(cat.trim()); + if (!slug) continue; + if (!grouped.has(slug)) { + grouped.set(slug, { name: cat.trim(), slug, posts: [] }); + } + const entry = grouped.get(slug); + if (entry.posts.length < 50) { + entry.posts.push(item); + } + } + }); + + return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name)); + }); +``` + +**Step 2: Verify the collection builds** + +Run: +```bash +cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme +npx @11ty/eleventy --dryrun 2>&1 | head -20 +``` + +Expected: No errors. The dryrun should complete without crashing. You won't see categoryFeeds output yet since no template uses it. + +**Step 3: Commit** + +```bash +git add eleventy.config.js +git commit -m "feat: add categoryFeeds collection for per-category RSS/JSON feeds" +``` + +--- + +### Task 2: Create RSS 2.0 category feed template + +**Files:** +- Create: `category-feed.njk` + +**Step 1: Create the template** + +Create `category-feed.njk` in the project root (same level as `feed.njk`): + +```nunjucks +--- +eleventyExcludeFromCollections: true +eleventyImport: + collections: + - categoryFeeds +pagination: + data: collections.categoryFeeds + size: 1 + alias: categoryFeed +permalink: "categories/{{ categoryFeed.slug }}/feed.xml" +--- + + + + {{ site.name }} — {{ categoryFeed.name }} + {{ site.url }}/categories/{{ categoryFeed.slug }}/ + Posts tagged with "{{ categoryFeed.name }}" on {{ site.name }} + {{ site.locale | default('en') }} + + + {{ categoryFeed.posts | getNewestCollectionItemDate | dateToRfc822 }} + {%- for post in categoryFeed.posts %} + {%- set absolutePostUrl = site.url + post.url %} + {%- set postImage = post.data.photo %} + {%- if postImage %} + {%- if postImage[0] and (postImage[0] | length) > 10 %} + {%- set postImage = postImage[0] %} + {%- endif %} + {%- endif %} + {%- if not postImage or postImage == "" %} + {%- set postImage = post.data.image or (post.content | extractFirstImage) %} + {%- endif %} + + {{ post.data.title | default(post.content | striptags | truncate(80)) | escape }} + {{ absolutePostUrl }} + {{ absolutePostUrl }} + {{ post.date | dateToRfc822 }} + {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | escape }} + {%- if postImage and postImage != "" and (postImage | length) > 10 %} + {%- set imageUrl = postImage | url | absoluteUrl(site.url) %} + + + {%- endif %} + + {%- endfor %} + + +``` + +**Step 2: Verify the template generates feed files** + +Run: +```bash +npx @11ty/eleventy --dryrun 2>&1 | grep "category-feed.njk" | head -5 +``` + +Expected: Lines showing `Writing ... /categories//feed.xml from ./category-feed.njk` for multiple categories. + +**Step 3: Commit** + +```bash +git add category-feed.njk +git commit -m "feat: add RSS 2.0 per-category feed template" +``` + +--- + +### Task 3: Create JSON Feed 1.1 category feed template + +**Files:** +- Create: `category-feed-json.njk` + +**Step 1: Create the template** + +Create `category-feed-json.njk` in the project root: + +```nunjucks +--- +eleventyExcludeFromCollections: true +eleventyImport: + collections: + - categoryFeeds +pagination: + data: collections.categoryFeeds + size: 1 + alias: categoryFeed +permalink: "categories/{{ categoryFeed.slug }}/feed.json" +--- +{ + "version": "https://jsonfeed.org/version/1.1", + "title": "{{ site.name }} — {{ categoryFeed.name }}", + "home_page_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/", + "feed_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/feed.json", + "hubs": [ + { + "type": "WebSub", + "url": "https://websubhub.com/hub" + } + ], + "description": "Posts tagged with \"{{ categoryFeed.name }}\" on {{ site.name }}", + "language": "{{ site.locale | default('en') }}", + "authors": [ + { + "name": "{{ site.author.name | default(site.name) }}", + "url": "{{ site.url }}/" + } + ], + "_textcasting": { + "version": "1.0", + "about": "https://textcasting.org/" + {%- set hasSupport = site.support and (site.support.url or site.support.stripe or site.support.lightning or site.support.paymentPointer) %} + {%- if hasSupport %}, + "support": {{ site.support | textcastingSupport | jsonEncode | safe }} + {%- endif %} + }, + "items": [ + {%- for post in categoryFeed.posts %} + {%- set absolutePostUrl = site.url + post.url %} + {%- set postImage = post.data.photo %} + {%- if postImage %} + {%- if postImage[0] and (postImage[0] | length) > 10 %} + {%- set postImage = postImage[0] %} + {%- endif %} + {%- endif %} + {%- if not postImage or postImage == "" %} + {%- set postImage = post.data.image or (post.content | extractFirstImage) %} + {%- endif %} + { + "id": "{{ absolutePostUrl }}", + "url": "{{ absolutePostUrl }}", + "title": {% if post.data.title %}{{ post.data.title | jsonEncode | safe }}{% else %}null{% endif %}, + "content_html": {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | jsonEncode | safe }}, + "content_text": {{ post.content | striptags | jsonEncode | safe }}, + "date_published": "{{ post.date | dateToRfc3339 }}", + "date_modified": "{{ (post.data.updated or post.date) | dateToRfc3339 }}" + {%- if postImage and postImage != "" and (postImage | length) > 10 %}, + "image": "{{ postImage | url | absoluteUrl(site.url) }}" + {%- endif %} + {%- set attachments = post.data | feedAttachments %} + {%- if attachments.length > 0 %}, + "attachments": {{ attachments | jsonEncode | safe }} + {%- endif %} + }{% if not loop.last %},{% endif %} + {%- endfor %} + ] +} +``` + +**Step 2: Verify the template generates feed files** + +Run: +```bash +npx @11ty/eleventy --dryrun 2>&1 | grep "category-feed-json.njk" | head -5 +``` + +Expected: Lines showing `Writing ... /categories//feed.json from ./category-feed-json.njk` for multiple categories. + +**Step 3: Commit** + +```bash +git add category-feed-json.njk +git commit -m "feat: add JSON Feed 1.1 per-category feed template" +``` + +--- + +### Task 4: Add discovery link tags in base.njk + +**Files:** +- Modify: `_includes/layouts/base.njk:98` (after the markdown alternate link block, before the authorization_endpoint link) + +**Step 1: Add the conditional link tags** + +In `_includes/layouts/base.njk`, find this line (currently line 98): + +```nunjucks + {% endif %} + +``` + +Insert the category feed links between `{% endif %}` (closing the markdown agents block) and the authorization_endpoint link: + +```nunjucks + {% endif %} + {% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %} + + + {% endif %} + +``` + +**Step 2: Verify the link tags appear on a category page** + +Run a full build and check a category page: + +```bash +npx @11ty/eleventy 2>&1 | tail -3 +``` + +Then inspect a generated category page: + +```bash +grep -A1 'category.*RSS Feed' _site/categories/indieweb/index.html +``` + +Expected: The two `` tags with correct category slug in the href. + +**Step 3: Commit** + +```bash +git add _includes/layouts/base.njk +git commit -m "feat: add RSS/JSON feed discovery links on category pages" +``` + +--- + +### Task 5: Extend WebSub notifications for category feeds + +**Files:** +- Modify: `eleventy.config.js:876-893` (the WebSub notification block inside `eleventy.after`) + +**Step 1: Replace the WebSub notification block** + +Find the existing WebSub block (starts at line 874): + +```javascript + // WebSub hub notification — skip on incremental rebuilds + if (incremental) return; + const hubUrl = "https://websubhub.com/hub"; + const feedUrls = [ + `${siteUrl}/`, + `${siteUrl}/feed.xml`, + `${siteUrl}/feed.json`, + ]; + for (const feedUrl of feedUrls) { + try { + const res = await fetch(hubUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`, + }); + console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`); + } catch (err) { + console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message); + } + } +``` + +Replace with: + +```javascript + // WebSub hub notification — skip on incremental rebuilds + if (incremental) return; + const hubUrl = "https://websubhub.com/hub"; + const feedUrls = [ + `${siteUrl}/`, + `${siteUrl}/feed.xml`, + `${siteUrl}/feed.json`, + ]; + + // Discover category feed URLs from build output + const outputDir = directories?.output || dir.output; + const categoriesDir = resolve(outputDir, "categories"); + try { + for (const entry of readdirSync(categoriesDir, { withFileTypes: true })) { + if (entry.isDirectory() && existsSync(resolve(categoriesDir, entry.name, "feed.xml"))) { + feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.xml`); + feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.json`); + } + } + } catch { + // categoriesDir may not exist on first build — ignore + } + + console.log(`[websub] Notifying hub for ${feedUrls.length} URLs...`); + for (const feedUrl of feedUrls) { + try { + const res = await fetch(hubUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`, + }); + console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`); + } catch (err) { + console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message); + } + } +``` + +Note: `readdirSync`, `existsSync`, and `resolve` are already imported at the top of `eleventy.config.js` (line 14-15). + +**Step 2: Verify no syntax errors** + +Run: +```bash +npx @11ty/eleventy --dryrun 2>&1 | tail -5 +``` + +Expected: No errors. Dryrun completes normally. + +**Step 3: Commit** + +```bash +git add eleventy.config.js +git commit -m "feat: extend WebSub notifications to include category feed URLs" +``` + +--- + +### Task 6: Full build and end-to-end verification + +**Files:** None (verification only) + +**Step 1: Run a full Eleventy build** + +```bash +npx @11ty/eleventy 2>&1 | tail -5 +``` + +Expected: Build completes with increased file count (2 extra files per category). Look for the `Wrote NNNN files` summary line — it should be noticeably higher than the previous build count of ~2483. + +**Step 2: Verify RSS feed content** + +```bash +head -15 _site/categories/indieweb/feed.xml +``` + +Expected: Valid RSS 2.0 XML with `` containing the site name and category name, `<atom:link>` self and hub references, and `<item>` entries. + +**Step 3: Verify JSON feed content** + +```bash +head -20 _site/categories/indieweb/feed.json +``` + +Expected: Valid JSON Feed 1.1 with `title`, `feed_url`, `hubs`, and `items` array. + +**Step 4: Count generated category feeds** + +```bash +find _site/categories/ -name "feed.xml" | wc -l +find _site/categories/ -name "feed.json" | wc -l +``` + +Expected: Both counts should be equal and match the number of categories on the site. + +**Step 5: Verify discovery links in HTML** + +```bash +grep 'category.*feed.xml\|category.*feed.json' _site/categories/indieweb/index.html +``` + +Expected: Two `<link rel="alternate">` tags — one RSS, one JSON — with correct category slug URLs. + +**Step 6: Commit all work (if any uncommitted changes remain)** + +```bash +git status +``` + +If clean, no action needed. Otherwise commit any remaining changes.