chore: remove dev plans from published repo

Plans moved to central /home/rick/code/indiekit-dev/docs/plans/
This commit is contained in:
Ricardo
2026-02-27 17:31:30 +01:00
parent 4c8c44a49e
commit 82db66c258
8 changed files with 0 additions and 2782 deletions

View File

@@ -1,100 +0,0 @@
# Per-Category RSS and JSON Feeds — Design
## Goal
Generate `/categories/{slug}/feed.xml` (RSS 2.0) and `/categories/{slug}/feed.json` (JSON Feed 1.1) for every category, so readers and AI agents can subscribe to specific topics.
## Architecture
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. No filtering happens in Nunjucks — templates receive pre-sorted, pre-limited post arrays.
## Components
### 1. Data Layer — `categoryFeeds` Collection
New collection in `eleventy.config.js`. Single pass over all published posts, grouping by category slug. Each entry:
```
{ name: "IndieWeb", slug: "indieweb", posts: [post1, post2, ...] }
```
- Posts sorted newest-first, limited to 50 per category
- Uses the existing `slugify` logic from the `categories` collection
- Independent from the existing `categories` collection (which stays untouched)
### 2. Feed Templates
**`category-feed.njk`** — RSS 2.0
- Pagination: `collections.categoryFeeds`, size 1, alias `categoryFeed`
- Permalink: `/categories/{{ categoryFeed.slug }}/feed.xml`
- Channel title: `"{{ site.name }} — {{ categoryFeed.name }}"`
- Self link: category feed URL
- WebSub hub link: `https://websubhub.com/hub`
- Items: iterate `categoryFeed.posts` — same structure as main `feed.njk`
- `eleventyExcludeFromCollections: true`
- `eleventyImport.collections: [categoryFeeds]`
**`category-feed-json.njk`** — JSON Feed 1.1
- Same pagination setup, permalink `.json`
- Same structure as main `feed-json.njk` with category-specific title/feed_url
- Includes textcasting support, attachments, image fallback chain
### 3. Discovery — Link Tags in `base.njk`
Conditional `<link rel="alternate">` tags for category pages:
```nunjucks
{% if category and page.url.startsWith('/categories/') and page.url != '/categories/' %}
<link rel="alternate" type="application/rss+xml"
href="/categories/{{ category | slugify }}/feed.xml"
title="{{ category }} — RSS Feed">
<link rel="alternate" type="application/json"
href="/categories/{{ category | slugify }}/feed.json"
title="{{ category }} — JSON Feed">
{% endif %}
```
The `category` variable flows from the `categories.njk` pagination alias through the data cascade into the layout.
### 4. WebSub Notifications
Extend the existing `eleventy.after` hook:
- After full builds (non-incremental), scan `categories/*/feed.xml` in the output directory
- Notify `https://websubhub.com/hub` for each discovered category feed URL (both RSS and JSON)
- Batch into a single POST request where possible
- Same incremental guard as existing notifications
### 5. Incremental Build Behavior
No special handling required:
- Templates declare `eleventyImport.collections: [categoryFeeds]`
- Eleventy 3.x rebuilds all dependent pages when the collection changes
- All category feeds regenerate on any post change (acceptable — feed templates are cheap text, no image processing)
- WebSub notifications only fire on full builds (same as current behavior)
## Files Changed
| File | Change |
|------|--------|
| `eleventy.config.js` | Add `categoryFeeds` collection; extend WebSub notification in `eleventy.after` |
| `category-feed.njk` | New — RSS 2.0 pagination template |
| `category-feed-json.njk` | New — JSON Feed 1.1 pagination template |
| `_includes/layouts/base.njk` | Add conditional `<link rel="alternate">` for category pages |
## Not Changed
- Main feeds (`feed.njk`, `feed-json.njk`) — untouched
- Category HTML pages (`categories.njk`, `categories-index.njk`) — untouched
- nginx/Caddy config — static files served automatically
- Deployment repos — no config changes needed
- Pagefind, markdown-agents — no interaction
## Constraints
- 50 items per category feed
- RSS 2.0 and JSON Feed 1.1 formats matching existing main feeds
- WebSub hub: `https://websubhub.com/hub`

View File

