chore: remove dev plans from published repo
Plans moved to central /home/rick/code/indiekit-dev/docs/plans/
This commit is contained in:
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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>
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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 |
|
|
||||||
@@ -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 (Monday–Sunday)
|
|
||||||
- **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.
|
|
||||||
@@ -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 }} – {{ 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">❤</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>
|
|
||||||
· <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">🔖</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>
|
|
||||||
· <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">🔁</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>
|
|
||||||
· <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>
|
|
||||||
· <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>
|
|
||||||
· <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>
|
|
||||||
· <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">
|
|
||||||
← {{ 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 }} →
|
|
||||||
</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 }} – {{ d.endDate | dateDisplay }}
|
|
||||||
· {{ 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.
|
|
||||||
Reference in New Issue
Block a user