Add Reply-to-Interactions section to README with architecture diagram, threading mechanism, reply routing table, and plugin dependencies. Update CLAUDE.md with interaction API sources and reply architecture. Add conversations and comments plugins to the plugin integration table. Confab-Link: http://localhost:8080/sessions/184584f4-67e1-485a-aba8-02ac34a600fe
25 KiB
CLAUDE.md - Indiekit Eleventy Theme
This file provides guidance to Claude Code when working with the Indiekit Eleventy theme.
Project Overview
This is a comprehensive Eleventy theme designed for IndieWeb-powered personal websites using Indiekit. It renders Micropub posts (articles, notes, photos, bookmarks, likes, replies, reposts), integrates with Indiekit endpoint plugins for enhanced functionality (CV, homepage builder, GitHub, Funkwhale, Last.fm, YouTube, RSS, Microsub, etc.), and includes full webmention support.
Live Site: https://rmendes.net Used as Git submodule in:
/home/rick/code/indiekit-dev/indiekit-cloudron(Cloudron deployment)/home/rick/code/indiekit-dev/indiekit-deploy(Docker Compose deployment)
CRITICAL: Submodule Workflow
This repo is used as a Git submodule. After ANY changes:
- Edit, commit, and push this repo (indiekit-eleventy-theme)
- Update submodule pointer in parent repo(s):
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"
git push origin main
- Redeploy:
cd /home/rick/code/indiekit-dev/indiekit-cloudron
make prepare # REQUIRED — copies .rmendes files to non-suffixed versions
cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
Common mistake: Editing files in indiekit-cloudron/eleventy-site/ instead of this repo. Those changes are ephemeral — always edit here.
CRITICAL: Indiekit Date Handling Convention
All dates MUST be stored and passed as ISO 8601 strings. This is the universal pattern across Indiekit and ALL @rmdes/* plugins.
The Rule
- Storage (MongoDB): Store dates as ISO strings (
new Date().toISOString()), NEVER as JavaScriptDateobjects - Controllers: Pass date strings through to templates unchanged — NO conversion helpers, NO
formatDate()wrappers - Templates: Use the
| dateNunjucks filter for display formatting (e.g.,{{ value | date("PPp") }}) - Template guards: Always wrap
| datein{% if value %}to protect against null/undefined
Why This Matters
The Nunjucks | date filter is @indiekit/util's formatDate(), which calls date-fns parseISO(string). It ONLY accepts ISO 8601 strings:
Dateobjects →dateString.split is not a function(CRASH)null/undefined→Cannot read properties of undefined (reading 'match')(CRASH)- Pre-formatted strings (e.g., "8 Feb 2025") → Invalid Date (WRONG OUTPUT)
- ISO strings (e.g.,
"2025-02-08T14:30:00.000Z") → Correctly formatted (WORKS)
Correct Pattern
// _data file - store/return ISO strings
export default async function () {
const data = await fetch(...);
return {
lastSync: new Date().toISOString(), // ← ISO string
items: data.map(item => ({
published: item.published || null, // ← already ISO string from API
}))
};
}
{# Template - use | date filter, guard for null #}
{% if lastSync %}
{{ lastSync | date("PPp") }}
{% endif %}
Architecture
Data Flow: Plugin → JSON → _data → Template
Indiekit Plugin (backend)
→ writes JSON to content/.indiekit/*.json
→ _data/*.js reads JSON file
→ Nunjucks template renders data
Example: CV plugin flow
@rmdes/indiekit-endpoint-cvwritescontent/.indiekit/cv.json_data/cv.jsreads the JSON file and exports the datacv.njkand_includes/components/sections/cv-*.njkrender the data- Homepage builder can include CV sections via
homepageConfig.sections
Key Files by Function
Core Configuration
| File | Purpose |
|---|---|
eleventy.config.js |
Eleventy configuration, plugins, filters, collections, post-build hooks |
tailwind.config.js |
Tailwind CSS configuration (colors, typography) |
postcss.config.js |
PostCSS pipeline (Tailwind, autoprefixer) |
package.json |
Dependencies, scripts (build, dev, build:css) |
Data Files (_data/)
All _data/*.js files are ESM modules that export functions returning data objects. Most fetch from Indiekit plugin JSON files or external APIs.
| File | Data Source | Purpose |
|---|---|---|
site.js |
Environment variables | Site config (name, URL, author, social links) |
cv.js |
content/.indiekit/cv.json |
CV data from @rmdes/indiekit-endpoint-cv |
homepageConfig.js |
content/.indiekit/homepage.json |
Homepage layout from @rmdes/indiekit-endpoint-homepage |
enabledPostTypes.js |
content/.indiekit/post-types.json or env |
List of enabled post types for navigation |
urlAliases.js |
content/.indiekit/url-aliases.json |
Legacy URL mappings for webmentions |
blogrollStatus.js |
Indiekit /blogrollapi/api/status |
Checks if blogroll plugin is available |
podrollStatus.js |
Indiekit /podroll/api/status |
Checks if podroll plugin is available |
githubActivity.js |
Indiekit /githubapi/api/* or GitHub API |
GitHub commits, stars, featured repos |
githubRepos.js |
GitHub API | Starred repositories for sidebar |
funkwhaleActivity.js |
Indiekit Funkwhale plugin API | Listening activity |
lastfmActivity.js |
Indiekit Last.fm plugin API | Scrobbles, loved tracks |
newsActivity.js |
Indiekit IndieNews plugin API | Submitted IndieNews posts |
youtubeChannel.js |
YouTube Data API v3 | Channel info, latest videos, live status |
blueskyFeed.js |
Bluesky AT Protocol API | Recent Bluesky posts for sidebar |
mastodonFeed.js |
Mastodon API | Recent Mastodon posts for sidebar |
Data Source Pattern:
Most plugin-dependent data files:
- Try to fetch from Indiekit plugin API first
- Fall back to direct API (if credentials available)
- Return
{ source: "indiekit" | "api" | "error", ...data } - Templates check
sourceto conditionally display
Layouts (_includes/layouts/)
| File | Used By | Features |
|---|---|---|
base.njk |
All pages | Base HTML shell with header, footer, nav, meta tags |
home.njk |
Homepage | Two-tier fallback: plugin-driven (homepage builder) or default (hero + recent posts) |
post.njk |
Individual posts | h-entry microformat, Bridgy syndication, webmentions, reply context, photo gallery |
page.njk |
Static pages | Simple content wrapper, no post metadata |
Components (_includes/components/)
| Component | Purpose |
|---|---|
homepage-builder.njk |
Renders plugin-configured homepage layout (single/two-column, sections, sidebar) |
homepage-section.njk |
Router for section types (hero, cv-*, custom-html, recent-posts) |
homepage-sidebar.njk |
Renders plugin-configured sidebar widgets |
homepage-footer.njk |
Optional homepage footer with admin link |
sidebar.njk |
Default sidebar (author card, social activity, GitHub, Funkwhale, blogroll, categories) |
blog-sidebar.njk |
Sidebar for blog/post pages (recent posts, categories) |
h-card.njk |
Microformat2 h-card for author identity |
reply-context.njk |
Displays reply-to/like-of/repost-of/bookmark-of context with h-cite |
webmentions.njk |
Renders likes, reposts, replies from webmention.io + conversations API, with threaded owner replies |
empty-collection.njk |
Fallback message when a post type collection is empty |
Sections (_includes/components/sections/)
Homepage builder sections:
| Section | Config Type | Purpose |
|---|---|---|
hero.njk |
hero |
Full-width hero with avatar, name, bio, social links |
recent-posts.njk |
recent-posts |
Recent posts grid (configurable maxItems, postTypes filter) |
cv-experience.njk |
cv-experience |
Work experience timeline from CV data |
cv-skills.njk |
cv-skills |
Skills with proficiency bars from CV data |
cv-education.njk |
cv-education |
Education history from CV data |
cv-projects.njk |
cv-projects |
Featured projects from CV data |
cv-interests.njk |
cv-interests |
Personal interests from CV data |
custom-html.njk |
custom-html |
Arbitrary HTML content (from admin UI) |
Widgets (_includes/components/widgets/)
Sidebar widgets:
| Widget | Data Source | Purpose |
|---|---|---|
author-card.njk |
site.author |
h-card with avatar, bio, social links |
social-activity.njk |
blueskyFeed, mastodonFeed |
Recent posts from Bluesky/Mastodon |
github-repos.njk |
githubActivity, githubRepos |
Featured repos, recent commits |
funkwhale.njk |
funkwhaleActivity |
Now playing, listening stats |
recent-posts.njk |
collections.posts |
Recent posts list (for non-blog pages) |
blogroll.njk |
Blogroll API | Recently updated blogs from OPML/Microsub |
categories.njk |
collections.categories |
Category list with post counts |
Top-Level Templates (*.njk)
Page templates in the root directory:
| Template | Permalink | Purpose |
|---|---|---|
index.njk |
/ |
Homepage (uses home.njk layout) |
about.njk |
/about/ |
About page with full h-card |
cv.njk |
/cv/ |
CV page with all sections |
blog.njk |
/blog/ |
All posts chronologically |
articles.njk |
/articles/ |
Articles collection |
notes.njk |
/notes/ |
Notes collection |
photos.njk |
/photos/ |
Photos collection |
bookmarks.njk |
/bookmarks/ |
Bookmarks collection |
likes.njk |
/likes/ |
Likes collection |
replies.njk |
/replies/ |
Replies collection |
reposts.njk |
/reposts/ |
Reposts collection |
interactions.njk |
/interactions/ |
Combined social interactions |
slashes.njk |
/slashes/ |
Index of all slash pages |
categories.njk |
/categories/:slug/ |
Posts by category (pagination template) |
categories-index.njk |
/categories/ |
All categories index |
github.njk |
/github/ |
GitHub activity page |
funkwhale.njk |
/funkwhale/ |
Funkwhale listening page |
listening.njk |
/listening/ |
Last.fm listening page |
youtube.njk |
/youtube/ |
YouTube channel page |
blogroll.njk |
/blogroll/ |
Blogroll page (client-side data fetch) |
podroll.njk |
/podroll/ |
Podroll (podcast episodes) page |
news.njk |
/news/ |
IndieNews submissions page |
search.njk |
/search/ |
Pagefind search UI |
feed.njk |
/feed.xml |
RSS 2.0 feed |
feed-json.njk |
/feed.json |
JSON Feed 1.1 |
404.njk |
/404.html |
404 error page |
changelog.njk |
/changelog/ |
Site changelog |
webmention-debug.njk |
/webmention-debug/ |
Debug page for webmentions |
Eleventy Configuration Highlights
Collections
| Collection | Glob Pattern | Purpose |
|---|---|---|
posts |
content/**/*.md |
All content combined |
articles |
content/articles/**/*.md |
Long-form posts |
notes |
content/notes/**/*.md |
Short status updates |
photos |
content/photos/**/*.md |
Photo posts |
bookmarks |
content/bookmarks/**/*.md |
Saved links |
likes |
content/likes/**/*.md |
Liked posts |
replies |
Filtered by inReplyTo property |
Reply posts |
reposts |
Filtered by repostOf property |
Repost posts |
pages |
content/*.md + content/pages/*.md |
Slash pages (/about, /now, /uses, etc.) |
feed |
content/**/*.md (first 20) |
Homepage/RSS feed |
categories |
Deduplicated from all posts | Category list |
Note: replies and reposts collections are dynamically filtered by property, not by directory. Supports both camelCase (inReplyTo, repostOf) and underscore (in_reply_to, repost_of) naming.
Custom Filters
| Filter | Purpose | Usage |
|---|---|---|
dateDisplay |
Format date as "January 1, 2025" | {{ date | dateDisplay }} |
isoDate |
Convert to ISO 8601 string | {{ date | isoDate }} |
date |
Format date with custom format | {{ date | date("MMM d, yyyy") }} |
truncate |
Truncate string to max length | {{ text | truncate(200) }} |
ogDescription |
Strip HTML, decode entities, truncate | {{ content | ogDescription(200) }} |
extractFirstImage |
Extract first <img src> from content |
{{ content | extractFirstImage }} |
obfuscateEmail |
Convert email to HTML entities | {{ email | obfuscateEmail }} |
head |
Get first N items from array | {{ array | head(5) }} |
slugify |
Convert string to slug | {{ name | slugify }} |
hash |
MD5 hash of file for cache busting | {{ '/css/style.css' | hash }} |
timestamp |
Current Unix timestamp | {{ '' | timestamp }} |
webmentionsForUrl |
Filter webmentions by URL + aliases | {{ webmentions | webmentionsForUrl(page.url, urlAliases) }} |
webmentionsByType |
Filter by type (likes, reposts, replies) | {{ mentions | webmentionsByType('likes') }} |
jsonEncode |
JSON.stringify for JSON feed | {{ value | jsonEncode }} |
dateToRfc822 |
RFC 2822 format for RSS | {{ date | dateToRfc822 }} |
Plugins
| Plugin | Purpose |
|---|---|
@11ty/eleventy-plugin-rss |
RSS feed filters (dateToRfc2822, absoluteUrl) |
@11ty/eleventy-plugin-syntaxhighlight |
Syntax highlighting for code blocks |
@11ty/eleventy-img |
Automatic image optimization (webp, lazy loading) |
eleventy-plugin-embed-everything |
Auto-embed YouTube, Vimeo, Mastodon, Bluesky, Spotify |
@chrisburnell/eleventy-cache-webmentions |
Build-time webmention caching |
@quasibit/eleventy-plugin-sitemap |
Sitemap generation |
html-minifier-terser |
HTML minification (production only) |
pagefind |
Search indexing (post-build via eleventy.after hook) |
Transforms
| Transform | Purpose |
|---|---|
youtube-link-to-embed |
Converts YouTube links to embeds |
htmlmin |
Minifies HTML (build mode only, not watch mode) |
eleventyImageTransformPlugin |
Optimizes <img> tags |
Pre-Build Hook: OG Image Generation (eleventy.before)
Generates OpenGraph images for posts without photos using Satori (Yoga WASM → SVG) + Resvg (Rust WASM → PNG).
Key files:
lib/og.js— generation logic, card layout, manifest-based cachinglib/og-cli.js— CLI wrapper, acceptsbatchSizeargumenteleventy.config.js— spawns og-cli with batch loop
Architecture: Runs as a separate process (execFileSync) to isolate WASM native memory from Eleventy. Uses batch spawning — each invocation generates up to 100 images, then exits with code 2 ("more remain"). The spawner re-loops until exit code 0. This keeps peak RSS at ~460 MB per batch regardless of total image count.
Why batch spawning: Satori and Resvg allocate native memory outside V8's heap. --max-old-space-size only limits V8 — WASM native allocations are invisible to it. Without batching, 2,350+ images grow native memory to ~3 GB, OOM-killing the process in the 3 GB container. Batching fully releases native memory between invocations.
Caching: Manifest at .cache/og/manifest.json maps slug → content hash. Only changed/new posts generate images. Manifest saved every 10 images for crash resilience.
Post-Build Hooks (eleventy.after)
- Pagefind indexing — indexes all HTML files for search
- WebSub hub notification — notifies subscribers of feed updates (/, /feed.xml, /feed.json)
IndieWeb Features
Microformats2
- h-card (author identity): Name, photo, bio, location, social links with
rel="me" - h-entry (post markup): All post types properly marked up
- h-feed (feed markup): Machine-readable post lists
- h-cite (reply context): Cites external content in replies/likes/reposts
Webmentions & Interactions
- Build-time caching via
@chrisburnell/eleventy-cache-webmentions - Client-side real-time fetching via
/js/webmentions.jsfrom three APIs:/webmentions/api/mentions— IndieWeb webmentions (webmention.io)/conversations/api/mentions— Mastodon/Bluesky/AP interactions (conversations plugin)/comments/api/comments— Native authenticated comments (comments plugin)
- Displays likes, reposts, replies with avatars and platform badges
- Owner reply threading — owner replies appear nested under parent interactions with amber Author badge
- Send webmention form on every post
- Legacy URL support via
urlAliases(for micro.blog and old blog URLs)
Reply-to-Interactions Architecture
The conversations API enriches its response with owner replies (is_owner: true, parent_url). The frontend's threadOwnerReplies() function matches parent_url to reply <li> elements via data-wm-url attributes and inserts threaded reply cards into wm-owner-reply-slot divs.
Reply routing is provenance-aware:
- Mastodon/Bluesky replies —
POST /micropubwithmp-syndicate-tofor platform threading - IndieWeb webmention replies —
POST /micropubwithout syndication (webmention sent automatically) - Native comment replies —
POST /comments/api/reply(stored in comments collection)
IndieAuth
rel="me"links in<head>for identity verification- Bluesky uses
rel="me atproto"for AT Protocol verification - Fediverse creator meta tag for Mastodon verification
Micropub Endpoints
Base layout includes <link> tags pointing to Indiekit endpoints:
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
<link rel="token_endpoint" href="{{ site.url }}/auth/token">
<link rel="micropub" href="{{ site.url }}/micropub">
<link rel="microsub" href="{{ site.url }}/microsub">
Bridgy Syndication
Posts include hidden Bridgy syndication content in post.njk:
<p class="p-summary e-bridgy-mastodon-content e-bridgy-bluesky-content hidden">
{# Interaction posts include emoji + target URL #}
🔖 {{ bookmarkedUrl }} - {{ description }}
</p>
Bridgy reads this content when syndicating to Bluesky/Mastodon. Interaction types (bookmarks, likes, replies, reposts) include emoji prefix and target URL.
Code Style
TypeScript/JavaScript
- ESM modules:
"type": "module"in package.json - Async data files:
export default async function () { ... } - Data source pattern: Return
{ source: "indiekit" | "api" | "error", ...data } - Date handling: Always use ISO 8601 strings (
new Date().toISOString())
Nunjucks Templates
- Property name compatibility: Support both camelCase and underscore names:
{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
{% set replyTo = inReplyTo or in_reply_to %}
- Date filter guards: Always check for null/undefined:
{% if published %}
{{ published | date("PPp") }}
{% endif %}
- Markdown engine disabled:
markdownTemplateEngine: falseto prevent parsing{{in content - Safe filter usage: Use
| safefor trusted HTML content only - Microformats classes: Follow IndieWeb conventions (h-entry, p-name, dt-published, e-content, u-photo, etc.)
CSS
- Tailwind CSS for all styling
- Dark mode:
dark:variants, controlled by.darkclass on<html> - Custom color palette:
primary(blue) andsurface(neutral) - Typography plugin:
proseclasses for content rendering - Responsive design: Mobile-first, breakpoints:
sm:,md:,lg:
Common Tasks
Adding a New Post Type
- Create collection in
eleventy.config.js:
eleventyConfig.addCollection("checkins", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/checkins/**/*.md")
.sort((a, b) => b.date - a.date);
});
- Create collection page (e.g.,
checkins.njk):
---
layout: layouts/page.njk
title: Check-ins
withBlogSidebar: true
permalink: /checkins/
---
{% for post in collections.checkins %}
{# render post #}
{% endfor %}
- Add to enabledPostTypes (optional, for nav):
Edit _data/enabledPostTypes.js or set POST_TYPES env var.
-
Update
reply-context.njkif the post type has a target URL property. -
Update
post.njkBridgy content if the post type needs special syndication text. -
Commit, push, and update submodule.
Adding a New Data Source
- Create
_data/newSource.js:
import EleventyFetch from "@11ty/eleventy-fetch";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
export default async function () {
try {
const url = `${INDIEKIT_URL}/newapi/api/data`;
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
return {
source: "indiekit",
...data,
};
} catch (error) {
console.log(`[newSource] API unavailable: ${error.message}`);
return {
source: "error",
items: [],
};
}
}
- Use in template:
{% if newSource and newSource.source == "indiekit" %}
{% for item in newSource.items %}
{# render item #}
{% endfor %}
{% endif %}
- Add status check to base.njk navigation (if needed).
Adding a New Homepage Section
- Create section template in
_includes/components/sections/:
{# new-section.njk #}
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 5 %}
<section class="mb-8 sm:mb-12">
<h2 class="text-xl sm:text-2xl font-bold">{{ sectionConfig.title or "New Section" }}</h2>
{# render content #}
</section>
- Register in
homepage-section.njk:
{% if section.type == "new-section" %}
{% include "components/sections/new-section.njk" %}
- Plugin integration: The plugin that provides this section should register it via
homepageSectionsin Indiekit.
Debugging Webmentions
- Check build-time cache: Look at
webmention-debug.njkpage - Check client-side fetch: Open browser console, check for fetch requests to
/webmentions/api/mentions - Verify target URL: Webmentions must match exact URL (with or without trailing slash)
- Check legacy URLs: Verify
urlAliasesdata includes old URLs if needed
Theming and Customization
- Colors: Edit
tailwind.config.js→theme.extend.colors - Typography: Edit
tailwind.config.js→theme.extend.typography - CSS utilities: Add custom utilities to
css/tailwind.css - Rebuild CSS:
npm run build:css(ormake build:cssin parent repo)
Performance Debugging
For diagnosing and fixing Eleventy build performance issues, see the comprehensive guide at /home/rick/code/indiekit-dev/docs/eleventy-debugging-guide.md.
Quick diagnostic steps:
- Baseline:
time npx @11ty/eleventy --quiet(run 3x, take median) - Benchmark:
DEBUG=Eleventy:Benchmark* npx @11ty/eleventy— find entries >15% of total or with call count matching page count - Classify: Network requests (high avg, low count) vs. redundant computation (low avg, high count) vs. client-side bloat (fast build, low Lighthouse)
- Fix: Timeout + cache for network; memoize with
Mapfor per-page computation; Web Components for client-side bloat - Verify: Re-measure against baseline
Relevant to this theme: Data files in _data/ that fetch from external APIs (GitHub, Mastodon, Bluesky, YouTube, Funkwhale, Last.fm) are Pattern A candidates — always use eleventy-fetch with appropriate duration and handle failures gracefully. The OG image generation hook is a Pattern B candidate — it already uses batch spawning and manifest caching to manage memory and avoid redundant work.
Anti-Patterns
- ❌ Forgetting to update submodule after changes
- ❌ Editing files in submodule directory (
indiekit-cloudron/eleventy-site/) - ❌ Using Date objects instead of ISO strings for dates
- ❌ Not guarding
| datefilters against null/undefined - ❌ Using only underscore property names (support both camelCase and underscore)
- ❌ Using
markdownTemplateEngine: "njk"(breaks code samples with{{) - ❌ Hardcoding personal data in templates (use environment variables)
- ❌ Forgetting to run
make preparebeforecloudron build(deploys stale config) - ❌ Using unsafe HTML string assignment in client-side JS (security hooks reject it — use
createElement+textContent) - ❌ Removing overrides without checking if they shadow submodule files (causes stale data)
Troubleshooting
"dateString.split is not a function"
Cause: A Date object was passed to the | date filter.
Fix: Store dates as ISO strings from the start: new Date().toISOString()
Stale data in homepage/CV despite correct JSON files
Cause: Override file in indiekit-cloudron/overrides/eleventy-site/ shadows the submodule.
Fix: Delete the override file and reset submodule: cd eleventy-site && git checkout -- _data/file.js
Webmentions not appearing
Causes:
- Build-time cache expired (rebuild to refresh)
- Client-side JS blocked by CSP (check console)
- Target URL mismatch (check with/without trailing slash)
- webmention.io down (check status)
Fix: Check webmention-debug.njk page, verify webmentionsForUrl filter is working.
Plugin data not appearing in navigation
Cause: The plugin's status endpoint is unavailable or returning source: "error".
Fix: Check the plugin's API is running, verify environment variables are set.
YouTube embeds not working
Causes:
- URL doesn't match pattern (must be youtube.com/watch or youtu.be)
- Link text doesn't contain "youtube" or URL (transform matches specific patterns)
Fix: Use embed plugin shortcode or raw <iframe> instead.
Workspace Context
This repo is part of the Indiekit development workspace at /home/rick/code/indiekit-dev/. See the workspace CLAUDE.md for the full repository map and plugin architecture.