@@ -1,442 +0,0 @@
# 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"
---
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>{{ site.name }} — {{ categoryFeed.name }}</title>
<link>{{ site.url }}/categories/{{ categoryFeed.slug }}/</link>
<description>Posts tagged with "{{ categoryFeed.name }}" on {{ site.name }}</description>
<language>{{ site.locale | default('en') }}</language>
<atom:link href="{{ site.url }}/categories/{{ categoryFeed.slug }}/feed.xml" rel="self" type="application/rss+xml"/>
<atom:link href="https://websubhub.com/hub" rel="hub"/>
<lastBuildDate>{{ categoryFeed.posts | getNewestCollectionItemDate | dateToRfc822 }}</lastBuildDate>
{%- 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 %}
<item>
<title>{{ post.data.title | default(post.content | striptags | truncate(80)) | escape }}</title>
<link>{{ absolutePostUrl }}</link>
<guid isPermaLink="true">{{ absolutePostUrl }}</guid>
<pubDate>{{ post.date | dateToRfc822 }}</pubDate>
<description>{{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | escape }}</description>
{%- if postImage and postImage != "" and (postImage | length) > 10 %}
{%- set imageUrl = postImage | url | absoluteUrl(site.url) %}
<enclosure url="{{ imageUrl }}" type="image/jpeg" length="0"/>
<media:content url="{{ imageUrl }}" medium="image"/>
{%- endif %}
</item>
{%- endfor %}
</channel>
</rss>
```
**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/<slug>/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/<slug>/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 %}
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
```
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/' %}
<link rel="alternate" type="application/rss+xml" href="/categories/{{ category | slugify }}/feed.xml" title="{{ category }} — RSS Feed">
<link rel="alternate" type="application/json" href="/categories/{{ category | slugify }}/feed.json" title="{{ category }} — JSON Feed">
{% endif %}
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
```
**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 `<link rel="alternate">` 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 `<title>` 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.

View File

@@ -1,241 +0,0 @@
# Homepage UI/UX Improvements — Design Document
**Date:** 2026-02-24
**Scope:** indiekit-eleventy-theme (rendering layer only)
**Status:** APPROVED
## Context
The homepage at rmendes.net is a data-driven page controlled by the `indiekit-endpoint-homepage` plugin. The plugin's admin UI determines which sections and sidebar widgets appear. This design addresses rendering quality improvements to three areas without changing the data model or plugin architecture.
The homepage uses a `two-column` layout with a full-width hero, main content sections (Recent Posts, Personal Skills, Personal Interests, Personal Projects), and a sidebar with 7+ widgets (Search, Social Activity, Recent Comments, Webmentions, Blogroll, GitHub, Listening, Author h-card).
### Design principles
- Improve visual quality of existing templates, not content decisions
- Content selection stays with the user via the homepage plugin config
- All changes are in the Eleventy theme (Nunjucks templates + Tailwind CSS)
- Use Alpine.js for interactivity (already loaded throughout the theme)
- Respect the personal vs work data split (homepage = personal, /cv/ = work)
- Never remove IndieWeb infrastructure (h-card, webmentions, microformats)
## Change 1: Projects Accordion
### Problem
The `cv-projects` section renders full paragraph descriptions for every project. On the homepage, 5 projects with multi-line descriptions dominate the page, pushing sidebar content far below the viewport. The section reads like a resume rather than a scannable overview.
### Design
Convert project cards from always-expanded to an accordion pattern using Alpine.js.
**Collapsed state (default):** Single row showing:
- Project name (linked if URL exists)
- Status badge (active/maintained/archived/completed)
- Date range (e.g., "2022-02 Present")
- Chevron toggle icon (right-aligned)
**Expanded state (on click):** Full card content:
- Description paragraph
- Technology tags
- Smooth reveal via `x-transition`
**File:** `_includes/components/sections/cv-projects.njk`
**Behavior:**
- All projects start collapsed on page load
- Click anywhere on the summary row to toggle
- Multiple projects can be open simultaneously (independent toggles, not mutual exclusion)
- The 2-column grid layout is preserved — each card in the grid is independently collapsible
- Chevron rotates 180deg when expanded
**Markup pattern:**
```html
<section x-data="{ expanded: {} }">
<!-- For each project -->
<div class="project-card">
<button @click="expanded[index] = !expanded[index]">
<h3>Name</h3> <span>status</span> <span>dates</span> <chevron :class="expanded[index] && 'rotate-180'">
</button>
<div x-show="expanded[index]" x-transition>
<p>description</p>
<div>tech tags</div>
</div>
</div>
</section>
```
**Visual details:**
- Summary row: `flex items-center justify-between` with `cursor-pointer`
- Hover: `hover:bg-surface-50 dark:hover:bg-surface-700/50` on the summary row
- Chevron: `w-4 h-4 text-surface-400 transition-transform duration-200`
- Transition: `x-transition:enter="transition ease-out duration-200"` with opacity + translate-y
### Impact
Reduces vertical space of the projects section by ~70% in collapsed state. Visitors can scan project names and drill into details on interest.
## Change 2: Sidebar Widget Collapsibility
### Problem
The sidebar has 7+ widgets stacked vertically, each fully expanded. The sidebar is longer than the main content area, and widgets below the fold (GitHub, Listening, Author h-card) are only reachable after significant scrolling.
### Design
Add a collapsible wrapper around each widget in `homepage-sidebar.njk`. Widget titles become clickable toggle buttons with a chevron indicator. Collapse state persists in `localStorage`.
**Default state (first visit):**
- First 3 widgets in the sidebar config: **open**
- Remaining widgets: **collapsed** (title + chevron visible)
**Return visits:** `localStorage` restores the user's last toggle state for each widget.
**Files changed:**
- `_includes/components/homepage-sidebar.njk` — add wrapper around each widget include
- `css/tailwind.css` — add `.widget-collapsible` styles
- Individual widget files — extract `<h3>` title to be passed as a variable OR keep title inside but hide it when the wrapper provides one
**Architecture decision:** The wrapper approach. Rather than modifying 10+ individual widget files, the sidebar dispatcher wraps each widget include in a collapsible container. This requires knowing the widget title at the dispatcher level.
**Title resolution:** Each widget type has a known title (Search, Social Activity, GitHub, Listening, Blogroll, etc.). The dispatcher maps `widget.type` to a display title, or uses `widget.config.title` if set. The individual widget files keep their own `<h3>` tags — the wrapper hides the inner title via CSS when the wrapper provides one, or we remove the inner `<h3>` from widget files and let the wrapper handle all titles uniformly.
**Recommended approach:** Remove `<h3>` from individual widget files and let the wrapper handle titles. This is cleaner and avoids duplicate headings. Each widget file keeps its content only.
**Markup pattern:**
```html
{% set widgetTitle = "Social Activity" %}
{% set widgetKey = "widget-social-activity" %}
{% set defaultOpen = loop.index0 < 3 %}
<div class="widget" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }">
<button
class="widget-header"
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
aria-expanded="open"
>
<h3 class="widget-title">{{ widgetTitle }}</h3>
<svg :class="open && 'rotate-180'" class="chevron">...</svg>
</button>
<div x-show="open" x-transition x-cloak>
{% include "components/widgets/social-activity.njk" %}
</div>
</div>
```
**Visual details:**
- Widget header: `flex items-center justify-between cursor-pointer`
- Chevron: `w-4 h-4 text-surface-400 transition-transform duration-200`
- No visual change when open — widget looks exactly as it does today
- When collapsed: only the header row (title + chevron) is visible, with the existing widget border/background
- Smooth transition: `x-transition:enter="transition ease-out duration-150"`
**Widget title map:**
| widget.type | Title |
|-------------|-------|
| search | Search |
| social-activity | Social Activity |
| github-repos | GitHub |
| funkwhale | Listening |
| recent-posts | Recent Posts |
| blogroll | Blogroll |
| feedland | FeedLand |
| categories | Categories |
| webmentions | Webmentions |
| recent-comments | Recent Comments |
| fediverse-follow | Fediverse |
| author-card | Author |
| custom-html | (from widget.config.title or "Custom") |
### Impact
Reduces initial sidebar scroll length. Visitors see all widget titles at a glance and expand what interests them. First-time visitors get a curated view (top 3 open), returning visitors get their preferred configuration.
## Change 3: Post Card Color-Coded Left Borders
### Problem
All post cards in the `recent-posts` section use identical styling (white bg, gray border, rounded-lg). When scrolling a mixed feed of notes, reposts, replies, likes, bookmarks, and photos, the only way to distinguish post types is by reading the small icon + label text inside each card. There's no scannable visual signal at the card level.
### Design
Add a `border-l-3` (3px left border) to each `<article>` in `recent-posts.njk`, colored by post type. The colors match the existing SVG icon colors already used inside the cards.
**Color mapping:**
| Post Type | Left Border Color | Matches Existing |
|-----------|------------------|-----------------|
| Like | `border-l-red-400` | `text-red-500` heart icon |
| Bookmark | `border-l-amber-400` | `text-amber-500` bookmark icon |
| Repost | `border-l-green-400` | `text-green-500` repost icon |
| Reply | `border-l-primary-400` | `text-primary-500` reply icon |
| Photo | `border-l-purple-400` | `text-purple-500` camera icon |
| Article | `border-l-surface-300 dark:border-l-surface-600` | Neutral, matches header text weight |
| Note | `border-l-surface-300 dark:border-l-surface-600` | Neutral, matches header text weight |
**File:** `_includes/components/sections/recent-posts.njk`
**Implementation:** Add the border class to each `<article>` element. The template already branches by post type (like, bookmark, repost, reply, photo, article, note) so each branch gets its specific border color.
**Before:**
```html
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
```
**After (example for repost):**
```html
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 border-l-3 border-l-green-400 dark:border-l-green-500 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
```
**Visual details:**
- `border-l-3` (3px) is enough to be noticeable without being heavy
- The left border color is constant (doesn't change on hover) — the top/right/bottom borders still change to primary on hover
- Dark mode uses slightly brighter variants (400 in light, 500 in dark) for visibility
- `rounded-lg` still applies — the left border gets a subtle radius at top-left and bottom-left corners
**Tailwind note:** `border-l-3` is not a default Tailwind class. Options:
1. Use `border-l-4` (4px, default Tailwind) — slightly thicker but no config change
2. Add `borderWidth: { 3: '3px' }` to `tailwind.config.js` extend — exact 3px
3. Use arbitrary value `border-l-[3px]` — works without config change
**Recommendation:** Use `border-l-[3px]` (arbitrary value). No config change needed, exact width desired.
### Impact
Instant visual scanability of the feed. Visitors can quickly identify post types by color without reading text labels. The feed feels more alive and differentiated.
## Files Modified (Summary)
| File | Change |
|------|--------|
| `_includes/components/sections/cv-projects.njk` | Alpine.js accordion with collapsed summary rows |
| `_includes/components/sections/recent-posts.njk` | Add `border-l-[3px]` with type-specific colors to each article |
| `_includes/components/homepage-sidebar.njk` | Collapsible wrapper around each widget with localStorage persistence |
| `_includes/components/widgets/*.njk` (10+ files) | Remove `<h3>` widget titles (moved to sidebar wrapper) |
| `css/tailwind.css` | Add `.widget-header` and `.widget-collapsible` styles |
## Files NOT Modified
- `tailwind.config.js` — no config changes needed (using arbitrary values)
- `_data/*.js` — no data changes
- `eleventy.config.js` — no config changes
- `indiekit-endpoint-homepage/` — no plugin changes
- `indiekit-endpoint-cv/` — no plugin changes
## Testing
1. Verify homepage renders correctly with all three changes
2. Test accordion open/close on projects section
3. Test sidebar collapse/expand and localStorage persistence (close browser, reopen, verify state)
4. Test dark mode for all color-coded borders
5. Test mobile responsiveness (sidebar stacks to full-width, widgets should still be collapsible)
6. Verify h-card microformat markup is preserved in the author-card widget
7. Verify the /cv/ page is unaffected (cv-projects on /cv/ uses a different template or the same template — if same, accordion applies there too, which is acceptable)
8. Visual check with playwright-cli on the live site after deployment
## Risks
- **Widget title extraction:** Moving titles from individual widget files to the wrapper requires updating 10+ files. Risk of missing one or breaking a title.
- **localStorage key collisions:** Using `widget-{type}` as keys. If the same widget type appears twice in the sidebar config, they'd share state. Mitigate by using `widget-{index}` or `widget-{type}-{index}`.
- **Alpine.js load order:** Widgets wrapped in `<is-land on:visible>` may not have Alpine.js available when the wrapper tries to initialize. Solution: the wrapper's `x-data` is outside `<is-land>`, so Alpine handles the toggle, and `<is-land>` handles lazy-loading the widget content inside.

View File

@@ -1,592 +0,0 @@
# Homepage UI/UX Improvements — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Improve homepage scannability by adding post card color-coded borders, collapsible project accordion, and collapsible sidebar widgets.
**Architecture:** Three independent rendering changes in the Eleventy theme's Nunjucks templates + Tailwind CSS. No data model changes. Alpine.js handles all interactivity (already loaded). localStorage persists sidebar widget collapse state.
**Tech Stack:** Nunjucks templates, Tailwind CSS (arbitrary values), Alpine.js, localStorage
**Design doc:** `docs/plans/2026-02-24-homepage-ui-ux-design.md`
---
## Task 1: Post Card Color-Coded Left Borders
**Files:**
- Modify: `_includes/components/sections/recent-posts.njk`
This is the simplest change — add a `border-l-[3px]` class with a type-specific color to each `<article>` element. The template already branches by post type (like, bookmark, repost, reply, photo, article, note), so each branch gets its own color.
**Color mapping (from design doc):**
| Post Type | Classes |
|-----------|---------|
| Like | `border-l-[3px] border-l-red-400 dark:border-l-red-500` |
| Bookmark | `border-l-[3px] border-l-amber-400 dark:border-l-amber-500` |
| Repost | `border-l-[3px] border-l-green-400 dark:border-l-green-500` |
| Reply | `border-l-[3px] border-l-primary-400 dark:border-l-primary-500` |
| Photo | `border-l-[3px] border-l-purple-400 dark:border-l-purple-500` |
| Article | `border-l-[3px] border-l-surface-300 dark:border-l-surface-600` |
| Note | `border-l-[3px] border-l-surface-300 dark:border-l-surface-600` |
### Step 1: Implement the color-coded borders
The `<article>` tag on **line 19** is shared by ALL post types. The type detection happens INSIDE the article (lines 22-27 set variables, lines 28-226 branch by type). Since we need different border colors per type, we must move the `<article>` tag inside each branch, OR use a Nunjucks variable to set the border class before the article opens.
**Approach:** Set a border class variable before the `<article>` tag using Nunjucks `{% set %}` blocks. This keeps the single `<article>` tag and avoids duplicating it 7 times.
In `_includes/components/sections/recent-posts.njk`, replace the block from line 18 through line 28 (the `{% for %}`, type detection variables, and `<article>` opening) with this version that sets a border class variable:
**Current (lines 18-19):**
```nunjucks
{% for post in collections.posts | head(maxItems) %}
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
```
**After:** Insert the type detection BEFORE the `<article>` tag, set a `borderClass` variable, and add it to the article's class list:
```nunjucks
{% for post in collections.posts | head(maxItems) %}
{# Detect post type for color-coded left border #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{% if likedUrl %}
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
{% elif bookmarkedUrl %}
{% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
{% elif repostedUrl %}
{% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
{% elif replyToUrl %}
{% set borderClass = "border-l-[3px] border-l-primary-400 dark:border-l-primary-500" %}
{% elif hasPhotos %}
{% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
{% else %}
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
{% endif %}
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 {{ borderClass }} hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
```
Then **remove** the duplicate type detection variables that currently exist inside the article (lines 22-26), since they've been moved above. The rest of the template still uses these same variable names in the `{% if likedUrl %}` / `{% elif %}` branches, so those continue to work — the variables are already set.
**Important:** The type detection variables (`likedUrl`, `bookmarkedUrl`, `repostedUrl`, `replyToUrl`, `hasPhotos`) are currently declared on lines 22-26 inside the `<article>`. After this change, they're declared before the `<article>`. Since they're still within the same `{% for %}` loop scope, all subsequent `{% if %}` checks on lines 28+ continue to reference them correctly. Remove lines 22-26 to avoid redeclaring the same variables.
### Step 2: Build and verify
Run:
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npm run build
```
Expected: Build completes with exit 0, no template errors.
### Step 3: Visual verification with playwright-cli
```bash
playwright-cli open https://rmendes.net
playwright-cli snapshot
```
Verify: Post cards in "Recent Posts" section have colored left borders (red for likes, green for reposts, etc.). Take a screenshot for evidence.
### Step 4: Commit
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git add _includes/components/sections/recent-posts.njk
git commit -m "feat: add color-coded left borders to post cards by type"
```
---
## Task 2: Projects Accordion
**Files:**
- Modify: `_includes/components/sections/cv-projects.njk`
Convert the always-expanded 2-column project cards grid into an Alpine.js accordion. Each card shows a collapsed summary row (name + status badge + date range + chevron) and expands on click to reveal description + technology tags.
### Step 1: Implement the accordion
Replace the entire content of `_includes/components/sections/cv-projects.njk` with the accordion version.
**Current behavior:** Lines 16-58 render a `grid grid-cols-1 sm:grid-cols-2 gap-4` with each card showing name, status, dates, description, and tech tags all at once.
**New behavior:** Same grid layout, but each card has:
- A clickable summary row (always visible): project name (linked if URL), status badge, date range, chevron icon
- A collapsible detail section (hidden by default): description + tech tags, revealed with `x-show` + `x-transition`
**Full replacement for `cv-projects.njk`:**
```nunjucks
{#
CV Projects Section - collapsible project cards (accordion)
Data fetched from /cv/data.json via homepage plugin
#}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 10 %}
{% set showTechnologies = sectionConfig.showTechnologies if sectionConfig.showTechnologies is defined else true %}
{% if cv and cv.projects and cv.projects.length %}
<section class="mb-8 sm:mb-12" id="projects" x-data="{ expanded: {} }">
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
{{ sectionConfig.title or "Projects" }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{% for item in cv.projects | head(maxItems) %}
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors overflow-hidden">
{# Summary row — always visible, clickable #}
<button
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
{% if item.url %}
<a href="{{ item.url }}" class="hover:text-primary-600 dark:hover:text-primary-400" @click.stop>{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</h3>
{% if item.status %}
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full capitalize
{% if item.status == 'active' %}bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
{% elif item.status == 'maintained' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% elif item.status == 'archived' %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400
{% else %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400{% endif %}">
{{ item.status }}
</span>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if item.startDate %}
<span class="text-xs text-surface-500 hidden sm:inline">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</span>
{% endif %}
<svg
class="w-4 h-4 text-surface-400 transition-transform duration-200"
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
{# Detail section — collapsible #}
<div
x-show="expanded[{{ loop.index0 }}]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
x-cloak
class="px-4 pb-4"
>
{% if item.startDate %}
<p class="text-xs text-surface-500 mb-1 sm:hidden">
{{ item.startDate }}{% if item.endDate %} {{ item.endDate }}{% else %} Present{% endif %}
</p>
{% endif %}
{% if item.description %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">{{ item.description }}</p>
{% endif %}
{% if showTechnologies and item.technologies and item.technologies.length %}
<div class="flex flex-wrap gap-1">
{% for tech in item.technologies %}
<span class="text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400 rounded">
{{ tech }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
```
**Key details:**
- `x-data="{ expanded: {} }"` on the `<section>` — object-based tracking, independent toggles
- `@click.stop` on the project name `<a>` link — prevents the button click handler from firing when clicking the link
- Date range shown in summary row on `sm:` screens, and duplicated inside the collapsible detail for mobile (`sm:hidden`)
- `x-cloak` hides detail sections during Alpine.js initialization
- `x-transition` with opacity + translate-y for smooth reveal
- Chevron rotates 180deg via `:class="expanded[index] && 'rotate-180'"`
- `aria-expanded` attribute for accessibility
### Step 2: Build and verify
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npm run build
```
Expected: Exit 0.
### Step 3: Visual verification with playwright-cli
```bash
playwright-cli open https://rmendes.net
playwright-cli snapshot
```
Verify: Projects section shows collapsed cards with name + status + date + chevron. Click a project card to expand — description and tech tags appear with smooth animation.
### Step 4: Commit
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git add _includes/components/sections/cv-projects.njk
git commit -m "feat: convert projects section to collapsible accordion"
```
---
## Task 3: Sidebar Widget Collapsibility
**Files:**
- Modify: `_includes/components/homepage-sidebar.njk` — add collapsible wrapper
- Modify: `css/tailwind.css` — add `.widget-header` and `.widget-collapsible` styles
- Modify: 10 widget files — remove `<h3>` titles (moved to sidebar wrapper)
This is the most complex change. The sidebar dispatcher wraps each widget in a collapsible Alpine.js container. The wrapper provides the `<h3>` title + chevron toggle, and a CSS rule hides the inner widget title to avoid duplication.
### Step 1: Add CSS classes for widget collapsibility
In `css/tailwind.css`, add these classes inside the existing `@layer components` block (after the `.widget-title` rule, around line 293):
```css
/* Collapsible widget wrapper */
.widget-header {
@apply flex items-center justify-between cursor-pointer;
}
.widget-header .widget-title {
@apply mb-0;
}
.widget-chevron {
@apply w-4 h-4 text-surface-400 transition-transform duration-200 shrink-0;
}
/* Hide inner widget titles when the collapsible wrapper provides one */
.widget-collapsible .widget .widget-title {
@apply hidden;
}
/* Hide FeedLand's custom title in collapsible wrapper */
.widget-collapsible .widget .fl-title {
@apply hidden;
}
```
### Step 2: Rewrite homepage-sidebar.njk with collapsible wrapper
Replace the entire content of `_includes/components/homepage-sidebar.njk`.
**Widget title map** (from design doc):
| widget.type | Title |
|-------------|-------|
| search | Search |
| social-activity | Social Activity |
| github-repos | GitHub |
| funkwhale | Listening |
| recent-posts | Recent Posts |
| blogroll | Blogroll |
| feedland | FeedLand |
| categories | Categories |
| webmentions | Webmentions |
| recent-comments | Recent Comments |
| fediverse-follow | Fediverse |
| author-card | Author |
| custom-html | (from widget.config.title or "Custom") |
**New `homepage-sidebar.njk`:**
```nunjucks
{# Homepage Builder Sidebar — renders widgets from homepageConfig.sidebar #}
{# Each widget is wrapped in a collapsible container with localStorage persistence #}
{% if homepageConfig.sidebar and homepageConfig.sidebar.length %}
{% for widget in homepageConfig.sidebar %}
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
{% elif widget.type == "feedland" %}{% set widgetTitle = "FeedLand" %}
{% elif widget.type == "categories" %}{% set widgetTitle = "Categories" %}
{% elif widget.type == "webmentions" %}{% set widgetTitle = "Webmentions" %}
{% elif widget.type == "recent-comments" %}{% set widgetTitle = "Recent Comments" %}
{% elif widget.type == "fediverse-follow" %}{% set widgetTitle = "Fediverse" %}
{% elif widget.type == "author-card" %}{% set widgetTitle = "Author" %}
{% elif widget.type == "custom-html" %}{% set widgetTitle = (widget.config.title if widget.config and widget.config.title) or "Custom" %}
{% else %}{% set widgetTitle = widget.type %}
{% endif %}
{% set widgetKey = "widget-" + widget.type + "-" + loop.index0 %}
{% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
{# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
<div
class="widget-collapsible mb-4"
x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }"
>
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<button
class="widget-header w-full p-4"
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
:aria-expanded="open ? 'true' : 'false'"
>
<h3 class="widget-title font-bold text-lg">{{ widgetTitle }}</h3>
<svg
class="widget-chevron"
:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
{# Widget content — inner .widget provides padding, inner title hidden by CSS #}
{% if widget.type == "author-card" %}
{% include "components/widgets/author-card.njk" %}
{% elif widget.type == "social-activity" %}
{% include "components/widgets/social-activity.njk" %}
{% elif widget.type == "github-repos" %}
{% include "components/widgets/github-repos.njk" %}
{% elif widget.type == "funkwhale" %}
{% include "components/widgets/funkwhale.njk" %}
{% elif widget.type == "recent-posts" %}
{% include "components/widgets/recent-posts.njk" %}
{% elif widget.type == "blogroll" %}
{% include "components/widgets/blogroll.njk" %}
{% elif widget.type == "feedland" %}
{% include "components/widgets/feedland.njk" %}
{% elif widget.type == "categories" %}
{% include "components/widgets/categories.njk" %}
{% elif widget.type == "search" %}
<div class="widget">
<div id="sidebar-search"></div>
<script>initPagefind("#sidebar-search");</script>
</div>
{% elif widget.type == "webmentions" %}
{% include "components/widgets/webmentions.njk" %}
{% elif widget.type == "recent-comments" %}
{% include "components/widgets/recent-comments.njk" %}
{% elif widget.type == "fediverse-follow" %}
{% include "components/widgets/fediverse-follow.njk" %}
{% elif widget.type == "custom-html" %}
{% set wConfig = widget.config or {} %}
<div class="widget">
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
{% else %}
<!-- Unknown widget type: {{ widget.type }} -->
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
```
**Key architecture decisions:**
- The wrapper provides the outer card styling (`bg-white`, `rounded-lg`, `border`, `shadow-sm`) and the title + chevron
- The inner widget files keep their `.widget` class, but the inner title is hidden via CSS `.widget-collapsible .widget .widget-title { display: none; }`
- The `search` widget was previously inline in the sidebar — it's now included directly (no separate file), with the inner `<h3>` removed since the wrapper provides it
- The `custom-html` widget's inner `<h3>` is removed — the wrapper uses `widget.config.title` or "Custom"
- The `<is-land on:visible>` wrappers remain inside the individual widget files — the collapsible wrapper is OUTSIDE `<is-land>`, so the toggle works immediately even before the lazy-loaded content initializes
- `widgetKey` uses `widget.type + "-" + loop.index0` to avoid localStorage key collisions if the same widget type appears twice
- First 3 widgets open by default (`loop.index0 < 3`), rest collapsed
**Note on widget `.widget` class and double borders:** The wrapper div already has `bg-white rounded-lg border shadow-sm`, and the inner `.widget` class also has those styles. To avoid double borders/shadows, we need to neutralize the inner `.widget` styling when inside `.widget-collapsible`. Add this to the CSS in Step 1:
```css
/* Neutralize inner widget card styling when inside collapsible wrapper */
.widget-collapsible .widget {
@apply border-0 shadow-none rounded-none mb-0 bg-transparent;
}
```
### Step 3: Build and verify
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npm run build
```
Expected: Exit 0.
### Step 4: Visual verification with playwright-cli
```bash
playwright-cli open https://rmendes.net
playwright-cli snapshot
```
Verify:
- All sidebar widgets have a title + chevron header
- First 3 widgets are expanded, remaining are collapsed
- Click a collapsed widget title → it expands smoothly
- Click an expanded widget title → it collapses
- No duplicate titles visible (inner titles hidden)
- No double borders or shadows on widget cards
### Step 5: Test localStorage persistence
```bash
playwright-cli click <ref-of-a-widget-header> # Toggle a widget
playwright-cli eval "localStorage.getItem('widget-social-activity-1')"
playwright-cli close
playwright-cli open https://rmendes.net
playwright-cli snapshot
```
Verify: The widget you toggled retains its state after page reload.
### Step 6: Verify dark mode
```bash
playwright-cli eval "document.documentElement.classList.add('dark')"
playwright-cli screenshot --filename=dark-mode-sidebar
```
Verify: Widget headers, chevrons, and collapsed/expanded states look correct in dark mode.
### Step 7: Verify mobile responsiveness
```bash
playwright-cli resize 375 812
playwright-cli snapshot
```
Verify: Sidebar stacks below main content, widgets are still collapsible.
### Step 8: Commit
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git add _includes/components/homepage-sidebar.njk css/tailwind.css
git commit -m "feat: add collapsible sidebar widgets with localStorage persistence"
```
---
## Task 4: Deploy and Final Verification
**Files:** None (deployment commands only)
### Step 1: Push theme repo
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git push origin main
```
### Step 2: Update submodule in indiekit-cloudron
```bash
cd /home/rick/code/indiekit-dev/indiekit-cloudron
git submodule update --remote eleventy-site
git add eleventy-site
git commit -m "chore: update eleventy-site submodule (homepage UI/UX improvements)"
git push origin main
```
### Step 3: Build and deploy
```bash
cd /home/rick/code/indiekit-dev/indiekit-cloudron
make prepare
cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
```
### Step 4: Final visual verification on live site
```bash
playwright-cli open https://rmendes.net
playwright-cli screenshot --filename=homepage-final
playwright-cli snapshot
```
Verify all three changes are live:
1. Post cards have color-coded left borders
2. Projects section is collapsible (all collapsed by default)
3. Sidebar widgets are collapsible (first 3 open, rest collapsed)
4. Dark mode works for all changes
5. h-card (Author widget) is present and contains proper microformat markup
---
## Risk Mitigation Notes
1. **`<is-land>` + Alpine.js interaction:** The collapsible wrapper's `x-data` is OUTSIDE `<is-land>`. Alpine.js initializes the toggle immediately. The `<is-land on:visible>` inside widget files handles lazy-loading the widget content. This means the toggle button works before the widget content loads — expanding an unloaded widget triggers `<is-land>` visibility, which then loads the content.
2. **FeedLand widget:** Uses custom `fl-title` instead of `widget-title`. The CSS rule `.widget-collapsible .widget .fl-title { display: none; }` handles this case.
3. **Author card widget:** Has no inner `<h3>` — it just includes `h-card.njk`. The CSS hiding rule won't find anything to hide, which is fine. The wrapper provides "Author" as the title.
4. **Search widget:** Was previously inline in the sidebar with its own `<is-land>` + `<h3>`. Now it's inline inside the collapsible wrapper with the `<h3>` removed. The `<is-land>` wrapper is preserved inside for lazy-loading Pagefind.
**Wait — re-reading the new sidebar template:** The search widget was changed to NOT use `<is-land>` in the inline version. Let me note this: the search widget should keep its `<is-land on:visible>` wrapper inside the collapsible content div. Update the search case to:
```nunjucks
{% elif widget.type == "search" %}
<is-land on:visible>
<div class="widget">
<div id="sidebar-search"></div>
<script>initPagefind("#sidebar-search");</script>
</div>
</is-land>
```
5. **Custom HTML widget:** Similarly should keep `<is-land on:visible>` wrapper:
```nunjucks
{% elif widget.type == "custom-html" %}
{% set wConfig = widget.config or {} %}
<is-land on:visible>
<div class="widget">
{% if wConfig.content %}
<div class="prose dark:prose-invert prose-sm max-w-none">
{{ wConfig.content | safe }}
</div>
{% endif %}
</div>
</is-land>
```

View File

@@ -1,177 +0,0 @@
# Editable Identity via Homepage Plugin — Design
## Goal
Make all author identity fields (hero content, h-card data, social links) editable from the Indiekit admin UI, stored permanently in MongoDB via the homepage plugin's existing `identity` field.
## Architecture
Extend `indiekit-endpoint-homepage` with a three-tab admin interface and an `identity` data section. The theme templates check `homepageConfig.identity.*` first, falling back to `site.author.*` environment variables. No new plugin, no new data file — identity lives in the existing `homepage.json`.
## Tab Structure
### Tab 1: Homepage Builder (`/homepage`)
Existing functionality, reorganized. Contains:
- Layout selection (presets + radio options)
- Hero config (enabled, show social toggles)
- Content Sections (drag-drop)
- Homepage Sidebar (drag-drop)
- Footer (drag-drop)
### Tab 2: Blog Sidebar (`/homepage/blog-sidebar`)
Extracted from the current single-page dashboard:
- Blog Listing Sidebar (drag-drop widget list)
- Blog Post Sidebar (drag-drop widget list)
### Tab 3: Identity (`/homepage/identity`)
New form page with sections:
**Profile:**
- `name` — text input
- `avatar` — text input (URL)
- `title` — text input (job title / subtitle)
- `pronoun` — text input
- `bio` — textarea
- `description` — textarea (site description shown in hero)
**Location:**
- `locality` — text input (city)
- `country` — text input
- `org` — text input (organization)
**Contact:**
- `url` — text input (author URL)
- `email` — text input
- `keyUrl` — text input (PGP key URL)
**Skills:**
- `categories` — tag-input component (comma-separated skills/interests)
**Social Links:**
- Full CRUD list using add-another pattern
- Each entry: `name` (text), `url` (text), `rel` (text, default "me"), `icon` (select from: github, linkedin, bluesky, mastodon, activitypub)
- Add, remove, reorder
## Data Model
### MongoDB Document (`homepageConfig` collection)
The existing singleton document gains an `identity` field:
```javascript
{
_id: "homepage",
layout: "two-column",
hero: { enabled: true, showSocial: true },
sections: [...],
sidebar: [...],
blogListingSidebar: [...],
blogPostSidebar: [...],
footer: [...],
identity: {
name: "Ricardo Mendes",
avatar: "https://...",
title: "Middleware Engineer & DevOps Specialist",
pronoun: "he/him",
bio: "Building infrastructure, automating workflows...",
description: "DevOps engineer, IndieWeb enthusiast...",
locality: "Brussels",
country: "Belgium",
org: "",
url: "https://rmendes.net",
email: "rick@example.com",
keyUrl: "https://...",
categories: ["IndieWeb", "OSINT", "DevOps"],
social: [
{ name: "GitHub", url: "https://github.com/rmdes", rel: "me", icon: "github" },
{ name: "Bluesky", url: "https://bsky.app/profile/rmendes.net", rel: "me atproto", icon: "bluesky" },
...
]
},
updatedAt: "ISO 8601 string"
}
```
### JSON File
Written to `content/.indiekit/homepage.json` on save (same as existing behavior). Eleventy file watcher triggers rebuild.
## Data Precedence
```
homepageConfig.identity.name → if truthy, use it
→ else fall back to site.author.name (env var)
```
Applied in the theme templates (hero.njk, h-card.njk) and anywhere else that reads `site.author.*` or `site.social`.
The simplest approach: create a computed `author` object in `_data/homepageConfig.js` that merges identity over site.author, so templates can use a single variable.
## Components Used (from @rmdes/indiekit-frontend)
| Field | Component |
|-------|-----------|
| Name, title, pronoun, locality, country, org, url, email, keyUrl | `input()` macro |
| Bio, description | `textarea()` macro |
| Categories/skills | `tag-input()` macro |
| Social links | `add-another()` macro wrapping input fields per entry |
| Icon selection | `select()` macro with predefined icon options |
| Form sections | `fieldset()` macro with legends |
| Save/cancel | `button()` macro (primary/secondary) |
| Errors | `errorSummary()` + field-level `errorMessage` |
## Tab Navigation
URL-based tabs using server-rendered pages (not client-side switching):
- `GET /homepage` — Homepage Builder tab
- `GET /homepage/blog-sidebar` — Blog Sidebar tab
- `GET /homepage/identity` — Identity tab
Each tab is a separate form with its own POST endpoint:
- `POST /homepage/save` — existing, handles layout/hero/sections/sidebar/footer
- `POST /homepage/save-blog-sidebar` — handles blogListingSidebar + blogPostSidebar
- `POST /homepage/save-identity` — handles identity fields
A shared tab navigation bar appears at the top of all three pages.
## Files Changed
### indiekit-endpoint-homepage (plugin)
| File | Change |
|------|--------|
| `index.js` | Add identity routes, identity configSchema |
| `lib/controllers/dashboard.js` | Split blog sidebar into separate GET handler, add identity GET/POST |
| `lib/controllers/api.js` | Add identity to public config endpoint |
| `lib/storage/config.js` | Handle identity save/merge |
| `views/homepage-dashboard.njk` | Remove blog sidebar sections, add tab nav |
| `views/homepage-blog-sidebar.njk` | New — extracted blog sidebar UI |
| `views/homepage-identity.njk` | New — identity editor form |
| `views/partials/tab-nav.njk` | New — shared tab navigation partial |
| `locales/en.json` | Add identity i18n strings |
### indiekit-eleventy-theme (theme)
| File | Change |
|------|--------|
| `_data/homepageConfig.js` | Merge identity over site.author, expose computed `author` object |
| `_includes/components/sections/hero.njk` | Use merged author data instead of raw site.author |
| `_includes/components/h-card.njk` | Use merged author data instead of raw site.author |
## Not Changed
- `_data/site.js` — env vars remain as fallback source, untouched
- Main feed templates — don't reference author data
- Post layout — uses site.author for meta tags (will get override via merged data)
- Other plugins — no changes needed
## Constraints
- Identity editor uses standard Indiekit frontend components (no custom JS beyond add-another)
- Social link icons limited to the set already defined in hero.njk/h-card.njk SVGs (github, linkedin, bluesky, mastodon, activitypub) — extensible later
- Avatar is a URL field, not file upload (avatar image hosting is separate)
- All dates stored as ISO 8601 strings

View File

@@ -1,468 +0,0 @@
# Editable Identity via Homepage Plugin — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make all author identity fields editable from the Indiekit admin UI via a three-tab homepage dashboard with Identity CRUD.
**Architecture:** Extend `indiekit-endpoint-homepage` with URL-based tabs (Homepage Builder, Blog Sidebar, Identity). Identity data stored in `homepageConfig.identity` in MongoDB + `homepage.json`. Theme templates check identity data first, falling back to `site.author.*` env vars.
**Tech Stack:** Express.js, Nunjucks, @rmdes/indiekit-frontend components, MongoDB, Eleventy
---
### Task 1: Add tab navigation partial and new routes in index.js
**Files:**
- Create: `indiekit-endpoint-homepage/views/partials/tab-nav.njk`
- Modify: `indiekit-endpoint-homepage/index.js`
**Step 1: Create the tab navigation partial**
Create `views/partials/tab-nav.njk`:
```nunjucks
{# Tab navigation for homepage admin - server-rendered URL tabs #}
<style>
.hp-tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-outline-variant, #ddd);
margin-block-end: var(--space-xl, 2rem);
}
.hp-tab-nav__item {
padding: var(--space-s, 0.75rem) var(--space-m, 1.25rem);
text-decoration: none;
color: var(--color-on-offset, #666);
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.hp-tab-nav__item:hover {
color: var(--color-primary, #0066cc);
}
.hp-tab-nav__item--active {
color: var(--color-primary, #0066cc);
border-bottom-color: var(--color-primary, #0066cc);
font-weight: 600;
}
</style>
<nav class="hp-tab-nav" aria-label="Homepage settings">
<a href="{{ homepageEndpoint }}"
class="hp-tab-nav__item{% if activeTab == 'builder' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.builder") }}
</a>
<a href="{{ homepageEndpoint }}/blog-sidebar"
class="hp-tab-nav__item{% if activeTab == 'blog-sidebar' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.blogSidebar") }}
</a>
<a href="{{ homepageEndpoint }}/identity"
class="hp-tab-nav__item{% if activeTab == 'identity' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.identity") }}
</a>
</nav>
```
**Step 2: Add new routes in index.js**
In the `get routes()` getter, after the existing routes (after `protectedRouter.get("/api/config", apiController.getConfig);`), add:
```javascript
// Blog sidebar tab
protectedRouter.get("/blog-sidebar", dashboardController.getBlogSidebar);
protectedRouter.post("/save-blog-sidebar", dashboardController.saveBlogSidebar);
// Identity tab
protectedRouter.get("/identity", dashboardController.getIdentity);
protectedRouter.post("/save-identity", dashboardController.saveIdentity);
```
**Step 3: Verify**
Run: `node -e "import('./index.js')"` from the plugin directory to check for syntax errors.
**Step 4: Commit**
```bash
git add views/partials/tab-nav.njk index.js
git commit -m "feat(homepage): add tab navigation partial and identity/blog-sidebar routes"
```
---
### Task 2: Add i18n strings for tabs and identity editor
**Files:**
- Modify: `indiekit-endpoint-homepage/locales/en.json`
**Step 1: Add the new i18n keys**
Add `"tabs"` block as a new top-level key inside `"homepageBuilder"`, and add `"identity"` block. Keep all existing keys unchanged.
Add under `"homepageBuilder"`:
```json
"tabs": {
"builder": "Homepage",
"blogSidebar": "Blog Sidebar",
"identity": "Identity"
},
```
Add the `"identity"` block:
```json
"identity": {
"title": "Identity",
"description": "Configure your author profile, contact details, and social links. These override environment variable defaults.",
"saved": "Identity saved successfully. Refresh your site to see changes.",
"profile": {
"legend": "Profile",
"name": { "label": "Name", "hint": "Your display name" },
"avatar": { "label": "Avatar URL", "hint": "URL to your avatar image" },
"title": { "label": "Title", "hint": "Job title or subtitle" },
"pronoun": { "label": "Pronoun", "hint": "e.g. he/him, she/her, they/them" },
"bio": { "label": "Bio", "hint": "Short biography" },
"description": { "label": "Site Description", "hint": "Description shown in the hero section" }
},
"location": {
"legend": "Location",
"locality": { "label": "City", "hint": "City or locality" },
"country": { "label": "Country" },
"org": { "label": "Organization", "hint": "Company or organization" }
},
"contact": {
"legend": "Contact",
"url": { "label": "URL", "hint": "Your personal website URL" },
"email": { "label": "Email" },
"keyUrl": { "label": "PGP Key URL", "hint": "URL to your public PGP key" }
},
"skills": {
"legend": "Skills & Interests",
"categories": { "label": "Categories", "hint": "Comma-separated skills, interests, or tags" }
},
"social": {
"legend": "Social Links",
"description": "Add links to your social profiles. These appear in the hero section and h-card.",
"name": { "label": "Name" },
"url": { "label": "URL" },
"rel": { "label": "Rel" },
"icon": { "label": "Icon" }
}
}
```
**Step 2: Verify no JSON syntax errors**
Run: `node -e "JSON.parse(require('fs').readFileSync('locales/en.json','utf8')); console.log('OK')"`
**Step 3: Commit**
```bash
git add locales/en.json
git commit -m "feat(homepage): add i18n strings for tabs and identity editor"
```
---
### Task 3: Add dashboard controller methods for blog sidebar and identity
**Files:**
- Modify: `indiekit-endpoint-homepage/lib/controllers/dashboard.js`
**Step 1: Add `parseSocialLinks` helper function**
Add at the top of the file, after the existing imports:
```javascript
/**
* Parse social links from form body.
* Express parses social[0][name], social[0][url] etc. into nested objects.
*/
function parseSocialLinks(body) {
const social = [];
if (!body.social) return social;
const entries = Array.isArray(body.social) ? body.social : Object.values(body.social);
for (const entry of entries) {
if (!entry || (!entry.name && !entry.url)) continue;
social.push({
name: entry.name || "",
url: entry.url || "",
rel: entry.rel || "me",
icon: entry.icon || "",
});
}
return social;
}
```
**Step 2: Update the existing `get` method**
Add `activeTab: "builder"` to the `response.render()` call.
**Step 3: Update the existing `save` method**
The save method must preserve blog sidebar and identity data that are no longer part of the homepage builder form. Read the current config first and merge:
```javascript
// Get current config to preserve fields not in this form
let currentConfig = await getConfig(application);
const config = {
layout: layout || "single-column",
hero: typeof hero === "string" ? JSON.parse(hero) : hero,
sections: typeof sections === "string" ? JSON.parse(sections) : sections,
sidebar: typeof sidebar === "string" ? JSON.parse(sidebar) : sidebar,
blogListingSidebar: currentConfig?.blogListingSidebar || [],
blogPostSidebar: currentConfig?.blogPostSidebar || [],
footer: typeof footer === "string" ? JSON.parse(footer) : footer,
identity: currentConfig?.identity || null,
};
```
**Step 4: Add `getBlogSidebar` controller method**
Renders `homepage-blog-sidebar` view with `activeTab: "blog-sidebar"`, current config, widgets, and blogPostWidgets.
**Step 5: Add `saveBlogSidebar` controller method**
Reads `blogListingSidebar` and `blogPostSidebar` from request body, preserves all other config fields, saves, redirects to `/homepage/blog-sidebar?saved=1`.
**Step 6: Add `getIdentity` controller method**
Reads `config.identity || {}`, renders `homepage-identity` view with `activeTab: "identity"`.
**Step 7: Add `saveIdentity` controller method**
Parses form fields (`identity-name`, `identity-bio`, etc.) and social links using `parseSocialLinks(body)`. Builds identity object, preserves all other config fields, saves, redirects to `/homepage/identity?saved=1`.
**Step 8: Verify**
Run: `node -e "import('./lib/controllers/dashboard.js')"` to check for syntax errors.
**Step 9: Commit**
```bash
git add lib/controllers/dashboard.js
git commit -m "feat(homepage): add blog sidebar and identity controller methods"
```
---
### Task 4: Refactor homepage-dashboard.njk — remove blog sidebar, add tab nav
**Files:**
- Modify: `indiekit-endpoint-homepage/views/homepage-dashboard.njk`
**Step 1: Add tab nav include**
After the page header `</header>`, before the success message `{% if request.query.saved %}`, add:
```nunjucks
{% include "partials/tab-nav.njk" %}
```
**Step 2: Remove blog sidebar sections from HTML**
Remove the two `<section>` blocks:
- Blog Listing Sidebar (with `id="blog-listing-sidebar-list"` and `id="blog-listing-sidebar-json"`)
- Blog Post Sidebar (with `id="blog-post-sidebar-list"` and `id="blog-post-sidebar-json"`)
**Step 3: Remove blog sidebar JavaScript**
From the `<script>` block, remove:
- `var blogListingSidebar = ...` and `var blogPostSidebar = ...` parsing
- `.forEach` key assignment for both
- All `addBlogListingWidget`, `removeBlogListingWidget`, `editBlogListingWidget`, `updateBlogListingSidebar` functions
- All `addBlogPostWidget`, `removeBlogPostWidget`, `editBlogPostWidget`, `updateBlogPostSidebar` functions
- `syncBlogListingSidebarFromDom` and `syncBlogPostSidebarFromDom`
- SortableJS entries for `blog-listing-sidebar-list` and `blog-post-sidebar-list`
- Initial render calls `updateBlogListingSidebar()` and `updateBlogPostSidebar()`
- Event listeners for `[data-add-blog-listing-widget]` and `[data-add-blog-post-widget]`
**Step 4: Verify**
Navigate to `/homepage` in the admin UI. Tab nav appears. Blog sidebar sections are gone. Saving layout/hero/sections/sidebar/footer still works.
**Step 5: Commit**
```bash
git add views/homepage-dashboard.njk
git commit -m "refactor(homepage): extract blog sidebar from main dashboard, add tab nav"
```
---
### Task 5: Create blog sidebar tab view
**Files:**
- Create: `indiekit-endpoint-homepage/views/homepage-blog-sidebar.njk`
**Step 1: Create the view**
This view extends `document.njk`, includes the tab nav, and contains the Blog Listing Sidebar and Blog Post Sidebar sections extracted from the old homepage-dashboard.njk. It includes:
- Same CSS classes as the main dashboard (`hp-section`, `hp-sections-list`, `hp-section-item`, etc.)
- Tab nav include with `activeTab: "blog-sidebar"`
- Two sections: Blog Listing Sidebar and Blog Post Sidebar (same HTML structure as before)
- Hidden JSON inputs for `blogListingSidebar` and `blogPostSidebar`
- Form POSTs to `{{ homepageEndpoint }}/save-blog-sidebar`
- Inline JS for the blog sidebar functions (stripKeys, createDragHandle, createItemElement, renderList, add/remove/edit/update/sync functions, SortableJS init)
The shared JS utility functions (`stripKeys`, `createDragHandle`, `createItemElement`, `renderList`) are duplicated into this view, matching the existing inline JS pattern.
**Step 2: Verify**
Navigate to `/homepage/blog-sidebar`. Tab nav shows "Blog Sidebar" as active. Widgets render. Drag-drop works. Save persists data.
**Step 3: Commit**
```bash
git add views/homepage-blog-sidebar.njk
git commit -m "feat(homepage): add blog sidebar tab view"
```
---
### Task 6: Create identity editor tab view
**Files:**
- Create: `indiekit-endpoint-homepage/views/homepage-identity.njk`
**Step 1: Create the view**
This view extends `document.njk`, includes the tab nav, and contains the identity editor form. Uses standard Indiekit frontend macros (available globally from `default.njk`):
- `input()` for name, avatar URL, title, pronoun, locality, country, org, url, email, keyUrl
- `textarea()` for bio, description
- `tagInput()` for categories/skills
- Social links: inline JS with `createElement`/`textContent` for add/remove rows (safe DOM manipulation pattern matching existing dashboard)
- Each social link row has: name (text), url (text), rel (text, default "me"), icon (select: github/linkedin/bluesky/mastodon/activitypub)
- Form POSTs to `{{ homepageEndpoint }}/save-identity`
Social link form fields use the pattern `social[N][name]`, `social[N][url]`, `social[N][rel]`, `social[N][icon]` which Express parses into nested objects automatically.
Form is organized into sections:
- Profile (name, avatar, title, pronoun, bio, description)
- Location (locality, country, org)
- Contact (url, email, keyUrl)
- Skills (categories via tag-input)
- Social Links (CRUD list)
**Step 2: Verify**
Navigate to `/homepage/identity`. All fields render. Fill in data, submit. Data saves to MongoDB. Reload — data persists.
**Step 3: Commit**
```bash
git add views/homepage-identity.njk
git commit -m "feat(homepage): add identity editor tab with social links CRUD"
```
---
### Task 7: Verify API controller includes identity field
**Files:**
- Verify: `indiekit-endpoint-homepage/lib/controllers/api.js`
**Step 1: Check the `getConfigPublic` method**
Verify that `identity: config.identity` is included in the public API response. The current code at line 96 already includes this. If not, add it.
**Step 2: Verify**
Hit `GET /homepage/api/config.json` and confirm the response includes the `identity` field with saved data.
**Step 3: Commit (only if changes were needed)**
```bash
git add lib/controllers/api.js
git commit -m "chore(homepage): ensure identity field in public API response"
```
---
### Task 8: Update theme templates to prefer identity data over env vars
**Files:**
- Modify: `indiekit-eleventy-theme/_includes/components/sections/hero.njk`
- Modify: `indiekit-eleventy-theme/_includes/components/h-card.njk`
**Step 1: Update hero.njk**
After `{% set heroConfig = homepageConfig.hero or {} %}`, add identity resolution variables:
```nunjucks
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set siteDescription = id.description or site.description %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
```
Replace all references:
- `site.author.name``authorName`
- `site.author.avatar``authorAvatar`
- `site.author.title``authorTitle`
- `site.author.bio``authorBio`
- `site.description``siteDescription`
- `site.social``socialLinks`
**Step 2: Update h-card.njk**
Add identity resolution at the top:
```nunjucks
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set authorUrl = id.url or site.author.url %}
{% set authorPronoun = id.pronoun or site.author.pronoun %}
{% set authorLocality = id.locality or site.author.locality %}
{% set authorCountry = id.country or site.author.country %}
{% set authorLocation = site.author.location %}
{% set authorOrg = id.org or site.author.org %}
{% set authorEmail = id.email or site.author.email %}
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
```
Replace all `site.author.*` and `site.social` references with the corresponding variables.
**Step 3: Verify**
Run Eleventy build locally (dryrun) to confirm no template errors:
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npx @11ty/eleventy --dryrun 2>&1 | tail -5
```
**Step 4: Commit**
```bash
git add _includes/components/sections/hero.njk _includes/components/h-card.njk
git commit -m "feat(theme): prefer identity data over env vars in hero and h-card"
```
---
### Task Summary
| # | Task | Repo | Depends On |
|---|------|------|-----------|
| 1 | Tab nav partial + routes | plugin | — |
| 2 | i18n strings | plugin | — |
| 3 | Dashboard controller methods | plugin | 1, 2 |
| 4 | Refactor homepage-dashboard.njk | plugin | 1, 3 |
| 5 | Create blog sidebar view | plugin | 1, 2, 3 |
| 6 | Create identity editor view | plugin | 1, 2, 3 |
| 7 | Verify API includes identity | plugin | 3 |
| 8 | Theme: identity over env vars | theme | all plugin tasks |

View File

@@ -1,105 +0,0 @@
# Weekly Digest Feature Design
**Date:** 2026-02-25
**Status:** APPROVED
## Overview
A weekly digest that aggregates all posts from a given ISO week into a single summary page and RSS feed item. Subscribers get one update per week instead of per-post — a "slow RSS" feed.
## Requirements
- **Included post types:** All except replies (articles, notes, photos, bookmarks, likes, reposts)
- **Format:** Summary list — title + link + short excerpt for each post, grouped by type
- **Likes/bookmarks (no title):** Show target URL as label (e.g. "Liked: https://example.com/post")
- **Week definition:** ISO 8601 weeks (MondaySunday)
- **Empty weeks:** Skipped entirely (no page or feed item generated)
- **Output:**
- HTML page per week at `/digest/YYYY/WNN/`
- Paginated index at `/digest/`
- RSS feed at `/digest/feed.xml`
## Approach
Eleventy collection + pagination (same pattern as `categoryFeeds`). Pure build-time, no plugins, no external data sources.
## Design
### 1. Collection: `weeklyDigests`
Added in `eleventy.config.js`. Groups all published posts (excluding replies) by ISO week number.
Each entry in the collection:
```javascript
{
year: 2026,
week: 9,
slug: "2026/W09",
label: "Week 9, 2026",
startDate: "2026-02-23", // Monday
endDate: "2026-03-01", // Sunday
posts: [ /* all posts, newest-first */ ],
byType: {
articles: [...],
notes: [...],
photos: [...],
bookmarks: [...],
likes: [...],
reposts: [...]
}
}
```
- `byType` is pre-computed so templates don't filter
- Post type detection reuses blog.njk logic (check likeOf, bookmarkOf, repostOf, inReplyTo, photo, title)
- Empty types omitted from `byType`
- Collection sorted newest-week-first
### 2. Templates
**`digest.njk`** — Individual digest page
- Paginated over `weeklyDigests` collection
- Permalink: `/digest/2026/W09/`
- Layout: `base.njk` with sidebar
- Heading: "Week 9, 2026 — Feb 23 Mar 1"
- Sections per type present that week:
- Articles/notes: title or content excerpt + date + permalink
- Photos: thumbnail + caption excerpt + permalink
- Bookmarks: "Bookmarked: https://target-url" + date + permalink
- Likes: "Liked: https://target-url" + date + permalink
- Reposts: "Reposted: https://target-url" + date + permalink
- Previous/next digest navigation at bottom
**`digest-index.njk`** — Paginated index
- Permalink: `/digest/` (paginated: `/digest/page/2/`)
- Lists: week label, date range, post count, link to digest page
- 20 digests per page
**`digest-feed.njk`** — RSS feed
- Permalink: `/digest/feed.xml`
- Each `<item>` = one week's digest
- Title: "Week 9, 2026 (Feb 23 Mar 1)"
- Description: HTML summary list (grouped by type, same as HTML page)
- pubDate: Sunday (end of week)
- Latest 20 digests
### 3. Discovery
- `<link rel="alternate">` for digest feed in `base.njk`
- "Digest" navigation item (conditional on digests existing)
### 4. Files Changed
| File | Change |
|------|--------|
| `eleventy.config.js` | Add `weeklyDigests` collection |
| `digest.njk` | New — individual digest page |
| `digest-index.njk` | New — paginated index |
| `digest-feed.njk` | New — RSS feed |
| `_includes/layouts/base.njk` | Add alternate link for digest feed |
No new dependencies. No data files. No Indiekit plugin changes.

View File

@@ -1,657 +0,0 @@
# Weekly Digest Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a weekly digest feature that aggregates posts by ISO week into HTML pages and a dedicated RSS feed.
**Architecture:** Eleventy collection (`weeklyDigests`) groups all published non-reply posts by ISO week. Three Nunjucks templates paginate over this collection to produce individual digest pages, a paginated index, and an RSS feed. Discovery links added to the base layout.
**Tech Stack:** Eleventy collections, Nunjucks templates, `@11ty/eleventy-plugin-rss` filters, Tailwind CSS classes (existing theme).
**Design doc:** `docs/plans/2026-02-25-weekly-digest-design.md`
---
## Task 1: Add `weeklyDigests` Collection
**Files:**
- Modify: `eleventy.config.js` (insert after `recentPosts` collection, ~line 767)
**Step 1: Write the collection code**
Add the following collection after the `recentPosts` collection (after line 767) in `eleventy.config.js`:
```javascript
// Weekly digests — posts grouped by ISO week for digest pages and RSS feed
eleventyConfig.addCollection("weeklyDigests", function (collectionApi) {
const allPosts = collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
// Exclude replies
return !(item.data.inReplyTo || item.data.in_reply_to);
})
.sort((a, b) => b.date - a.date);
// Group by ISO week
const weekMap = new Map(); // "YYYY-WNN" -> { year, week, posts[] }
for (const post of allPosts) {
const d = new Date(post.date);
// ISO week calculation
const jan4 = new Date(d.getFullYear(), 0, 4);
const dayOfYear = Math.floor((d - new Date(d.getFullYear(), 0, 1)) / 86400000) + 1;
const jan4DayOfWeek = (jan4.getDay() + 6) % 7; // Mon=0
const weekNum = Math.floor((dayOfYear + jan4DayOfWeek - 1) / 7) + 1;
// ISO year can differ from calendar year at year boundaries
let isoYear = d.getFullYear();
if (weekNum < 1) {
isoYear--;
} else if (weekNum > 52) {
const dec31 = new Date(d.getFullYear(), 11, 31);
const dec31Day = (dec31.getDay() + 6) % 7;
if (dec31Day < 3) {
// This week belongs to next year
isoYear++;
}
}
// Use a more reliable ISO week calculation
const getISOWeek = (date) => {
const d2 = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d2.setUTCDate(d2.getUTCDate() + 4 - (d2.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d2.getUTCFullYear(), 0, 1));
return Math.ceil(((d2 - yearStart) / 86400000 + 1) / 7);
};
const getISOYear = (date) => {
const d2 = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d2.setUTCDate(d2.getUTCDate() + 4 - (d2.getUTCDay() || 7));
return d2.getUTCFullYear();
};
const week = getISOWeek(d);
const year = getISOYear(d);
const key = `${year}-W${String(week).padStart(2, "0")}`;
if (!weekMap.has(key)) {
// Calculate Monday (start) and Sunday (end) of ISO week
const simple = new Date(Date.UTC(year, 0, 4));
const dayOfWeek = simple.getUTCDay() || 7;
simple.setUTCDate(simple.getUTCDate() - dayOfWeek + 1); // Monday of week 1
const monday = new Date(simple);
monday.setUTCDate(monday.getUTCDate() + (week - 1) * 7);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
weekMap.set(key, {
year,
week,
slug: `${year}/W${String(week).padStart(2, "0")}`,
label: `Week ${week}, ${year}`,
startDate: monday.toISOString().slice(0, 10),
endDate: sunday.toISOString().slice(0, 10),
posts: [],
});
}
weekMap.get(key).posts.push(post);
}
// Build byType for each week and convert to array
const typeDetect = (post) => {
if (post.data.likeOf || post.data.like_of) return "likes";
if (post.data.bookmarkOf || post.data.bookmark_of) return "bookmarks";
if (post.data.repostOf || post.data.repost_of) return "reposts";
if (post.data.photo && post.data.photo.length) return "photos";
if (post.data.title) return "articles";
return "notes";
};
const digests = [...weekMap.values()].map((entry) => {
const byType = {};
for (const post of entry.posts) {
const type = typeDetect(post);
if (!byType[type]) byType[type] = [];
byType[type].push(post);
}
return { ...entry, byType };
});
// Sort newest-week-first
digests.sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
return b.week - a.week;
});
return digests;
});
```
**Step 2: Verify the build still succeeds**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy --dryrun 2>&1 | tail -20`
Expected: Build completes with no errors. The `weeklyDigests` collection is created but not yet used by any template.
**Step 3: Commit**
```bash
git add eleventy.config.js
git commit -m "feat: add weeklyDigests collection for digest feature"
```
---
## Task 2: Create Individual Digest Page Template
**Files:**
- Create: `digest.njk`
**Step 1: Create the template**
Create `digest.njk` in the theme root:
```nunjucks
---
layout: layouts/base.njk
withSidebar: true
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- weeklyDigests
pagination:
data: collections.weeklyDigests
size: 1
alias: digest
eleventyComputed:
title: "{{ digest.label }}"
permalink: "digest/{{ digest.slug }}/"
---
<article class="h-feed">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">
{{ digest.label }}
</h1>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
{{ digest.startDate | dateDisplay }} &ndash; {{ digest.endDate | dateDisplay }}
<span class="text-sm">({{ digest.posts.length }} post{% if digest.posts.length != 1 %}s{% endif %})</span>
</p>
{# Type display order #}
{% set typeOrder = [
{ key: "articles", label: "Articles" },
{ key: "notes", label: "Notes" },
{ key: "photos", label: "Photos" },
{ key: "bookmarks", label: "Bookmarks" },
{ key: "likes", label: "Likes" },
{ key: "reposts", label: "Reposts" }
] %}
{% for typeInfo in typeOrder %}
{% set typePosts = digest.byType[typeInfo.key] %}
{% if typePosts and typePosts.length %}
<section class="mb-8">
<h2 class="text-lg sm:text-xl font-semibold text-surface-800 dark:text-surface-200 mb-4 border-b border-surface-200 dark:border-surface-700 pb-2">
{{ typeInfo.label }}
<span class="text-sm font-normal text-surface-500 dark:text-surface-400">({{ typePosts.length }})</span>
</h2>
<ul class="space-y-4">
{% for post in typePosts %}
<li class="h-entry">
{% if typeInfo.key == "likes" %}
{# Like: "Liked: target-url" #}
{% set targetUrl = post.data.likeOf or post.data.like_of %}
<div class="flex items-start gap-2">
<span class="text-red-500 flex-shrink-0">&#x2764;</span>
<div>
<a href="{{ targetUrl }}" class="text-primary-600 dark:text-primary-400 hover:underline break-all">{{ targetUrl }}</a>
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
</div>
{% elif typeInfo.key == "bookmarks" %}
{# Bookmark: "Bookmarked: target-url" #}
{% set targetUrl = post.data.bookmarkOf or post.data.bookmark_of %}
<div class="flex items-start gap-2">
<span class="text-amber-500 flex-shrink-0">&#x1F516;</span>
<div>
{% if post.data.title %}
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">{{ post.data.title }}</a>
{% else %}
<a href="{{ targetUrl }}" class="text-primary-600 dark:text-primary-400 hover:underline break-all">{{ targetUrl }}</a>
{% endif %}
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
</div>
{% elif typeInfo.key == "reposts" %}
{# Repost: "Reposted: target-url" #}
{% set targetUrl = post.data.repostOf or post.data.repost_of %}
<div class="flex items-start gap-2">
<span class="text-green-500 flex-shrink-0">&#x1F501;</span>
<div>
<a href="{{ targetUrl }}" class="text-primary-600 dark:text-primary-400 hover:underline break-all">{{ targetUrl }}</a>
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
</div>
{% elif typeInfo.key == "photos" %}
{# Photo: thumbnail + caption #}
<div>
{% if post.data.photo and post.data.photo[0] %}
{% set photoUrl = post.data.photo[0].url or post.data.photo[0] %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<a href="{{ post.url }}" class="block mb-2">
<img src="{{ photoUrl }}" alt="{{ post.data.photo[0].alt | default('Photo') }}" class="rounded max-h-48 object-cover" loading="lazy" eleventy:ignore>
</a>
{% endif %}
{% if post.data.title %}
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">{{ post.data.title }}</a>
{% elif post.templateContent %}
<p class="text-surface-700 dark:text-surface-300 text-sm">{{ post.templateContent | striptags | truncate(120) }}</p>
{% endif %}
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
{% elif typeInfo.key == "articles" %}
{# Article: title + excerpt #}
<div>
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">
{{ post.data.title | default("Untitled") }}
</a>
{% if post.templateContent %}
<p class="text-surface-700 dark:text-surface-300 text-sm mt-1">{{ post.templateContent | striptags | truncate(200) }}</p>
{% endif %}
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
{% else %}
{# Note: content excerpt #}
<div>
<p class="text-surface-700 dark:text-surface-300">{{ post.templateContent | striptags | truncate(200) }}</p>
<div class="text-sm text-surface-500 dark:text-surface-400 mt-1">
<time class="dt-published" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline">Permalink</a>
</div>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endfor %}
{# Previous/Next digest navigation #}
{% set allDigests = collections.weeklyDigests %}
{% set currentIndex = -1 %}
{% for d in allDigests %}
{% if d.slug == digest.slug %}
{% set currentIndex = loop.index0 %}
{% endif %}
{% endfor %}
<nav class="flex justify-between items-center mt-8 pt-6 border-t border-surface-200 dark:border-surface-700" aria-label="Digest navigation">
{% if currentIndex > 0 %}
{% set newer = allDigests[currentIndex - 1] %}
<a href="/digest/{{ newer.slug }}/" class="text-primary-600 dark:text-primary-400 hover:underline">
&larr; {{ newer.label }}
</a>
{% else %}
<span></span>
{% endif %}
{% if currentIndex < allDigests.length - 1 %}
{% set older = allDigests[currentIndex + 1] %}
<a href="/digest/{{ older.slug }}/" class="text-primary-600 dark:text-primary-400 hover:underline">
{{ older.label }} &rarr;
</a>
{% else %}
<span></span>
{% endif %}
</nav>
</article>
```
**Step 2: Verify the build produces digest pages**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy --dryrun 2>&1 | grep digest | head -10`
Expected: Output shows `/digest/YYYY/WNN/` permalinks being generated.
**Step 3: Commit**
```bash
git add digest.njk
git commit -m "feat: add individual digest page template"
```
---
## Task 3: Create Paginated Digest Index
**Files:**
- Create: `digest-index.njk`
**Step 1: Create the template**
Create `digest-index.njk` in the theme root:
```nunjucks
---
layout: layouts/base.njk
title: Weekly Digest
withSidebar: true
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- weeklyDigests
pagination:
data: collections.weeklyDigests
size: 20
alias: paginatedDigests
permalink: "digest/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Weekly Digest</h1>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
A weekly summary of all posts. Subscribe via <a href="/digest/feed.xml" class="text-primary-600 dark:text-primary-400 hover:underline">RSS</a> for one update per week.
</p>
{% if paginatedDigests.length > 0 %}
<ul class="space-y-4">
{% for d in paginatedDigests %}
<li class="p-4 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
<a href="/digest/{{ d.slug }}/" class="block">
<h2 class="font-semibold text-surface-900 dark:text-surface-100 hover:text-primary-600 dark:hover:text-primary-400">
{{ d.label }}
</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">
{{ d.startDate | dateDisplay }} &ndash; {{ d.endDate | dateDisplay }}
&middot; {{ d.posts.length }} post{% if d.posts.length != 1 %}s{% endif %}
</p>
{% set typeLabels = [] %}
{% for key, posts in d.byType %}
{% set typeLabels = (typeLabels.push(key + " (" + posts.length + ")"), typeLabels) %}
{% endfor %}
{% if typeLabels.length %}
<p class="text-xs text-surface-400 dark:text-surface-500 mt-1">
{{ typeLabels | join(", ") }}
</p>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}
<nav class="pagination mt-8" aria-label="Digest pagination">
<div class="pagination-info">
Page {{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}
</div>
<div class="pagination-links">
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}" class="pagination-link" aria-label="Previous page">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</a>
{% else %}
<span class="pagination-link disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Previous
</span>
{% endif %}
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}" class="pagination-link" aria-label="Next page">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
{% else %}
<span class="pagination-link disabled">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<p class="text-surface-600 dark:text-surface-400">No digests yet. Posts will be grouped into weekly digests automatically.</p>
{% endif %}
</div>
```
**Step 2: Verify the build produces the index**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy --dryrun 2>&1 | grep "digest/" | head -5`
Expected: Output includes `/digest/` index page alongside individual digest pages.
**Step 3: Commit**
```bash
git add digest-index.njk
git commit -m "feat: add paginated digest index page"
```
---
## Task 4: Create Digest RSS Feed
**Files:**
- Create: `digest-feed.njk`
**Step 1: Create the template**
Create `digest-feed.njk` in the theme root. This follows the same pattern as `category-feed.njk` and `feed.njk`:
```nunjucks
---
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- weeklyDigests
permalink: /digest/feed.xml
---
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ site.name }} — Weekly Digest</title>
<link>{{ site.url }}/digest/</link>
<description>Weekly summary of all posts on {{ site.name }}. One update per week.</description>
<language>{{ site.locale | default('en') }}</language>
<atom:link href="{{ site.url }}/digest/feed.xml" rel="self" type="application/rss+xml"/>
<atom:link href="https://websubhub.com/hub" rel="hub"/>
{%- set latestDigests = collections.weeklyDigests | head(20) %}
{%- if latestDigests.length %}
<lastBuildDate>{{ latestDigests[0].endDate | dateToRfc822 }}</lastBuildDate>
{%- endif %}
{%- for digest in latestDigests %}
<item>
<title>{{ digest.label }} ({{ digest.startDate | dateDisplay }} {{ digest.endDate | dateDisplay }})</title>
<link>{{ site.url }}/digest/{{ digest.slug }}/</link>
<guid isPermaLink="true">{{ site.url }}/digest/{{ digest.slug }}/</guid>
<pubDate>{{ digest.endDate | dateToRfc822 }}</pubDate>
<description>{{ digest | digestToHtml(site.url) | escape }}</description>
</item>
{%- endfor %}
</channel>
</rss>
```
**Step 2: Add `digestToHtml` filter to `eleventy.config.js`**
Add after the existing `dateDisplay` filter (~line 347), or near the other custom filters:
```javascript
// Digest-to-HTML filter for RSS feed descriptions
eleventyConfig.addFilter("digestToHtml", (digest, siteUrl) => {
const typeLabels = {
articles: "Articles",
notes: "Notes",
photos: "Photos",
bookmarks: "Bookmarks",
likes: "Likes",
reposts: "Reposts",
};
const typeOrder = ["articles", "notes", "photos", "bookmarks", "likes", "reposts"];
let html = "";
for (const type of typeOrder) {
const posts = digest.byType[type];
if (!posts || !posts.length) continue;
html += `<h3>${typeLabels[type]}</h3><ul>`;
for (const post of posts) {
const postUrl = siteUrl + post.url;
let label;
if (type === "likes") {
const target = post.data.likeOf || post.data.like_of;
label = `Liked: ${target}`;
} else if (type === "bookmarks") {
const target = post.data.bookmarkOf || post.data.bookmark_of;
label = post.data.title || `Bookmarked: ${target}`;
} else if (type === "reposts") {
const target = post.data.repostOf || post.data.repost_of;
label = `Reposted: ${target}`;
} else if (post.data.title) {
label = post.data.title;
} else {
// Note or untitled: use content excerpt
const content = post.templateContent || "";
label = content.replace(/<[^>]*>/g, "").slice(0, 120).trim() || "Untitled";
}
html += `<li><a href="${postUrl}">${label}</a></li>`;
}
html += `</ul>`;
}
return html;
});
```
**Step 3: Verify the build produces the feed**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy --dryrun 2>&1 | grep "feed.xml"`
Expected: Output includes `/digest/feed.xml` alongside `/feed.xml`.
**Step 4: Commit**
```bash
git add digest-feed.njk eleventy.config.js
git commit -m "feat: add weekly digest RSS feed with digestToHtml filter"
```
---
## Task 5: Add Discovery Links and Navigation
**Files:**
- Modify: `_includes/layouts/base.njk` (~line 94 for alternate link, ~lines 200/278/354 for navigation)
**Step 1: Add the alternate link for the digest feed**
In `_includes/layouts/base.njk`, after line 95 (after the JSON feed alternate link), add:
```nunjucks
<link rel="alternate" type="application/rss+xml" href="/digest/feed.xml" title="Weekly Digest — RSS Feed">
```
This goes right after:
```nunjucks
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed">
```
**Step 2: Add "Digest" navigation item**
In the desktop navigation (after the "Interactions" link around line 205), the mobile navigation (around line 283), and the footer (around line 357), add a link to `/digest/`:
Desktop nav — after `<a href="/interactions/">Interactions</a>`:
```nunjucks
<a href="/digest/">Digest</a>
```
Mobile nav — after `<a href="/interactions/">Interactions</a>`:
```nunjucks
<a href="/digest/">Digest</a>
```
Footer "Content" section — after the Interactions `<li>`:
```nunjucks
<li><a href="/digest/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400">Digest</a></li>
```
**Step 3: Verify the build succeeds**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy --dryrun 2>&1 | tail -5`
Expected: Build completes successfully.
**Step 4: Commit**
```bash
git add _includes/layouts/base.njk
git commit -m "feat: add digest feed discovery link and navigation items"
```
---
## Task 6: Manual Verification
**Step 1: Run a full build (not dryrun)**
Run: `cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && npx @11ty/eleventy`
Expected: Build succeeds, output shows digest pages generated.
**Step 2: Spot-check generated files**
Check a digest page exists:
```bash
ls _site/digest/ | head -5
```
Check the feed is valid XML:
```bash
head -30 _site/digest/feed.xml
```
Check the index page has content:
```bash
head -30 _site/digest/index.html
```
**Step 3: Push and update submodule**
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme && git push origin main
cd /home/rick/code/indiekit-dev/indiekit-cloudron && git submodule update --remote eleventy-site && git add eleventy-site && git commit -m "chore: update eleventy-site submodule (weekly digest feature)" && git push origin main
```
---
## Known Considerations
- **`dateDisplay` and `dateToRfc822` with date strings:** The `startDate`/`endDate` fields are ISO date strings like `"2026-02-23"`. The `dateDisplay` filter creates a `new Date()` from this, which works for date-only strings. The `dateToRfc822` filter uses `@11ty/eleventy-plugin-rss`'s `dateToRfc2822` which also accepts date strings. If either filter has issues with date-only strings (no time component), the fix is to append `T00:00:00Z` in the collection code.
- **Empty site:** If there are zero posts, the `weeklyDigests` collection is empty, so no digest pages or feed items are generated. The index page shows a fallback message.
- **Nunjucks `for...in` for objects:** The `{% for key, posts in d.byType %}` syntax in the index template uses Nunjucks object iteration. This works in Nunjucks but iteration order follows JS property insertion order.