diff --git a/docs/plans/2026-02-24-category-feeds-design.md b/docs/plans/2026-02-24-category-feeds-design.md deleted file mode 100644 index 471a926..0000000 --- a/docs/plans/2026-02-24-category-feeds-design.md +++ /dev/null @@ -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 `` tags for category pages: - -```nunjucks -{% if category and page.url.startsWith('/categories/') and page.url != '/categories/' %} - - -{% 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 `` 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` diff --git a/docs/plans/2026-02-24-category-feeds-plan.md b/docs/plans/2026-02-24-category-feeds-plan.md deleted file mode 100644 index 3fafc12..0000000 --- a/docs/plans/2026-02-24-category-feeds-plan.md +++ /dev/null @@ -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" ---- - - - - {{ site.name }} — {{ categoryFeed.name }} - {{ site.url }}/categories/{{ categoryFeed.slug }}/ - Posts tagged with "{{ categoryFeed.name }}" on {{ site.name }} - {{ site.locale | default('en') }} - - - {{ categoryFeed.posts | getNewestCollectionItemDate | dateToRfc822 }} - {%- for post in categoryFeed.posts %} - {%- set absolutePostUrl = site.url + post.url %} - {%- set postImage = post.data.photo %} - {%- if postImage %} - {%- if postImage[0] and (postImage[0] | length) > 10 %} - {%- set postImage = postImage[0] %} - {%- endif %} - {%- endif %} - {%- if not postImage or postImage == "" %} - {%- set postImage = post.data.image or (post.content | extractFirstImage) %} - {%- endif %} - - {{ post.data.title | default(post.content | striptags | truncate(80)) | escape }} - {{ absolutePostUrl }} - {{ absolutePostUrl }} - {{ post.date | dateToRfc822 }} - {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | escape }} - {%- if postImage and postImage != "" and (postImage | length) > 10 %} - {%- set imageUrl = postImage | url | absoluteUrl(site.url) %} - - - {%- endif %} - - {%- endfor %} - - -``` - -**Step 2: Verify the template generates feed files** - -Run: -```bash -npx @11ty/eleventy --dryrun 2>&1 | grep "category-feed.njk" | head -5 -``` - -Expected: Lines showing `Writing ... /categories//feed.xml from ./category-feed.njk` for multiple categories. - -**Step 3: Commit** - -```bash -git add category-feed.njk -git commit -m "feat: add RSS 2.0 per-category feed template" -``` - ---- - -### Task 3: Create JSON Feed 1.1 category feed template - -**Files:** -- Create: `category-feed-json.njk` - -**Step 1: Create the template** - -Create `category-feed-json.njk` in the project root: - -```nunjucks ---- -eleventyExcludeFromCollections: true -eleventyImport: - collections: - - categoryFeeds -pagination: - data: collections.categoryFeeds - size: 1 - alias: categoryFeed -permalink: "categories/{{ categoryFeed.slug }}/feed.json" ---- -{ - "version": "https://jsonfeed.org/version/1.1", - "title": "{{ site.name }} — {{ categoryFeed.name }}", - "home_page_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/", - "feed_url": "{{ site.url }}/categories/{{ categoryFeed.slug }}/feed.json", - "hubs": [ - { - "type": "WebSub", - "url": "https://websubhub.com/hub" - } - ], - "description": "Posts tagged with \"{{ categoryFeed.name }}\" on {{ site.name }}", - "language": "{{ site.locale | default('en') }}", - "authors": [ - { - "name": "{{ site.author.name | default(site.name) }}", - "url": "{{ site.url }}/" - } - ], - "_textcasting": { - "version": "1.0", - "about": "https://textcasting.org/" - {%- set hasSupport = site.support and (site.support.url or site.support.stripe or site.support.lightning or site.support.paymentPointer) %} - {%- if hasSupport %}, - "support": {{ site.support | textcastingSupport | jsonEncode | safe }} - {%- endif %} - }, - "items": [ - {%- for post in categoryFeed.posts %} - {%- set absolutePostUrl = site.url + post.url %} - {%- set postImage = post.data.photo %} - {%- if postImage %} - {%- if postImage[0] and (postImage[0] | length) > 10 %} - {%- set postImage = postImage[0] %} - {%- endif %} - {%- endif %} - {%- if not postImage or postImage == "" %} - {%- set postImage = post.data.image or (post.content | extractFirstImage) %} - {%- endif %} - { - "id": "{{ absolutePostUrl }}", - "url": "{{ absolutePostUrl }}", - "title": {% if post.data.title %}{{ post.data.title | jsonEncode | safe }}{% else %}null{% endif %}, - "content_html": {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | jsonEncode | safe }}, - "content_text": {{ post.content | striptags | jsonEncode | safe }}, - "date_published": "{{ post.date | dateToRfc3339 }}", - "date_modified": "{{ (post.data.updated or post.date) | dateToRfc3339 }}" - {%- if postImage and postImage != "" and (postImage | length) > 10 %}, - "image": "{{ postImage | url | absoluteUrl(site.url) }}" - {%- endif %} - {%- set attachments = post.data | feedAttachments %} - {%- if attachments.length > 0 %}, - "attachments": {{ attachments | jsonEncode | safe }} - {%- endif %} - }{% if not loop.last %},{% endif %} - {%- endfor %} - ] -} -``` - -**Step 2: Verify the template generates feed files** - -Run: -```bash -npx @11ty/eleventy --dryrun 2>&1 | grep "category-feed-json.njk" | head -5 -``` - -Expected: Lines showing `Writing ... /categories//feed.json from ./category-feed-json.njk` for multiple categories. - -**Step 3: Commit** - -```bash -git add category-feed-json.njk -git commit -m "feat: add JSON Feed 1.1 per-category feed template" -``` - ---- - -### Task 4: Add discovery link tags in base.njk - -**Files:** -- Modify: `_includes/layouts/base.njk:98` (after the markdown alternate link block, before the authorization_endpoint link) - -**Step 1: Add the conditional link tags** - -In `_includes/layouts/base.njk`, find this line (currently line 98): - -```nunjucks - {% endif %} - -``` - -Insert the category feed links between `{% endif %}` (closing the markdown agents block) and the authorization_endpoint link: - -```nunjucks - {% endif %} - {% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %} - - - {% endif %} - -``` - -**Step 2: Verify the link tags appear on a category page** - -Run a full build and check a category page: - -```bash -npx @11ty/eleventy 2>&1 | tail -3 -``` - -Then inspect a generated category page: - -```bash -grep -A1 'category.*RSS Feed' _site/categories/indieweb/index.html -``` - -Expected: The two `` tags with correct category slug in the href. - -**Step 3: Commit** - -```bash -git add _includes/layouts/base.njk -git commit -m "feat: add RSS/JSON feed discovery links on category pages" -``` - ---- - -### Task 5: Extend WebSub notifications for category feeds - -**Files:** -- Modify: `eleventy.config.js:876-893` (the WebSub notification block inside `eleventy.after`) - -**Step 1: Replace the WebSub notification block** - -Find the existing WebSub block (starts at line 874): - -```javascript - // WebSub hub notification — skip on incremental rebuilds - if (incremental) return; - const hubUrl = "https://websubhub.com/hub"; - const feedUrls = [ - `${siteUrl}/`, - `${siteUrl}/feed.xml`, - `${siteUrl}/feed.json`, - ]; - for (const feedUrl of feedUrls) { - try { - const res = await fetch(hubUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`, - }); - console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`); - } catch (err) { - console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message); - } - } -``` - -Replace with: - -```javascript - // WebSub hub notification — skip on incremental rebuilds - if (incremental) return; - const hubUrl = "https://websubhub.com/hub"; - const feedUrls = [ - `${siteUrl}/`, - `${siteUrl}/feed.xml`, - `${siteUrl}/feed.json`, - ]; - - // Discover category feed URLs from build output - const outputDir = directories?.output || dir.output; - const categoriesDir = resolve(outputDir, "categories"); - try { - for (const entry of readdirSync(categoriesDir, { withFileTypes: true })) { - if (entry.isDirectory() && existsSync(resolve(categoriesDir, entry.name, "feed.xml"))) { - feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.xml`); - feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.json`); - } - } - } catch { - // categoriesDir may not exist on first build — ignore - } - - console.log(`[websub] Notifying hub for ${feedUrls.length} URLs...`); - for (const feedUrl of feedUrls) { - try { - const res = await fetch(hubUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`, - }); - console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`); - } catch (err) { - console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message); - } - } -``` - -Note: `readdirSync`, `existsSync`, and `resolve` are already imported at the top of `eleventy.config.js` (line 14-15). - -**Step 2: Verify no syntax errors** - -Run: -```bash -npx @11ty/eleventy --dryrun 2>&1 | tail -5 -``` - -Expected: No errors. Dryrun completes normally. - -**Step 3: Commit** - -```bash -git add eleventy.config.js -git commit -m "feat: extend WebSub notifications to include category feed URLs" -``` - ---- - -### Task 6: Full build and end-to-end verification - -**Files:** None (verification only) - -**Step 1: Run a full Eleventy build** - -```bash -npx @11ty/eleventy 2>&1 | tail -5 -``` - -Expected: Build completes with increased file count (2 extra files per category). Look for the `Wrote NNNN files` summary line — it should be noticeably higher than the previous build count of ~2483. - -**Step 2: Verify RSS feed content** - -```bash -head -15 _site/categories/indieweb/feed.xml -``` - -Expected: Valid RSS 2.0 XML with `` containing the site name and category name, `<atom:link>` self and hub references, and `<item>` entries. - -**Step 3: Verify JSON feed content** - -```bash -head -20 _site/categories/indieweb/feed.json -``` - -Expected: Valid JSON Feed 1.1 with `title`, `feed_url`, `hubs`, and `items` array. - -**Step 4: Count generated category feeds** - -```bash -find _site/categories/ -name "feed.xml" | wc -l -find _site/categories/ -name "feed.json" | wc -l -``` - -Expected: Both counts should be equal and match the number of categories on the site. - -**Step 5: Verify discovery links in HTML** - -```bash -grep 'category.*feed.xml\|category.*feed.json' _site/categories/indieweb/index.html -``` - -Expected: Two `<link rel="alternate">` tags — one RSS, one JSON — with correct category slug URLs. - -**Step 6: Commit all work (if any uncommitted changes remain)** - -```bash -git status -``` - -If clean, no action needed. Otherwise commit any remaining changes. diff --git a/docs/plans/2026-02-24-homepage-ui-ux-design.md b/docs/plans/2026-02-24-homepage-ui-ux-design.md deleted file mode 100644 index a9cc4f7..0000000 --- a/docs/plans/2026-02-24-homepage-ui-ux-design.md +++ /dev/null @@ -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. diff --git a/docs/plans/2026-02-24-homepage-ui-ux-plan.md b/docs/plans/2026-02-24-homepage-ui-ux-plan.md deleted file mode 100644 index 61cff4b..0000000 --- a/docs/plans/2026-02-24-homepage-ui-ux-plan.md +++ /dev/null @@ -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> - ``` diff --git a/docs/plans/2026-02-25-identity-editor-design.md b/docs/plans/2026-02-25-identity-editor-design.md deleted file mode 100644 index 2ae1148..0000000 --- a/docs/plans/2026-02-25-identity-editor-design.md +++ /dev/null @@ -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 diff --git a/docs/plans/2026-02-25-identity-editor-plan.md b/docs/plans/2026-02-25-identity-editor-plan.md deleted file mode 100644 index 2d7b9a2..0000000 --- a/docs/plans/2026-02-25-identity-editor-plan.md +++ /dev/null @@ -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 | diff --git a/docs/plans/2026-02-25-weekly-digest-design.md b/docs/plans/2026-02-25-weekly-digest-design.md deleted file mode 100644 index 9ddad42..0000000 --- a/docs/plans/2026-02-25-weekly-digest-design.md +++ /dev/null @@ -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. diff --git a/docs/plans/2026-02-25-weekly-digest-plan.md b/docs/plans/2026-02-25-weekly-digest-plan.md deleted file mode 100644 index 23ff381..0000000 --- a/docs/plans/2026-02-25-weekly-digest-plan.md +++ /dev/null @@ -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 - {{ site.url }}/digest/ - Weekly summary of all posts on {{ site.name }}. One update per week. - {{ site.locale | default('en') }} - - - {%- set latestDigests = collections.weeklyDigests | head(20) %} - {%- if latestDigests.length %} - {{ latestDigests[0].endDate | dateToRfc822 }} - {%- endif %} - {%- for digest in latestDigests %} - - {{ digest.label }} ({{ digest.startDate | dateDisplay }} – {{ digest.endDate | dateDisplay }}) - {{ site.url }}/digest/{{ digest.slug }}/ - {{ site.url }}/digest/{{ digest.slug }}/ - {{ digest.endDate | dateToRfc822 }} - {{ digest | digestToHtml(site.url) | escape }} - - {%- endfor %} - - -``` - -**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 += `

${typeLabels[type]}

    `; - 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 += `
  • ${label}
  • `; - } - html += `
`; - } - - 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 - -``` - -This goes right after: -```nunjucks - -``` - -**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 `Interactions`: -```nunjucks - Digest -``` - -Mobile nav — after `Interactions`: -```nunjucks - Digest -``` - -Footer "Content" section — after the Interactions `
  • `: -```nunjucks -
  • Digest
  • -``` - -**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.