From 6bb3c5a6d6b740fd4579f0b3d06acb24836883e8 Mon Sep 17 00:00:00 2001
From: svemagie <869694+svemagie@users.noreply.github.com>
Date: Fri, 6 Mar 2026 14:37:53 +0100
Subject: [PATCH] some fiddeling later
---
indiekit.config.mjs | 75 +
theme/_data/blogrollStatus.js | 34 +
theme/_data/blueskyFeed.js | 68 +
theme/_data/conversationMentions.js | 14 +
theme/_data/cv.js | 37 +
theme/_data/cvPageConfig.js | 29 +
theme/_data/eleventyComputed.js | 52 +
theme/_data/enabledPostTypes.js | 50 +
theme/_data/funkwhaleActivity.js | 123 +
theme/_data/githubActivity.js | 284 +
theme/_data/githubRepos.js | 48 +
theme/_data/githubStarred.js | 32 +
theme/_data/homepageConfig.js | 29 +
theme/_data/lastfmActivity.js | 83 +
theme/_data/mastodonFeed.js | 96 +
theme/_data/newsActivity.js | 99 +
theme/_data/podrollStatus.js | 34 +
theme/_data/recentComments.js | 24 +
theme/_data/site.js | 139 +
theme/_data/urlAliases.js | 155 +
theme/_data/youtubeChannel.js | 206 +
theme/_includes/components/blog-sidebar.njk | 273 +
theme/_includes/components/comments.njk | 109 +
theme/_includes/components/cv-builder.njk | 169 +
theme/_includes/components/cv-footer.njk | 26 +
theme/_includes/components/cv-sidebar.njk | 43 +
.../_includes/components/empty-collection.njk | 27 +
.../_includes/components/fediverse-modal.njk | 81 +
.../components/funkwhale-stats-content.njk | 66 +
theme/_includes/components/h-card.njk | 111 +
.../_includes/components/homepage-builder.njk | 86 +
.../_includes/components/homepage-footer.njk | 26 +
.../_includes/components/homepage-section.njk | 56 +
.../_includes/components/homepage-sidebar.njk | 137 +
theme/_includes/components/icon.njk | 67 +
.../_includes/components/post-navigation.njk | 103 +
theme/_includes/components/reply-context.njk | 74 +
.../components/sections/custom-html.njk | 18 +
.../sections/cv-education-personal.njk | 2 +
.../components/sections/cv-education-work.njk | 2 +
.../components/sections/cv-education.njk | 88 +
.../sections/cv-experience-personal.njk | 2 +
.../sections/cv-experience-work.njk | 2 +
.../components/sections/cv-experience.njk | 48 +
.../sections/cv-interests-personal.njk | 2 +
.../components/sections/cv-interests-work.njk | 2 +
.../components/sections/cv-interests.njk | 50 +
.../components/sections/cv-languages.njk | 21 +
.../sections/cv-projects-personal.njk | 124 +
.../components/sections/cv-projects-work.njk | 124 +
.../components/sections/cv-projects.njk | 114 +
.../sections/cv-skills-personal.njk | 2 +
.../components/sections/cv-skills-work.njk | 2 +
.../components/sections/cv-skills.njk | 50 +
.../components/sections/featured-posts.njk | 259 +
theme/_includes/components/sections/hero.njk | 66 +
.../components/sections/posting-activity.njk | 24 +
.../components/sections/recent-posts.njk | 257 +
theme/_includes/components/sidebar.njk | 280 +
theme/_includes/components/social-icon.njk | 131 +
theme/_includes/components/webmentions.njk | 206 +
.../widgets/author-card-compact.njk | 30 +
.../components/widgets/author-card.njk | 6 +
.../_includes/components/widgets/blogroll.njk | 110 +
.../components/widgets/categories.njk | 15 +
.../components/widgets/fediverse-follow.njk | 38 +
.../_includes/components/widgets/feedland.njk | 383 +
.../components/widgets/funkwhale.njk | 115 +
.../components/widgets/github-repos.njk | 213 +
.../components/widgets/post-categories.njk | 21 +
.../components/widgets/post-navigation.njk | 66 +
.../components/widgets/recent-comments.njk | 27 +
.../components/widgets/recent-posts-blog.njk | 85 +
.../components/widgets/recent-posts.njk | 93 +
theme/_includes/components/widgets/search.njk | 10 +
theme/_includes/components/widgets/share.njk | 31 +
.../components/widgets/social-activity.njk | 96 +
.../components/widgets/subscribe.njk | 20 +
theme/_includes/components/widgets/toc.njk | 19 +
.../components/widgets/webmentions.njk | 168 +
theme/_includes/layouts/base.njk | 549 ++
theme/_includes/layouts/fullwidth.njk | 25 +
theme/_includes/layouts/home.njk | 157 +
theme/_includes/layouts/page.njk | 135 +
theme/_includes/layouts/post.njk | 263 +
theme/about.njk | 69 +
theme/articles.njk | 100 +
theme/blog.njk | 389 +
theme/bookmarks.njk | 109 +
theme/categories-index.njk | 30 +
theme/categories.njk | 83 +
theme/category-feed-json.njk | 69 +
theme/category-feed.njk | 47 +
theme/changelog.njk | 208 +
theme/chardonsbleus.njk | 28 +
theme/css/critical.css | 57 +
theme/css/lite-yt-embed.css | 95 +
theme/css/prism-theme.css | 201 +
theme/css/tailwind.css | 986 +++
theme/cv.njk | 146 +
theme/digest-feed.njk | 31 +
theme/digest-index.njk | 83 +
theme/digest.njk | 168 +
theme/eleventy.config.js | 1299 ++++
theme/featured.njk | 186 +
theme/feed-json.njk | 66 +
theme/feed.njk | 44 +
theme/funkwhale.njk | 269 +
theme/github.njk | 270 +
theme/graph.njk | 18 +
theme/images/default-avatar.svg | 5 +
theme/images/favicon.svg | 4 +
theme/images/og-default.png | Bin 0 -> 39412 bytes
theme/images/rick.jpg | Bin 0 -> 152493 bytes
theme/index.njk | 8 +
theme/interactions.njk | 533 ++
theme/interactive/architecture.html | 1162 +++
theme/js/admin.js | 67 +
theme/js/comments.js | 151 +
theme/js/fediverse-interact.js | 144 +
theme/js/lightbox.js | 80 +
theme/js/save-later.js | 51 +
theme/js/share-post.js | 86 +
theme/js/time-difference.js | 91 +
theme/js/vendor/alpine-collapse.min.js | 1 +
theme/js/vendor/alpine.min.js | 5 +
theme/js/vendor/lite-yt-embed.js | 225 +
theme/js/webmentions.js | 467 ++
theme/lib/og-cli.js | 19 +
theme/lib/og.js | 344 +
theme/lib/unfurl-shortcode.js | 174 +
theme/likes.njk | 110 +
theme/listening.njk | 508 ++
theme/news.njk | 461 ++
theme/notes.njk | 97 +
theme/package-lock.json | 6455 +++++++++++++++++
theme/package.json | 46 +
theme/podroll.njk | 386 +
theme/postcss.config.js | 6 +
theme/readlater.njk | 250 +
theme/replies.njk | 139 +
theme/reposts.njk | 115 +
theme/search.njk | 39 +
theme/slashes.njk | 153 +
theme/starred.njk | 435 ++
theme/tailwind.config.js | 97 +
theme/webmention-debug.njk | 124 +
theme/youtube.njk | 262 +
148 files changed, 26067 insertions(+)
create mode 100644 indiekit.config.mjs
create mode 100644 theme/_data/blogrollStatus.js
create mode 100644 theme/_data/blueskyFeed.js
create mode 100644 theme/_data/conversationMentions.js
create mode 100644 theme/_data/cv.js
create mode 100644 theme/_data/cvPageConfig.js
create mode 100644 theme/_data/eleventyComputed.js
create mode 100644 theme/_data/enabledPostTypes.js
create mode 100644 theme/_data/funkwhaleActivity.js
create mode 100644 theme/_data/githubActivity.js
create mode 100644 theme/_data/githubRepos.js
create mode 100644 theme/_data/githubStarred.js
create mode 100644 theme/_data/homepageConfig.js
create mode 100644 theme/_data/lastfmActivity.js
create mode 100644 theme/_data/mastodonFeed.js
create mode 100644 theme/_data/newsActivity.js
create mode 100644 theme/_data/podrollStatus.js
create mode 100644 theme/_data/recentComments.js
create mode 100644 theme/_data/site.js
create mode 100644 theme/_data/urlAliases.js
create mode 100644 theme/_data/youtubeChannel.js
create mode 100644 theme/_includes/components/blog-sidebar.njk
create mode 100644 theme/_includes/components/comments.njk
create mode 100644 theme/_includes/components/cv-builder.njk
create mode 100644 theme/_includes/components/cv-footer.njk
create mode 100644 theme/_includes/components/cv-sidebar.njk
create mode 100644 theme/_includes/components/empty-collection.njk
create mode 100644 theme/_includes/components/fediverse-modal.njk
create mode 100644 theme/_includes/components/funkwhale-stats-content.njk
create mode 100644 theme/_includes/components/h-card.njk
create mode 100644 theme/_includes/components/homepage-builder.njk
create mode 100644 theme/_includes/components/homepage-footer.njk
create mode 100644 theme/_includes/components/homepage-section.njk
create mode 100644 theme/_includes/components/homepage-sidebar.njk
create mode 100644 theme/_includes/components/icon.njk
create mode 100644 theme/_includes/components/post-navigation.njk
create mode 100644 theme/_includes/components/reply-context.njk
create mode 100644 theme/_includes/components/sections/custom-html.njk
create mode 100644 theme/_includes/components/sections/cv-education-personal.njk
create mode 100644 theme/_includes/components/sections/cv-education-work.njk
create mode 100644 theme/_includes/components/sections/cv-education.njk
create mode 100644 theme/_includes/components/sections/cv-experience-personal.njk
create mode 100644 theme/_includes/components/sections/cv-experience-work.njk
create mode 100644 theme/_includes/components/sections/cv-experience.njk
create mode 100644 theme/_includes/components/sections/cv-interests-personal.njk
create mode 100644 theme/_includes/components/sections/cv-interests-work.njk
create mode 100644 theme/_includes/components/sections/cv-interests.njk
create mode 100644 theme/_includes/components/sections/cv-languages.njk
create mode 100644 theme/_includes/components/sections/cv-projects-personal.njk
create mode 100644 theme/_includes/components/sections/cv-projects-work.njk
create mode 100644 theme/_includes/components/sections/cv-projects.njk
create mode 100644 theme/_includes/components/sections/cv-skills-personal.njk
create mode 100644 theme/_includes/components/sections/cv-skills-work.njk
create mode 100644 theme/_includes/components/sections/cv-skills.njk
create mode 100644 theme/_includes/components/sections/featured-posts.njk
create mode 100644 theme/_includes/components/sections/hero.njk
create mode 100644 theme/_includes/components/sections/posting-activity.njk
create mode 100644 theme/_includes/components/sections/recent-posts.njk
create mode 100644 theme/_includes/components/sidebar.njk
create mode 100644 theme/_includes/components/social-icon.njk
create mode 100644 theme/_includes/components/webmentions.njk
create mode 100644 theme/_includes/components/widgets/author-card-compact.njk
create mode 100644 theme/_includes/components/widgets/author-card.njk
create mode 100644 theme/_includes/components/widgets/blogroll.njk
create mode 100644 theme/_includes/components/widgets/categories.njk
create mode 100644 theme/_includes/components/widgets/fediverse-follow.njk
create mode 100644 theme/_includes/components/widgets/feedland.njk
create mode 100644 theme/_includes/components/widgets/funkwhale.njk
create mode 100644 theme/_includes/components/widgets/github-repos.njk
create mode 100644 theme/_includes/components/widgets/post-categories.njk
create mode 100644 theme/_includes/components/widgets/post-navigation.njk
create mode 100644 theme/_includes/components/widgets/recent-comments.njk
create mode 100644 theme/_includes/components/widgets/recent-posts-blog.njk
create mode 100644 theme/_includes/components/widgets/recent-posts.njk
create mode 100644 theme/_includes/components/widgets/search.njk
create mode 100644 theme/_includes/components/widgets/share.njk
create mode 100644 theme/_includes/components/widgets/social-activity.njk
create mode 100644 theme/_includes/components/widgets/subscribe.njk
create mode 100644 theme/_includes/components/widgets/toc.njk
create mode 100644 theme/_includes/components/widgets/webmentions.njk
create mode 100644 theme/_includes/layouts/base.njk
create mode 100644 theme/_includes/layouts/fullwidth.njk
create mode 100644 theme/_includes/layouts/home.njk
create mode 100644 theme/_includes/layouts/page.njk
create mode 100644 theme/_includes/layouts/post.njk
create mode 100644 theme/about.njk
create mode 100644 theme/articles.njk
create mode 100644 theme/blog.njk
create mode 100644 theme/bookmarks.njk
create mode 100644 theme/categories-index.njk
create mode 100644 theme/categories.njk
create mode 100644 theme/category-feed-json.njk
create mode 100644 theme/category-feed.njk
create mode 100644 theme/changelog.njk
create mode 100644 theme/chardonsbleus.njk
create mode 100644 theme/css/critical.css
create mode 100644 theme/css/lite-yt-embed.css
create mode 100644 theme/css/prism-theme.css
create mode 100644 theme/css/tailwind.css
create mode 100644 theme/cv.njk
create mode 100644 theme/digest-feed.njk
create mode 100644 theme/digest-index.njk
create mode 100644 theme/digest.njk
create mode 100644 theme/eleventy.config.js
create mode 100644 theme/featured.njk
create mode 100644 theme/feed-json.njk
create mode 100644 theme/feed.njk
create mode 100644 theme/funkwhale.njk
create mode 100644 theme/github.njk
create mode 100644 theme/graph.njk
create mode 100644 theme/images/default-avatar.svg
create mode 100644 theme/images/favicon.svg
create mode 100644 theme/images/og-default.png
create mode 100644 theme/images/rick.jpg
create mode 100644 theme/index.njk
create mode 100644 theme/interactions.njk
create mode 100644 theme/interactive/architecture.html
create mode 100644 theme/js/admin.js
create mode 100644 theme/js/comments.js
create mode 100644 theme/js/fediverse-interact.js
create mode 100644 theme/js/lightbox.js
create mode 100644 theme/js/save-later.js
create mode 100644 theme/js/share-post.js
create mode 100644 theme/js/time-difference.js
create mode 100644 theme/js/vendor/alpine-collapse.min.js
create mode 100644 theme/js/vendor/alpine.min.js
create mode 100644 theme/js/vendor/lite-yt-embed.js
create mode 100644 theme/js/webmentions.js
create mode 100644 theme/lib/og-cli.js
create mode 100644 theme/lib/og.js
create mode 100644 theme/lib/unfurl-shortcode.js
create mode 100644 theme/likes.njk
create mode 100644 theme/listening.njk
create mode 100644 theme/news.njk
create mode 100644 theme/notes.njk
create mode 100644 theme/package-lock.json
create mode 100644 theme/package.json
create mode 100644 theme/podroll.njk
create mode 100644 theme/postcss.config.js
create mode 100644 theme/readlater.njk
create mode 100644 theme/replies.njk
create mode 100644 theme/reposts.njk
create mode 100644 theme/search.njk
create mode 100644 theme/slashes.njk
create mode 100644 theme/starred.njk
create mode 100644 theme/tailwind.config.js
create mode 100644 theme/webmention-debug.njk
create mode 100644 theme/youtube.njk
diff --git a/indiekit.config.mjs b/indiekit.config.mjs
new file mode 100644
index 0000000..d4477c5
--- /dev/null
+++ b/indiekit.config.mjs
@@ -0,0 +1,75 @@
+export default {
+ url: "https://blog.giersig.eu",
+ // Debug-Level erhöhen
+ debug: "indiekit:*",
+ application: {
+ name: "Indiekit",
+ admin: {
+ username: "admin@blog.giersig.eu",
+ password: "accus3D!23"
+ }
+ },
+ "@indiekit/endpoint-auth": {
+ publicUrl: "https://blog.giersig.eu"
+ },
+ publication: {
+ me: "https://blog.giersig.eu",
+ postTypes: [
+ {
+ type: "article",
+ name: "Artikel",
+ post: {
+ path: "src/posts/{slug}.md",
+ url: "https://blog.giersig.eu/posts/{slug}/",
+ },
+ },
+ {
+ type: "note",
+ name: "Notiz",
+ post: {
+ path: "src/notes/{slug}.md",
+ url: "https://blog.giersig.eu/notes/{slug}/",
+ },
+ },
+ {
+ type: "bookmark",
+ name: "Lesezeichen",
+ post: {
+ path: "src/bookmarks/{slug}.md",
+ url: "https://blog.giersig.eu/bookmarks/{slug}/",
+ },
+ },
+ ],
+ },
+
+ secret: process.env.SECRET,
+ mongodbUrl: `mongodb://indiekit:${process.env.MONGO_PASSWORD}@10.100.0.20:27017/indiekit`,
+ plugins: [
+ "@indiekit/store-github",
+ "@rmdes/indiekit-endpoint-posts",
+ "@rmdes/indiekit-endpoint-auth",
+ "@rmdes/indiekit-endpoint-share",
+ "@rmdes/indiekit-endpoint-github",
+ "@rmdes/indiekit-endpoint-webmention-io",
+ "@rmdes/indiekit-endpoint-conversations",
+ // "@rmdes/indiekit-endpoint-activitypub",
+ ],
+ "@indiekit/store-github": {
+ user: "svemagie",
+ repo: "blog",
+ branch: "main",
+ },
+ "@rmdes/indiekit-endpoint-github": {
+ token: process.env.GITHUB_TOKEN,
+ user: "svemagie",
+ },
+ "@rmdes/indiekit-endpoint-webmention-io": {
+ token: process.env.WEBMENTION_IO_TOKEN,
+ },
+ "@rmdes/indiekit-endpoint-conversations": {
+ enabled: true,
+ },
+ "@rmdes/indiekit-endpoint-activitypub": {
+ username: "blog.giersig.eu",
+ },
+};
diff --git a/theme/_data/blogrollStatus.js b/theme/_data/blogrollStatus.js
new file mode 100644
index 0000000..aee1601
--- /dev/null
+++ b/theme/_data/blogrollStatus.js
@@ -0,0 +1,34 @@
+/**
+ * Blogroll Status Data
+ * Checks if the blogroll API backend is available at build time.
+ * Used for conditional navigation — the blogroll page itself loads data client-side.
+ */
+
+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}/blogrollapi/api/status`;
+ console.log(`[blogrollStatus] Checking API: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log("[blogrollStatus] API available");
+ return {
+ available: true,
+ source: "indiekit",
+ ...data,
+ };
+ } catch (error) {
+ console.log(
+ `[blogrollStatus] API unavailable: ${error.message}`
+ );
+ return {
+ available: false,
+ source: "unavailable",
+ };
+ }
+}
diff --git a/theme/_data/blueskyFeed.js b/theme/_data/blueskyFeed.js
new file mode 100644
index 0000000..b0083bc
--- /dev/null
+++ b/theme/_data/blueskyFeed.js
@@ -0,0 +1,68 @@
+/**
+ * Bluesky Feed Data
+ * Fetches recent posts from Bluesky using the AT Protocol API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+import { BskyAgent } from "@atproto/api";
+
+export default async function () {
+ const handle = process.env.BLUESKY_HANDLE || "";
+
+ try {
+ // Create agent and resolve handle to DID
+ const agent = new BskyAgent({ service: "https://bsky.social" });
+
+ // Get the author's feed using public API (no auth needed for public posts)
+ const feedUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=10`;
+
+ const response = await EleventyFetch(feedUrl, {
+ duration: "15m", // Cache for 15 minutes
+ type: "json",
+ fetchOptions: {
+ headers: {
+ Accept: "application/json",
+ },
+ },
+ });
+
+ if (!response.feed) {
+ console.log("No Bluesky feed found for handle:", handle);
+ return [];
+ }
+
+ // Transform the feed into a simpler format
+ return response.feed.map((item) => {
+ // Extract rkey from AT URI (at://did:plc:xxx/app.bsky.feed.post/rkey)
+ const rkey = item.post.uri.split("/").pop();
+ const postUrl = `https://bsky.app/profile/${item.post.author.handle}/post/${rkey}`;
+
+ return {
+ text: item.post.record.text,
+ createdAt: item.post.record.createdAt,
+ uri: item.post.uri,
+ url: postUrl,
+ cid: item.post.cid,
+ author: {
+ handle: item.post.author.handle,
+ displayName: item.post.author.displayName,
+ avatar: item.post.author.avatar,
+ },
+ likeCount: item.post.likeCount || 0,
+ repostCount: item.post.repostCount || 0,
+ replyCount: item.post.replyCount || 0,
+ // Extract any embedded links or images
+ embed: item.post.embed
+ ? {
+ type: item.post.embed.$type,
+ images: item.post.embed.images || [],
+ external: item.post.embed.external || null,
+ }
+ : null,
+ };
+ });
+ } catch (error) {
+ console.error("Error fetching Bluesky feed:", error.message);
+ return [];
+ }
+}
diff --git a/theme/_data/conversationMentions.js b/theme/_data/conversationMentions.js
new file mode 100644
index 0000000..1652f37
--- /dev/null
+++ b/theme/_data/conversationMentions.js
@@ -0,0 +1,14 @@
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+export default async function () {
+ try {
+ const data = await EleventyFetch(
+ "http://127.0.0.1:8080/conversations/api/mentions?per-page=10000",
+ { duration: "15m", type: "json" }
+ );
+ return data.children || [];
+ } catch (e) {
+ console.log(`[conversationMentions] API unavailable: ${e.message}`);
+ return [];
+ }
+}
diff --git a/theme/_data/cv.js b/theme/_data/cv.js
new file mode 100644
index 0000000..a64d08f
--- /dev/null
+++ b/theme/_data/cv.js
@@ -0,0 +1,37 @@
+/**
+ * CV Data — reads from indiekit-endpoint-cv plugin data file.
+ *
+ * The CV plugin writes content/.indiekit/cv.json on every save
+ * and on startup. Eleventy reads that file here.
+ *
+ * Falls back to empty defaults if no plugin is installed.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default function () {
+ try {
+ const cvPath = resolve(__dirname, "..", "content", ".indiekit", "cv.json");
+ const raw = readFileSync(cvPath, "utf8");
+ const data = JSON.parse(raw);
+ console.log("[cv] Loaded CV data from plugin");
+ return data;
+ } catch {
+ // No CV plugin data file — return empty defaults
+ return {
+ lastUpdated: null,
+ experience: [],
+ projects: [],
+ skills: {},
+ skillTypes: {},
+ languages: [],
+ education: [],
+ interests: {},
+ interestTypes: {},
+ };
+ }
+}
diff --git a/theme/_data/cvPageConfig.js b/theme/_data/cvPageConfig.js
new file mode 100644
index 0000000..456b388
--- /dev/null
+++ b/theme/_data/cvPageConfig.js
@@ -0,0 +1,29 @@
+/**
+ * CV Page Configuration Data
+ * Reads config from indiekit-endpoint-cv plugin CV page builder.
+ * Falls back to null — cv.njk then uses the default hardcoded layout.
+ *
+ * The CV plugin writes a .indiekit/cv-page.json file that Eleventy watches.
+ * On change, a rebuild picks up the new config, allowing layout changes
+ * without a Docker rebuild.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default function () {
+ try {
+ // Resolve via the content/ symlink relative to the Eleventy project
+ const configPath = resolve(__dirname, "..", "content", ".indiekit", "cv-page.json");
+ const raw = readFileSync(configPath, "utf8");
+ const config = JSON.parse(raw);
+ console.log("[cvPageConfig] Loaded CV page builder config");
+ return config;
+ } catch {
+ // No CV page builder config — fall back to hardcoded layout in cv.njk
+ return null;
+ }
+}
diff --git a/theme/_data/eleventyComputed.js b/theme/_data/eleventyComputed.js
new file mode 100644
index 0000000..fa56636
--- /dev/null
+++ b/theme/_data/eleventyComputed.js
@@ -0,0 +1,52 @@
+/**
+ * Computed data resolved during the data cascade.
+ *
+ * Eleventy 3.x parallel rendering causes `page.url`, `page.fileSlug`,
+ * and `page.inputPath` to return values from OTHER pages being processed
+ * concurrently. This affects both templates and eleventyComputed functions.
+ *
+ * IMPORTANT: Only `permalink` is computed here, because it reads from the
+ * file's own frontmatter data (per-file, immune to race conditions).
+ * OG image lookups are done in templates using the `permalink` data value
+ * and Nunjucks filters (see base.njk).
+ *
+ * NEVER use `page.url`, `page.fileSlug`, or `page.inputPath` here.
+ *
+ * See: https://github.com/11ty/eleventy/issues/3183
+ */
+
+export default {
+ eleventyComputed: {
+ // Compute permalink from file path for posts without explicit frontmatter permalink.
+ // Pattern: content/{type}/{yyyy}-{MM}-{dd}-{slug}.md → /{type}/{yyyy}/{MM}/{dd}/{slug}/
+ permalink: (data) => {
+ // Convert stale /content/ permalinks from pre-beta.37 posts to canonical format
+ if (data.permalink && typeof data.permalink === "string") {
+ const contentMatch = data.permalink.match(
+ /^\/content\/([^/]+)\/(\d{4})-(\d{2})-(\d{2})-(.+?)\/?$/
+ );
+ if (contentMatch) {
+ const [, type, year, month, day, slug] = contentMatch;
+ return `/${type}/${year}/${month}/${day}/${slug}/`;
+ }
+ // Valid non-/content/ permalink — use as-is
+ return data.permalink;
+ }
+
+ // No frontmatter permalink — compute from file path
+ // NOTE: data.page.inputPath may be wrong due to parallel rendering,
+ // but posts without frontmatter permalink are rare (only pre-beta.37 edge cases)
+ const inputPath = data.page?.inputPath || "";
+ const match = inputPath.match(
+ /content\/([^/]+)\/(\d{4})-(\d{2})-(\d{2})-(.+)\.md$/
+ );
+ if (match) {
+ const [, type, year, month, day, slug] = match;
+ return `/${type}/${year}/${month}/${day}/${slug}/`;
+ }
+
+ // For non-matching files (pages, root files), let Eleventy decide
+ return data.permalink;
+ },
+ },
+};
diff --git a/theme/_data/enabledPostTypes.js b/theme/_data/enabledPostTypes.js
new file mode 100644
index 0000000..989bc7a
--- /dev/null
+++ b/theme/_data/enabledPostTypes.js
@@ -0,0 +1,50 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const CONTENT_DIR = process.env.CONTENT_DIR || "/data/content";
+
+// Standard post types for any Indiekit deployment
+const ALL_POST_TYPES = [
+ { type: "article", label: "Articles", path: "/articles/", createUrl: "/posts/create?type=article" },
+ { type: "note", label: "Notes", path: "/notes/", createUrl: "/posts/create?type=note" },
+ { type: "photo", label: "Photos", path: "/photos/", createUrl: "/posts/create?type=photo" },
+ { type: "bookmark", label: "Bookmarks", path: "/bookmarks/", createUrl: "/posts/create?type=bookmark" },
+ { type: "like", label: "Likes", path: "/likes/", createUrl: "/posts/create?type=like" },
+ { type: "reply", label: "Replies", path: "/replies/", createUrl: "/posts/create?type=reply" },
+ { type: "repost", label: "Reposts", path: "/reposts/", createUrl: "/posts/create?type=repost" },
+];
+
+/**
+ * Returns the list of enabled post types.
+ *
+ * Resolution order:
+ * 1. .indiekit/post-types.json in content dir (written by Indiekit or deployer)
+ * 2. POST_TYPES env var (comma-separated: "article,note,photo")
+ * 3. All standard post types (default)
+ */
+export default function () {
+ // 1. Try config file
+ try {
+ const configPath = resolve(CONTENT_DIR, ".indiekit", "post-types.json");
+ const raw = readFileSync(configPath, "utf8");
+ const types = JSON.parse(raw);
+ if (Array.isArray(types)) {
+ // Array of type strings: ["article", "note"]
+ return ALL_POST_TYPES.filter((pt) => types.includes(pt.type));
+ }
+ // Array of objects with at least { type }
+ return types;
+ } catch {
+ // File doesn't exist — fall through
+ }
+
+ // 2. Try env var
+ const envTypes = process.env.POST_TYPES;
+ if (envTypes) {
+ const types = envTypes.split(",").map((t) => t.trim().toLowerCase());
+ return ALL_POST_TYPES.filter((pt) => types.includes(pt.type));
+ }
+
+ // 3. Default — all standard types
+ return ALL_POST_TYPES;
+}
diff --git a/theme/_data/funkwhaleActivity.js b/theme/_data/funkwhaleActivity.js
new file mode 100644
index 0000000..316aeb4
--- /dev/null
+++ b/theme/_data/funkwhaleActivity.js
@@ -0,0 +1,123 @@
+/**
+ * Funkwhale Activity Data
+ * Fetches from Indiekit's endpoint-funkwhale public API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
+const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || "";
+
+/**
+ * Fetch from Indiekit's public Funkwhale API endpoint
+ */
+async function fetchFromIndiekit(endpoint) {
+ try {
+ const url = `${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`;
+ console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log(`[funkwhaleActivity] Indiekit ${endpoint} success`);
+ return data;
+ } catch (error) {
+ console.log(
+ `[funkwhaleActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
+ );
+ return null;
+ }
+}
+
+/**
+ * Format duration in seconds to human-readable string
+ */
+function formatDuration(seconds) {
+ if (!seconds || seconds < 0) return "0:00";
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 24) {
+ const days = Math.floor(hours / 24);
+ return `${days}d`;
+ }
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+
+ return `${minutes}m`;
+}
+
+export default async function () {
+ try {
+ console.log("[funkwhaleActivity] Fetching Funkwhale data...");
+
+ // Fetch all data from Indiekit API
+ const [nowPlaying, listenings, favorites, stats] = await Promise.all([
+ fetchFromIndiekit("now-playing"),
+ fetchFromIndiekit("listenings"),
+ fetchFromIndiekit("favorites"),
+ fetchFromIndiekit("stats"),
+ ]);
+
+ // Check if we got data
+ const hasData = nowPlaying || listenings?.listenings?.length || stats?.summary;
+
+ if (!hasData) {
+ console.log("[funkwhaleActivity] No data available from Indiekit");
+ return {
+ nowPlaying: null,
+ listenings: [],
+ favorites: [],
+ stats: null,
+ instanceUrl: FUNKWHALE_INSTANCE,
+ source: "unavailable",
+ };
+ }
+
+ console.log("[funkwhaleActivity] Using Indiekit API data");
+
+ // Format stats with human-readable durations
+ let formattedStats = null;
+ if (stats?.summary) {
+ formattedStats = {
+ ...stats,
+ summary: {
+ all: {
+ ...stats.summary.all,
+ totalDurationFormatted: formatDuration(stats.summary.all?.totalDuration || 0),
+ },
+ month: {
+ ...stats.summary.month,
+ totalDurationFormatted: formatDuration(stats.summary.month?.totalDuration || 0),
+ },
+ week: {
+ ...stats.summary.week,
+ totalDurationFormatted: formatDuration(stats.summary.week?.totalDuration || 0),
+ },
+ },
+ };
+ }
+
+ return {
+ nowPlaying: nowPlaying || null,
+ listenings: listenings?.listenings || [],
+ favorites: favorites?.favorites || [],
+ stats: formattedStats,
+ instanceUrl: FUNKWHALE_INSTANCE,
+ source: "indiekit",
+ };
+ } catch (error) {
+ console.error("[funkwhaleActivity] Error:", error.message);
+ return {
+ nowPlaying: null,
+ listenings: [],
+ favorites: [],
+ stats: null,
+ instanceUrl: FUNKWHALE_INSTANCE,
+ source: "error",
+ };
+ }
+}
diff --git a/theme/_data/githubActivity.js b/theme/_data/githubActivity.js
new file mode 100644
index 0000000..d8a19a7
--- /dev/null
+++ b/theme/_data/githubActivity.js
@@ -0,0 +1,284 @@
+/**
+ * GitHub Activity Data
+ * Fetches from Indiekit's endpoint-github public API
+ * Falls back to direct GitHub API if Indiekit is unavailable
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+const GITHUB_USERNAME = process.env.GITHUB_USERNAME || "";
+const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
+
+// Fallback featured repos if Indiekit API unavailable (from env: comma-separated)
+const FALLBACK_FEATURED_REPOS = process.env.GITHUB_FEATURED_REPOS?.split(",").filter(Boolean) || [];
+
+/**
+ * Fetch from Indiekit's public GitHub API endpoint
+ */
+async function fetchFromIndiekit(endpoint) {
+ try {
+ const url = `${INDIEKIT_URL}/githubapi/api/${endpoint}`;
+ console.log(`[githubActivity] Fetching from Indiekit: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log(`[githubActivity] Indiekit ${endpoint} success`);
+ return data;
+ } catch (error) {
+ console.log(
+ `[githubActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
+ );
+ return null;
+ }
+}
+
+/**
+ * Fetch from GitHub API directly
+ */
+async function fetchFromGitHub(endpoint) {
+ const url = `https://api.github.com${endpoint}`;
+ const headers = {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "Eleventy-Site",
+ };
+
+ if (process.env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
+ }
+
+ return await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ fetchOptions: { headers },
+ });
+}
+
+/**
+ * Truncate text with ellipsis
+ */
+function truncate(text, maxLength = 80) {
+ if (!text || text.length <= maxLength) return text || "";
+ return text.slice(0, maxLength - 1) + "...";
+}
+
+/**
+ * Extract commits from push events
+ */
+function extractCommits(events) {
+ if (!Array.isArray(events)) return [];
+
+ return events
+ .filter((event) => event.type === "PushEvent")
+ .flatMap((event) =>
+ (event.payload?.commits || []).map((commit) => ({
+ sha: commit.sha.slice(0, 7),
+ message: truncate(commit.message.split("\n")[0]),
+ url: `https://github.com/${event.repo.name}/commit/${commit.sha}`,
+ repo: event.repo.name,
+ repoUrl: `https://github.com/${event.repo.name}`,
+ date: event.created_at,
+ }))
+ )
+ .slice(0, 10);
+}
+
+/**
+ * Extract PRs/Issues from events
+ */
+function extractContributions(events) {
+ if (!Array.isArray(events)) return [];
+
+ return events
+ .filter(
+ (event) =>
+ (event.type === "PullRequestEvent" || event.type === "IssuesEvent") &&
+ event.payload?.action === "opened"
+ )
+ .map((event) => {
+ const item = event.payload.pull_request || event.payload.issue;
+ return {
+ type: event.type === "PullRequestEvent" ? "pr" : "issue",
+ title: truncate(item?.title),
+ url: item?.html_url,
+ repo: event.repo.name,
+ repoUrl: `https://github.com/${event.repo.name}`,
+ number: item?.number,
+ date: event.created_at,
+ };
+ })
+ .slice(0, 10);
+}
+
+/**
+ * Format starred repos
+ */
+function formatStarred(repos) {
+ if (!Array.isArray(repos)) return [];
+
+ return repos.map((repo) => ({
+ name: repo.full_name,
+ description: truncate(repo.description, 120),
+ url: repo.html_url,
+ stars: repo.stargazers_count,
+ language: repo.language,
+ topics: repo.topics?.slice(0, 5) || [],
+ }));
+}
+
+/**
+ * Fetch featured repos directly from GitHub (fallback)
+ */
+async function fetchFeaturedFromGitHub(repoList) {
+ const featured = [];
+
+ for (const repoFullName of repoList) {
+ try {
+ const repo = await fetchFromGitHub(`/repos/${repoFullName}`);
+ let commits = [];
+ try {
+ const commitsData = await fetchFromGitHub(
+ `/repos/${repoFullName}/commits?per_page=5`
+ );
+ commits = commitsData.map((c) => ({
+ sha: c.sha.slice(0, 7),
+ message: truncate(c.commit.message.split("\n")[0]),
+ url: c.html_url,
+ date: c.commit.author.date,
+ }));
+ } catch (e) {
+ console.log(`[githubActivity] Could not fetch commits for ${repoFullName}`);
+ }
+
+ featured.push({
+ fullName: repo.full_name,
+ name: repo.name,
+ description: repo.description,
+ url: repo.html_url,
+ stars: repo.stargazers_count,
+ forks: repo.forks_count,
+ language: repo.language,
+ isPrivate: repo.private,
+ commits,
+ });
+ } catch (error) {
+ console.log(`[githubActivity] Could not fetch ${repoFullName}: ${error.message}`);
+ }
+ }
+
+ return featured;
+}
+
+/**
+ * Fetch commits directly from user's recently pushed repos
+ * Fallback when events API doesn't include commit details
+ */
+async function fetchCommitsFromRepos(username, limit = 10) {
+ try {
+ const repos = await fetchFromGitHub(
+ `/users/${username}/repos?sort=pushed&per_page=5`
+ );
+
+ if (!Array.isArray(repos) || repos.length === 0) {
+ return [];
+ }
+
+ const allCommits = [];
+ for (const repo of repos.slice(0, 5)) {
+ try {
+ const repoCommits = await fetchFromGitHub(
+ `/repos/${repo.full_name}/commits?per_page=5`
+ );
+ for (const c of repoCommits) {
+ allCommits.push({
+ sha: c.sha.slice(0, 7),
+ message: truncate(c.commit?.message?.split("\n")[0]),
+ url: c.html_url,
+ repo: repo.full_name,
+ repoUrl: repo.html_url,
+ date: c.commit?.author?.date,
+ });
+ }
+ } catch {
+ // Skip repos we can't access
+ }
+ }
+
+ // Sort by date and limit
+ return allCommits
+ .sort((a, b) => new Date(b.date) - new Date(a.date))
+ .slice(0, limit);
+ } catch (error) {
+ console.log(`[githubActivity] Could not fetch commits from repos: ${error.message}`);
+ return [];
+ }
+}
+
+export default async function () {
+ try {
+ console.log("[githubActivity] Fetching GitHub data...");
+
+ // Try Indiekit public API first
+ const [indiekitStars, indiekitCommits, indiekitContributions, indiekitActivity, indiekitFeatured] =
+ await Promise.all([
+ fetchFromIndiekit("stars"),
+ fetchFromIndiekit("commits"),
+ fetchFromIndiekit("contributions"),
+ fetchFromIndiekit("activity"),
+ fetchFromIndiekit("featured"),
+ ]);
+
+ // Check if Indiekit API is available
+ const hasIndiekitData =
+ indiekitStars?.stars ||
+ indiekitCommits?.commits ||
+ indiekitFeatured?.featured;
+
+ if (hasIndiekitData) {
+ console.log("[githubActivity] Using Indiekit API data");
+ return {
+ stars: indiekitStars?.stars || [],
+ commits: indiekitCommits?.commits || [],
+ contributions: indiekitContributions?.contributions || [],
+ activity: indiekitActivity?.activity || [],
+ featured: indiekitFeatured?.featured || [],
+ source: "indiekit",
+ };
+ }
+
+ // Fallback to direct GitHub API
+ console.log("[githubActivity] Falling back to GitHub API");
+
+ const [events, starred, featured] = await Promise.all([
+ fetchFromGitHub(`/users/${GITHUB_USERNAME}/events/public?per_page=50`),
+ fetchFromGitHub(`/users/${GITHUB_USERNAME}/starred?per_page=20&sort=created`),
+ fetchFeaturedFromGitHub(FALLBACK_FEATURED_REPOS),
+ ]);
+
+ // Try to extract commits from events first
+ let commits = extractCommits(events || []);
+
+ // If events API didn't have commits, fetch directly from repos
+ if (commits.length === 0 && GITHUB_USERNAME) {
+ console.log("[githubActivity] Events API returned no commits, fetching from repos");
+ commits = await fetchCommitsFromRepos(GITHUB_USERNAME, 10);
+ }
+
+ return {
+ stars: formatStarred(starred || []),
+ commits,
+ contributions: extractContributions(events || []),
+ featured,
+ source: "github",
+ };
+ } catch (error) {
+ console.error("[githubActivity] Error:", error.message);
+ return {
+ stars: [],
+ commits: [],
+ contributions: [],
+ featured: [],
+ source: "error",
+ };
+ }
+}
diff --git a/theme/_data/githubRepos.js b/theme/_data/githubRepos.js
new file mode 100644
index 0000000..1b8fe5f
--- /dev/null
+++ b/theme/_data/githubRepos.js
@@ -0,0 +1,48 @@
+/**
+ * GitHub Repos Data
+ * Fetches public repositories from GitHub API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+export default async function () {
+ const username = process.env.GITHUB_USERNAME || "";
+
+ try {
+ // Fetch public repos, sorted by updated date
+ const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
+
+ const repos = await EleventyFetch(url, {
+ duration: "1h", // Cache for 1 hour
+ type: "json",
+ fetchOptions: {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "Eleventy-Site",
+ },
+ },
+ });
+
+ // Filter and transform repos
+ return repos
+ .filter((repo) => !repo.fork && !repo.private) // Exclude forks and private repos
+ .map((repo) => ({
+ name: repo.name,
+ full_name: repo.full_name,
+ description: repo.description,
+ html_url: repo.html_url,
+ homepage: repo.homepage,
+ language: repo.language,
+ stargazers_count: repo.stargazers_count,
+ forks_count: repo.forks_count,
+ open_issues_count: repo.open_issues_count,
+ topics: repo.topics || [],
+ updated_at: repo.updated_at,
+ created_at: repo.created_at,
+ }))
+ .slice(0, 10); // Limit to 10 repos
+ } catch (error) {
+ console.error("Error fetching GitHub repos:", error.message);
+ return [];
+ }
+}
diff --git a/theme/_data/githubStarred.js b/theme/_data/githubStarred.js
new file mode 100644
index 0000000..659d181
--- /dev/null
+++ b/theme/_data/githubStarred.js
@@ -0,0 +1,32 @@
+/**
+ * GitHub Starred Repos Metadata
+ * Fetches the starred API response (cached 15min) to extract totalCount.
+ * Only totalCount is passed to Eleventy's data cascade — the full star
+ * list is discarded after parsing, keeping build memory low.
+ * The starred page fetches all data client-side via Alpine.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}/githubapi/api/starred/all`;
+ const response = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+
+ return {
+ totalCount: response.totalCount || 0,
+ buildDate: new Date().toISOString(),
+ };
+ } catch (error) {
+ console.log(`[githubStarred] Could not fetch starred count: ${error.message}`);
+ return {
+ totalCount: 0,
+ buildDate: new Date().toISOString(),
+ };
+ }
+}
diff --git a/theme/_data/homepageConfig.js b/theme/_data/homepageConfig.js
new file mode 100644
index 0000000..ed5b2a7
--- /dev/null
+++ b/theme/_data/homepageConfig.js
@@ -0,0 +1,29 @@
+/**
+ * Homepage Configuration Data
+ * Reads config from indiekit-endpoint-homepage plugin (when installed).
+ * Falls back to null — home.njk then uses the default layout.
+ *
+ * Future: The homepage plugin will write a .indiekit/homepage.json file
+ * that Eleventy watches. On change, a rebuild picks up the new config,
+ * allowing layout changes without a Docker rebuild.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default function () {
+ try {
+ // Resolve via the content/ symlink relative to the Eleventy project
+ const configPath = resolve(__dirname, "..", "content", ".indiekit", "homepage.json");
+ const raw = readFileSync(configPath, "utf8");
+ const config = JSON.parse(raw);
+ console.log("[homepageConfig] Loaded plugin config");
+ return config;
+ } catch {
+ // No homepage plugin config — this is the normal case for most deployments
+ return null;
+ }
+}
diff --git a/theme/_data/lastfmActivity.js b/theme/_data/lastfmActivity.js
new file mode 100644
index 0000000..d7dd837
--- /dev/null
+++ b/theme/_data/lastfmActivity.js
@@ -0,0 +1,83 @@
+/**
+ * Last.fm Activity Data
+ * Fetches from Indiekit's endpoint-lastfm public API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
+const LASTFM_USERNAME = process.env.LASTFM_USERNAME || "";
+
+/**
+ * Fetch from Indiekit's public Last.fm API endpoint
+ */
+async function fetchFromIndiekit(endpoint) {
+ try {
+ const url = `${INDIEKIT_URL}/lastfmapi/api/${endpoint}`;
+ console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log(`[lastfmActivity] Indiekit ${endpoint} success`);
+ return data;
+ } catch (error) {
+ console.log(
+ `[lastfmActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
+ );
+ return null;
+ }
+}
+
+export default async function () {
+ try {
+ console.log("[lastfmActivity] Fetching Last.fm data...");
+
+ // Fetch all data from Indiekit API
+ const [nowPlaying, scrobbles, loved, stats] = await Promise.all([
+ fetchFromIndiekit("now-playing"),
+ fetchFromIndiekit("scrobbles"),
+ fetchFromIndiekit("loved"),
+ fetchFromIndiekit("stats"),
+ ]);
+
+ // Check if we got data
+ const hasData = nowPlaying || scrobbles?.scrobbles?.length || stats?.summary;
+
+ if (!hasData) {
+ console.log("[lastfmActivity] No data available from Indiekit");
+ return {
+ nowPlaying: null,
+ scrobbles: [],
+ loved: [],
+ stats: null,
+ username: LASTFM_USERNAME,
+ profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
+ source: "unavailable",
+ };
+ }
+
+ console.log("[lastfmActivity] Using Indiekit API data");
+
+ return {
+ nowPlaying: nowPlaying || null,
+ scrobbles: scrobbles?.scrobbles || [],
+ loved: loved?.loved || [],
+ stats: stats || null,
+ username: LASTFM_USERNAME,
+ profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
+ source: "indiekit",
+ };
+ } catch (error) {
+ console.error("[lastfmActivity] Error:", error.message);
+ return {
+ nowPlaying: null,
+ scrobbles: [],
+ loved: [],
+ stats: null,
+ username: LASTFM_USERNAME,
+ profileUrl: null,
+ source: "error",
+ };
+ }
+}
diff --git a/theme/_data/mastodonFeed.js b/theme/_data/mastodonFeed.js
new file mode 100644
index 0000000..0c79abf
--- /dev/null
+++ b/theme/_data/mastodonFeed.js
@@ -0,0 +1,96 @@
+/**
+ * Mastodon Feed Data
+ * Fetches recent posts from Mastodon using the public API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+export default async function () {
+ const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
+ const username = process.env.MASTODON_USER || "";
+
+ try {
+ // First, look up the account ID
+ const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
+
+ const account = await EleventyFetch(lookupUrl, {
+ duration: "1h", // Cache account lookup for 1 hour
+ type: "json",
+ fetchOptions: {
+ headers: {
+ Accept: "application/json",
+ },
+ },
+ });
+
+ if (!account || !account.id) {
+ console.log("Mastodon account not found:", username);
+ return [];
+ }
+
+ // Fetch recent statuses (excluding replies and boosts for cleaner feed)
+ const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true&exclude_reblogs=true`;
+
+ const statuses = await EleventyFetch(statusesUrl, {
+ duration: "15m", // Cache for 15 minutes
+ type: "json",
+ fetchOptions: {
+ headers: {
+ Accept: "application/json",
+ },
+ },
+ });
+
+ if (!statuses || !Array.isArray(statuses)) {
+ console.log("No Mastodon statuses found for:", username);
+ return [];
+ }
+
+ // Transform statuses into a simpler format
+ return statuses.map((status) => ({
+ id: status.id,
+ url: status.url,
+ text: stripHtml(status.content),
+ htmlContent: status.content,
+ createdAt: status.created_at,
+ author: {
+ username: status.account.username,
+ displayName: status.account.display_name || status.account.username,
+ avatar: status.account.avatar,
+ url: status.account.url,
+ },
+ favouritesCount: status.favourites_count || 0,
+ reblogsCount: status.reblogs_count || 0,
+ repliesCount: status.replies_count || 0,
+ // Media attachments
+ media: status.media_attachments
+ ? status.media_attachments.map((m) => ({
+ type: m.type,
+ url: m.url,
+ previewUrl: m.preview_url,
+ description: m.description,
+ }))
+ : [],
+ }));
+ } catch (error) {
+ console.error("Error fetching Mastodon feed:", error.message);
+ return [];
+ }
+}
+
+// Simple HTML stripper for plain text display
+function stripHtml(html) {
+ if (!html) return "";
+ return html
+ .replace(/ /gi, " ")
+ .replace(/<\/p>/gi, " ")
+ .replace(/<[^>]+>/g, "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
diff --git a/theme/_data/newsActivity.js b/theme/_data/newsActivity.js
new file mode 100644
index 0000000..47499b8
--- /dev/null
+++ b/theme/_data/newsActivity.js
@@ -0,0 +1,99 @@
+/**
+ * News/RSS Activity Data
+ * Fetches from Indiekit's endpoint-rss public API
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
+
+/**
+ * Fetch from Indiekit's public RSS API endpoint
+ */
+async function fetchFromIndiekit(endpoint) {
+ try {
+ const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`;
+ console.log(`[newsActivity] Fetching from Indiekit: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log(`[newsActivity] Indiekit ${endpoint} success`);
+ return data;
+ } catch (error) {
+ console.log(
+ `[newsActivity] Indiekit API unavailable for ${endpoint}: ${error.message}`
+ );
+ return null;
+ }
+}
+
+export default async function () {
+ try {
+ console.log("[newsActivity] Fetching RSS feed data...");
+
+ // Fetch all data from Indiekit API
+ const [itemsRes, feedsRes, statusRes] = await Promise.all([
+ fetchFromIndiekit("items?limit=50"),
+ fetchFromIndiekit("feeds"),
+ fetchFromIndiekit("status"),
+ ]);
+
+ // Check if we got data
+ const hasData = itemsRes?.items?.length || feedsRes?.feeds?.length;
+
+ if (!hasData) {
+ console.log("[newsActivity] No data available from Indiekit");
+ return {
+ items: [],
+ feeds: [],
+ status: null,
+ lastUpdated: null,
+ source: "unavailable",
+ };
+ }
+
+ console.log(
+ `[newsActivity] Got ${itemsRes?.items?.length || 0} items from ${feedsRes?.feeds?.length || 0} feeds`
+ );
+
+ // Create a map of feed IDs to feed info for quick lookup
+ const feedMap = new Map();
+ for (const feed of feedsRes?.feeds || []) {
+ feedMap.set(feed.id, feed);
+ }
+
+ // Enhance items with additional feed info
+ const items = (itemsRes?.items || []).map((item) => {
+ const feed = feedMap.get(item.feedId);
+ return {
+ ...item,
+ feedInfo: feed
+ ? {
+ title: feed.title,
+ siteUrl: feed.siteUrl,
+ imageUrl: feed.imageUrl,
+ }
+ : null,
+ };
+ });
+
+ return {
+ items,
+ feeds: feedsRes?.feeds || [],
+ pagination: itemsRes?.pagination || null,
+ status: statusRes || null,
+ lastUpdated: statusRes?.lastSync || new Date().toISOString(),
+ source: "indiekit",
+ };
+ } catch (error) {
+ console.error("[newsActivity] Error:", error.message);
+ return {
+ items: [],
+ feeds: [],
+ status: null,
+ lastUpdated: null,
+ source: "error",
+ };
+ }
+}
diff --git a/theme/_data/podrollStatus.js b/theme/_data/podrollStatus.js
new file mode 100644
index 0000000..cf117e5
--- /dev/null
+++ b/theme/_data/podrollStatus.js
@@ -0,0 +1,34 @@
+/**
+ * Podroll Status Data
+ * Checks if the podroll API backend is available at build time.
+ * Used for conditional navigation — the podroll page itself loads data client-side.
+ */
+
+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}/podrollapi/api/status`;
+ console.log(`[podrollStatus] Checking API: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log("[podrollStatus] API available");
+ return {
+ available: true,
+ source: "indiekit",
+ ...data,
+ };
+ } catch (error) {
+ console.log(
+ `[podrollStatus] API unavailable: ${error.message}`
+ );
+ return {
+ available: false,
+ source: "unavailable",
+ };
+ }
+}
diff --git a/theme/_data/recentComments.js b/theme/_data/recentComments.js
new file mode 100644
index 0000000..bdd1ede
--- /dev/null
+++ b/theme/_data/recentComments.js
@@ -0,0 +1,24 @@
+/**
+ * Recent Comments Data
+ * Fetches the 5 most recent comments at build time for the sidebar widget.
+ */
+
+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}/comments/api/comments?limit=5`;
+ console.log(`[recentComments] Fetching: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "15m",
+ type: "json",
+ });
+ console.log(`[recentComments] Got ${(data.children || []).length} comments`);
+ return data.children || [];
+ } catch (error) {
+ console.log(`[recentComments] Unavailable: ${error.message}`);
+ return [];
+ }
+}
diff --git a/theme/_data/site.js b/theme/_data/site.js
new file mode 100644
index 0000000..7d3d342
--- /dev/null
+++ b/theme/_data/site.js
@@ -0,0 +1,139 @@
+/**
+ * Site configuration for Eleventy
+ *
+ * Configure via environment variables in Cloudron app settings.
+ * All values have sensible defaults for initial deployment.
+ */
+
+// Parse social links from env (format: "name|url|icon,name|url|icon")
+function parseSocialLinks(envVar) {
+ if (!envVar) return [];
+ return envVar.split(",").map((link) => {
+ const [name, url, icon] = link.split("|").map((s) => s.trim());
+ // Bluesky requires "me atproto" for verification
+ const rel = url.includes("bsky.app") ? "me atproto" : "me";
+ return { name, url, rel, icon: icon || name.toLowerCase() };
+ });
+}
+
+// Get fediverse handle for fediverse:creator meta tag
+// Prefers the site's own ActivityPub identity over external Mastodon account
+function getFediverseCreator() {
+ // Primary: site's own ActivityPub actor (canonical fediverse identity)
+ const apHandle = process.env.ACTIVITYPUB_HANDLE;
+ if (apHandle) {
+ const domain = (process.env.SITE_URL || "https://example.com").replace(/^https?:\/\//, "");
+ return `@${apHandle}@${domain}`;
+ }
+ // Fallback: external Mastodon account (syndication target)
+ const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || "";
+ const user = process.env.MASTODON_USER || "";
+ if (instance && user) {
+ return `@${user}@${instance}`;
+ }
+ return "";
+}
+
+// Auto-generate social links from feed config when SITE_SOCIAL is not set
+function buildSocialFromFeeds() {
+ const links = [];
+ const github = process.env.GITHUB_USERNAME;
+ if (github) {
+ links.push({ name: "GitHub", url: `https://github.com/${github}`, rel: "me", icon: "github" });
+ }
+ const bskyHandle = process.env.BLUESKY_HANDLE;
+ if (bskyHandle) {
+ links.push({ name: "Bluesky", url: `https://bsky.app/profile/${bskyHandle}`, rel: "me atproto", icon: "bluesky" });
+ }
+ const mastoInstance = process.env.MASTODON_INSTANCE?.replace("https://", "");
+ const mastoUser = process.env.MASTODON_USER;
+ if (mastoInstance && mastoUser) {
+ links.push({ name: "Mastodon", url: `https://${mastoInstance}/@${mastoUser}`, rel: "me", icon: "mastodon" });
+ }
+ const linkedin = process.env.LINKEDIN_USERNAME;
+ if (linkedin) {
+ links.push({ name: "LinkedIn", url: `https://linkedin.com/in/${linkedin}`, rel: "me", icon: "linkedin" });
+ }
+ const apHandle = process.env.ACTIVITYPUB_HANDLE;
+ if (apHandle) {
+ const siteUrl = process.env.SITE_URL || "https://example.com";
+ links.push({ name: "ActivityPub", url: `${siteUrl}/activitypub/users/${apHandle}`, rel: "me", icon: "activitypub" });
+ }
+ return links;
+}
+
+// site.url: no trailing slash — used as URL base for path concatenation ({{ site.url }}/path)
+// site.me / site.author.url: trailing slash — Mastodon rel="me" requires exact match
+const siteUrlBase = (process.env.SITE_URL || "https://example.com").replace(/\/$/, "");
+const siteUrlWithSlash = siteUrlBase + "/";
+
+export default {
+ // Basic site info
+ name: process.env.SITE_NAME || "My IndieWeb Blog",
+ url: siteUrlBase,
+ me: siteUrlWithSlash,
+ locale: process.env.SITE_LOCALE || "en",
+ description:
+ process.env.SITE_DESCRIPTION ||
+ "An IndieWeb-powered blog with Micropub support",
+
+ // Author info (shown in h-card, about page, etc.)
+ author: {
+ name: process.env.AUTHOR_NAME || "Blog Author",
+ url: siteUrlWithSlash,
+ avatar: process.env.AUTHOR_AVATAR || "/images/default-avatar.svg",
+ title: process.env.AUTHOR_TITLE || "",
+ bio: process.env.AUTHOR_BIO || "Welcome to my IndieWeb blog.",
+ location: process.env.AUTHOR_LOCATION || "",
+ locality: process.env.AUTHOR_LOCALITY || "",
+ region: process.env.AUTHOR_REGION || "",
+ country: process.env.AUTHOR_COUNTRY || "",
+ org: process.env.AUTHOR_ORG || "",
+ pronoun: process.env.AUTHOR_PRONOUN || "",
+ categories: process.env.AUTHOR_CATEGORIES?.split(",").map(s => s.trim()) || [],
+ keyUrl: process.env.AUTHOR_KEY_URL || "",
+ email: process.env.AUTHOR_EMAIL || "",
+ },
+
+ // Social links (for rel="me" and h-card)
+ // Set SITE_SOCIAL env var as: "GitHub|https://github.com/user|github,Mastodon|https://mastodon.social/@user|mastodon"
+ // Falls back to auto-generating from feed config (GITHUB_USERNAME, BLUESKY_HANDLE, etc.)
+ social: parseSocialLinks(process.env.SITE_SOCIAL).length > 0
+ ? parseSocialLinks(process.env.SITE_SOCIAL)
+ : buildSocialFromFeeds(),
+
+ // Feed integrations (usernames for data fetching)
+ feeds: {
+ github: process.env.GITHUB_USERNAME || "",
+ bluesky: process.env.BLUESKY_HANDLE || "",
+ mastodon: {
+ instance: process.env.MASTODON_INSTANCE?.replace("https://", "") || "",
+ username: process.env.MASTODON_USER || "",
+ },
+ },
+
+ // Webmentions configuration
+ webmentions: {
+ domain: process.env.SITE_URL?.replace("https://", "").replace("http://", "") || "example.com",
+ },
+
+ // Fediverse creator for meta tag (e.g., @rick@rmendes.net)
+ fediverseCreator: getFediverseCreator(),
+
+ // Support/monetization configuration (used in _textcasting JSON Feed extension)
+ support: {
+ url: process.env.SUPPORT_URL || null,
+ stripe: process.env.SUPPORT_STRIPE_URL || null,
+ lightning: process.env.SUPPORT_LIGHTNING_ADDRESS || null,
+ paymentPointer: process.env.SUPPORT_PAYMENT_POINTER || null,
+ },
+
+ // Markdown for Agents — serve clean Markdown to AI agents
+ // Set MARKDOWN_AGENTS_ENABLED to "false" to disable entirely
+ markdownAgents: {
+ enabled: (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true",
+ aiTrain: process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes",
+ search: process.env.MARKDOWN_AGENTS_SEARCH || "yes",
+ aiInput: process.env.MARKDOWN_AGENTS_AI_INPUT || "yes",
+ },
+};
diff --git a/theme/_data/urlAliases.js b/theme/_data/urlAliases.js
new file mode 100644
index 0000000..1478a1f
--- /dev/null
+++ b/theme/_data/urlAliases.js
@@ -0,0 +1,155 @@
+/**
+ * URL Aliases for Webmention Recovery
+ *
+ * Maps new URLs to their old URLs so webmentions from previous
+ * URL structures can be displayed on current pages.
+ *
+ * Place redirect map files in the parent directory of this theme:
+ * - redirects.map (e.g., micro.blog: /YYYY/MM/DD/slug.html → /notes/...)
+ * - old-blog-redirects.map (e.g., Known/WP: /YYYY/slug → /content/...)
+ */
+
+import { readFileSync, existsSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const siteUrl = process.env.SITE_URL || "https://example.com";
+
+/**
+ * Parse a redirect map file into URL mappings
+ * Format: old_path new_path;
+ */
+function parseRedirectMap(filePath) {
+ const aliases = {};
+
+ if (!existsSync(filePath)) {
+ console.log(`[urlAliases] File not found: ${filePath}`);
+ return aliases;
+ }
+
+ try {
+ const content = readFileSync(filePath, "utf-8");
+ const lines = content.split("\n").filter((line) => {
+ const trimmed = line.trim();
+ return trimmed && !trimmed.startsWith("#");
+ });
+
+ for (const line of lines) {
+ // Format: /old/path /new/path;
+ const match = line.match(/^(\S+)\s+(\S+);?$/);
+ if (match) {
+ const [, oldPath, newPath] = match;
+ // Normalize paths (remove trailing slashes, ensure leading slash)
+ const normalizedNew = newPath.replace(/;$/, "").replace(/\/$/, "");
+ const normalizedOld = oldPath.replace(/\/$/, "");
+
+ // Map new URL → array of old URLs
+ if (!aliases[normalizedNew]) {
+ aliases[normalizedNew] = [];
+ }
+ aliases[normalizedNew].push(normalizedOld);
+ }
+ }
+ } catch (error) {
+ console.error(`[urlAliases] Error parsing ${filePath}:`, error.message);
+ }
+
+ return aliases;
+}
+
+/**
+ * Merge multiple alias maps
+ */
+function mergeAliases(...maps) {
+ const merged = {};
+ for (const map of maps) {
+ for (const [newUrl, oldUrls] of Object.entries(map)) {
+ if (!merged[newUrl]) {
+ merged[newUrl] = [];
+ }
+ merged[newUrl].push(...oldUrls);
+ }
+ }
+ return merged;
+}
+
+// Parse redirect maps from /app/pkg (Docker) or parent directory (local dev)
+// In Docker: eleventy-site is at /app/pkg/eleventy-site, maps are at /app/pkg/
+// In local dev: maps might be at ../
+const pkgRoot = resolve(__dirname, "../..");
+
+// Helper to find first existing file
+function findFile(candidates) {
+ for (const path of candidates) {
+ if (existsSync(path)) {
+ console.log(`[urlAliases] Found: ${path}`);
+ return path;
+ }
+ }
+ console.log(`[urlAliases] No file found in: ${candidates.join(", ")}`);
+ return null;
+}
+
+// Try multiple possible locations for each map type
+const microblogMapPath = findFile([
+ resolve(pkgRoot, "redirects.map"),
+ resolve(__dirname, "../../redirects.map"),
+]);
+
+const knownMapPath = findFile([
+ resolve(pkgRoot, "old-blog-redirects.map"),
+ resolve(__dirname, "../../old-blog-redirects.map"),
+]);
+
+const microblogAliases = microblogMapPath ? parseRedirectMap(microblogMapPath) : {};
+const knownAliases = knownMapPath ? parseRedirectMap(knownMapPath) : {};
+
+const allAliases = mergeAliases(microblogAliases, knownAliases);
+
+// Log summary
+const totalMappings = Object.keys(allAliases).length;
+const totalOldUrls = Object.values(allAliases).reduce((sum, urls) => sum + urls.length, 0);
+console.log(`[urlAliases] Loaded ${totalMappings} URL mappings with ${totalOldUrls} old URLs`);
+
+export default {
+ // The merged alias map: new URL → [old URLs]
+ aliases: allAliases,
+
+ // Site URL for building absolute URLs
+ siteUrl,
+
+ /**
+ * Get all URLs (old and new) that should be checked for webmentions
+ * @param {string} url - Current page URL (relative)
+ * @returns {string[]} - Array of absolute URLs to check
+ */
+ getAllUrls(url) {
+ const normalizedUrl = url.replace(/\/$/, "");
+ const urls = [
+ `${siteUrl}${url}`,
+ `${siteUrl}${normalizedUrl}`,
+ ];
+
+ // Add old URL variations
+ const oldUrls = allAliases[normalizedUrl] || [];
+ for (const oldUrl of oldUrls) {
+ urls.push(`${siteUrl}${oldUrl}`);
+ // Also try with trailing slash
+ urls.push(`${siteUrl}${oldUrl}/`);
+ }
+
+ // Deduplicate
+ return [...new Set(urls)];
+ },
+
+ /**
+ * Get just the old URLs for a given new URL
+ * @param {string} url - Current page URL (relative)
+ * @returns {string[]} - Array of old relative URLs
+ */
+ getOldUrls(url) {
+ const normalizedUrl = url.replace(/\/$/, "");
+ return allAliases[normalizedUrl] || [];
+ },
+};
diff --git a/theme/_data/youtubeChannel.js b/theme/_data/youtubeChannel.js
new file mode 100644
index 0000000..7fbf461
--- /dev/null
+++ b/theme/_data/youtubeChannel.js
@@ -0,0 +1,206 @@
+/**
+ * YouTube Channel Data
+ * Fetches from Indiekit's endpoint-youtube public API
+ * Supports single or multiple channels
+ */
+
+import EleventyFetch from "@11ty/eleventy-fetch";
+
+const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
+
+/**
+ * Fetch from Indiekit's public YouTube API endpoint
+ */
+async function fetchFromIndiekit(endpoint) {
+ try {
+ const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
+ console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
+ const data = await EleventyFetch(url, {
+ duration: "5m",
+ type: "json",
+ });
+ console.log(`[youtubeChannel] Indiekit ${endpoint} success`);
+ return data;
+ } catch (error) {
+ console.log(
+ `[youtubeChannel] Indiekit API unavailable for ${endpoint}: ${error.message}`
+ );
+ return null;
+ }
+}
+
+/**
+ * Format large numbers with locale separators
+ */
+function formatNumber(num) {
+ if (!num) return "0";
+ return new Intl.NumberFormat().format(num);
+}
+
+/**
+ * Format view count with K/M suffix for compact display
+ */
+function formatViewCount(num) {
+ if (!num) return "0";
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1).replace(/\\.0$/, "") + "K";
+ }
+ return num.toString();
+}
+
+/**
+ * Format relative time from ISO date string
+ */
+function formatRelativeTime(dateString) {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return "Today";
+ if (diffDays === 1) return "Yesterday";
+ if (diffDays < 7) return `${diffDays} days ago`;
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
+ return `${Math.floor(diffDays / 365)} years ago`;
+}
+
+/**
+ * Format channel data with computed fields
+ */
+function formatChannel(channel) {
+ if (!channel) return null;
+ return {
+ ...channel,
+ subscriberCountFormatted: formatNumber(channel.subscriberCount),
+ videoCountFormatted: formatNumber(channel.videoCount),
+ viewCountFormatted: formatNumber(channel.viewCount),
+ url: `https://www.youtube.com/channel/${channel.id}`,
+ };
+}
+
+/**
+ * Format video data with computed fields
+ */
+function formatVideo(video) {
+ return {
+ ...video,
+ viewCountFormatted: formatViewCount(video.viewCount),
+ relativeTime: formatRelativeTime(video.publishedAt),
+ };
+}
+
+export default async function () {
+ try {
+ console.log("[youtubeChannel] Fetching YouTube data...");
+
+ // Fetch all data from Indiekit API
+ const [channelData, videosData, liveData] = await Promise.all([
+ fetchFromIndiekit("channel"),
+ fetchFromIndiekit("videos"),
+ fetchFromIndiekit("live"),
+ ]);
+
+ // Check if we got data
+ const hasData =
+ channelData?.channel ||
+ channelData?.channels?.length ||
+ videosData?.videos?.length;
+
+ if (!hasData) {
+ console.log("[youtubeChannel] No data available from Indiekit");
+ return {
+ channel: null,
+ channels: [],
+ videos: [],
+ videosByChannel: {},
+ liveStatus: null,
+ liveStatuses: [],
+ isMultiChannel: false,
+ source: "unavailable",
+ };
+ }
+
+ console.log("[youtubeChannel] Using Indiekit API data");
+
+ // Determine if multi-channel mode
+ const isMultiChannel = !!(channelData?.channels && channelData.channels.length > 1);
+
+ // Format channels
+ let channels = [];
+ let channel = null;
+
+ if (isMultiChannel) {
+ channels = (channelData.channels || []).map(formatChannel).filter(Boolean);
+ channel = channels[0] || null;
+ } else {
+ channel = formatChannel(channelData?.channel);
+ channels = channel ? [channel] : [];
+ }
+
+ // Format videos
+ const videos = (videosData?.videos || []).map(formatVideo);
+
+ // Group videos by channel if multi-channel
+ let videosByChannel = {};
+ if (isMultiChannel && videosData?.videosByChannel) {
+ for (const [channelName, channelVideos] of Object.entries(videosData.videosByChannel)) {
+ videosByChannel[channelName] = (channelVideos || []).map(formatVideo);
+ }
+ } else if (channel) {
+ videosByChannel[channel.configName || channel.title] = videos;
+ }
+
+ // Format live status
+ let liveStatus = null;
+ let liveStatuses = [];
+
+ if (liveData) {
+ if (isMultiChannel && liveData.liveStatuses) {
+ liveStatuses = liveData.liveStatuses;
+ // Find first live or upcoming
+ const live = liveStatuses.find((s) => s.isLive);
+ const upcoming = liveStatuses.find((s) => s.isUpcoming && !s.isLive);
+ liveStatus = {
+ isLive: !!live,
+ isUpcoming: !live && !!upcoming,
+ stream: live?.stream || upcoming?.stream || null,
+ };
+ } else {
+ liveStatus = {
+ isLive: liveData.isLive || false,
+ isUpcoming: liveData.isUpcoming || false,
+ stream: liveData.stream || null,
+ };
+ liveStatuses = [{ ...liveStatus, channelConfigName: channel?.configName }];
+ }
+ }
+
+ return {
+ channel,
+ channels,
+ videos,
+ videosByChannel,
+ liveStatus,
+ liveStatuses,
+ isMultiChannel,
+ source: "indiekit",
+ };
+ } catch (error) {
+ console.error("[youtubeChannel] Error:", error.message);
+ return {
+ channel: null,
+ channels: [],
+ videos: [],
+ videosByChannel: {},
+ liveStatus: null,
+ liveStatuses: [],
+ isMultiChannel: false,
+ source: "error",
+ };
+ }
+}
diff --git a/theme/_includes/components/blog-sidebar.njk b/theme/_includes/components/blog-sidebar.njk
new file mode 100644
index 0000000..286f819
--- /dev/null
+++ b/theme/_includes/components/blog-sidebar.njk
@@ -0,0 +1,273 @@
+{# Blog Sidebar - Shown on individual post pages #}
+{# Data-driven when homepageConfig.blogPostSidebar is configured, otherwise falls back to default widgets #}
+{# Each widget is wrapped in a collapsible container with localStorage persistence #}
+{% from "components/icon.njk" import icon %}
+
+{% if homepageConfig and homepageConfig.blogPostSidebar and homepageConfig.blogPostSidebar.length %}
+ {# === Data-driven mode: render configured widgets === #}
+ {% for widget in homepageConfig.blogPostSidebar %}
+
+ {# 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 == "author-card-compact" %}{% set widgetTitle = "Author" %}
+ {% elif widget.type == "subscribe" %}{% set widgetTitle = "Subscribe" %}
+ {% elif widget.type == "toc" %}{% set widgetTitle = "Table of Contents" %}
+ {% elif widget.type == "post-categories" %}{% set widgetTitle = "Categories" %}
+ {% elif widget.type == "share" %}{% set widgetTitle = "Share" %}
+ {% 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 %}
+
+ {# Resolve widget icon and accent border #}
+ {% if widget.type == "social-activity" %}
+ {% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
+ {% elif widget.type == "github-repos" %}
+ {% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
+ {% elif widget.type == "funkwhale" %}
+ {% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
+ {% elif widget.type == "blogroll" %}
+ {% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "feedland" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "subscribe" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
+ {% elif widget.type == "fediverse-follow" %}
+ {% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
+ {% elif widget.type == "author-card" or widget.type == "author-card-compact" %}
+ {% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-posts" %}
+ {% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "categories" or widget.type == "post-categories" %}
+ {% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-comments" %}
+ {% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "search" %}
+ {% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "webmentions" %}
+ {% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "toc" %}
+ {% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "share" %}
+ {% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% else %}
+ {% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
+ {% endif %}
+
+ {% set widgetKey = "post-widget-" + widget.type + "-" + loop.index0 %}
+ {% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
+
+ {# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
+
+
+ {% endfor %}
+{% else %}
+ {# === Fallback: default blog post sidebar (backward compatibility) === #}
+ {# Each widget wrapped in collapsible container #}
+
+ {# Author Card Compact #}
+ {% set widgetKey = "post-fb-author-card-compact" %}
+
+
+ {# Table of Contents #}
+ {% set widgetKey = "post-fb-toc" %}
+
+
+ {# Post Categories #}
+ {% set widgetKey = "post-fb-post-categories" %}
+
+
+ {# Recent Posts #}
+ {% set widgetKey = "post-fb-recent-posts" %}
+
+
+ {# Webmentions #}
+ {% set widgetKey = "post-fb-webmentions" %}
+
+
+ {# Share #}
+ {% set widgetKey = "post-fb-share" %}
+
+
+ {# Subscribe #}
+ {% set widgetKey = "post-fb-subscribe" %}
+
+
+ {# Recent Comments #}
+ {% set widgetKey = "post-fb-recent-comments" %}
+
+{% endif %}
diff --git a/theme/_includes/components/comments.njk b/theme/_includes/components/comments.njk
new file mode 100644
index 0000000..ddf074c
--- /dev/null
+++ b/theme/_includes/components/comments.njk
@@ -0,0 +1,109 @@
+{# Comments section — shown on post pages before webmentions #}
+{# Collapsed when empty, auto-opens when comments exist #}
+{% set absoluteUrl = site.url + page.url %}
+
+
+
+
diff --git a/theme/_includes/components/cv-builder.njk b/theme/_includes/components/cv-builder.njk
new file mode 100644
index 0000000..3699f73
--- /dev/null
+++ b/theme/_includes/components/cv-builder.njk
@@ -0,0 +1,169 @@
+{#
+ CV Page Builder - renders configured layout, sections, and sidebar
+ from cvPageConfig (written by indiekit-endpoint-cv plugin)
+#}
+
+{% set layout = cvPageConfig.layout or "single-column" %}
+{% set hasSidebar = cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
+
+{# CV identity — check cvPageConfig.identity first, fall back to site.author #}
+{% set cvId = cvPageConfig.identity if (cvPageConfig and cvPageConfig.identity) else {} %}
+{% set authorName = cvId.name or site.author.name %}
+{% set authorAvatar = cvId.avatar or site.author.avatar %}
+{% set authorTitle = cvId.title or site.author.title %}
+{% set authorBio = cvId.bio or site.author.bio %}
+{% set authorDescription = cvId.description or '' %}
+{% set socialLinks = cvId.social if (cvId.social and cvId.social.length) else site.social %}
+{% set cvLocality = cvId.locality or site.author.locality %}
+{% set cvCountry = cvId.country or site.author.country %}
+{% set cvOrg = cvId.org or site.author.org %}
+{% set cvUrl = cvId.url or '' %}
+{% set cvEmail = cvId.email or site.author.email %}
+{% set cvKeyUrl = cvId.keyUrl or site.author.keyUrl %}
+
+{# Hero — rendered at top when enabled (default: true) #}
+{% if cvPageConfig.hero.enabled != false %}
+
+
+
+
+
+ {{ authorName }}
+
+ {% if authorTitle %}
+
+ {{ authorTitle }}
+
+ {% endif %}
+ {% if authorBio %}
+
+ {{ authorBio }}
+
+ {% endif %}
+ {% if authorDescription %}
+
+
+ More about me ↓
+
+
+ {{ authorDescription }}
+
+
+ {% endif %}
+ {% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
+ {% if cvPageConfig.hero.showSocial != false and socialLinks %}
+
+ {% endif %}
+ {# Contact details — location, organization, website, email, PGP #}
+ {% if cvLocality or cvCountry or cvOrg or cvUrl or cvEmail or cvKeyUrl %}
+
+ {% if cvLocality or cvCountry %}
+
{% if cvLocality %}{{ cvLocality }}{% endif %}{% if cvLocality and cvCountry %}, {% endif %}{% if cvCountry %}{{ cvCountry }}{% endif %}
+ {% endif %}
+ {% if cvOrg %}
+
{{ cvOrg }}
+ {% endif %}
+ {% if cvUrl %}
+
{{ cvUrl | replace("https://", "") | replace("http://", "") }}
+ {% endif %}
+ {% if cvEmail %}
+
{{ cvEmail }}
+ {% endif %}
+ {% if cvKeyUrl %}
+
PGP Key
+ {% endif %}
+
+ {% endif %}
+
+
+
+{% endif %}
+
+{# Layout wrapper #}
+{% if layout == "single-column" %}
+
+ {# Single column — no sidebar, full width sections #}
+
+ {% for section in cvPageConfig.sections %}
+ {% include "components/homepage-section.njk" %}
+ {% endfor %}
+
+
+{% elif layout == "two-column" and hasSidebar %}
+
+ {# Two column — sections + sidebar #}
+
+
+{% elif layout == "full-width-hero" %}
+
+ {# Full width hero (already rendered above), then two-column below #}
+ {% if hasSidebar %}
+
+ {% else %}
+
+ {% for section in cvPageConfig.sections %}
+ {% include "components/homepage-section.njk" %}
+ {% endfor %}
+
+ {% endif %}
+
+{% else %}
+
+ {# Fallback — two-column without sidebar, or unknown layout #}
+
+ {% for section in cvPageConfig.sections %}
+ {% include "components/homepage-section.njk" %}
+ {% endfor %}
+
+
+{% endif %}
+
+{# Last Updated #}
+{% if cv.lastUpdated %}
+
+ Last updated: {{ cv.lastUpdated | date("PPP") }}
+
+{% endif %}
+
+{# Footer — rendered after the main layout, full width #}
+{% include "components/cv-footer.njk" %}
diff --git a/theme/_includes/components/cv-footer.njk b/theme/_includes/components/cv-footer.njk
new file mode 100644
index 0000000..c86957e
--- /dev/null
+++ b/theme/_includes/components/cv-footer.njk
@@ -0,0 +1,26 @@
+{# CV Page Builder Footer — renders footer items in a responsive 3-column grid #}
+{% if cvPageConfig.footer and cvPageConfig.footer.length %}
+
+{% endif %}
diff --git a/theme/_includes/components/cv-sidebar.njk b/theme/_includes/components/cv-sidebar.njk
new file mode 100644
index 0000000..d27f903
--- /dev/null
+++ b/theme/_includes/components/cv-sidebar.njk
@@ -0,0 +1,43 @@
+{# CV Page Builder Sidebar — renders widgets from cvPageConfig.sidebar #}
+{% if cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
+ {% for widget in cvPageConfig.sidebar %}
+ {% 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" %}
+ {% include "components/widgets/search.njk" %}
+ {% elif widget.type == "webmentions" %}
+ {% include "components/widgets/webmentions.njk" %}
+ {% elif widget.type == "custom-html" %}
+ {# Custom content widget #}
+ {% set wConfig = widget.config or {} %}
+
+
+
+ {% else %}
+
+ {% endif %}
+ {% endfor %}
+{% endif %}
diff --git a/theme/_includes/components/empty-collection.njk b/theme/_includes/components/empty-collection.njk
new file mode 100644
index 0000000..8198853
--- /dev/null
+++ b/theme/_includes/components/empty-collection.njk
@@ -0,0 +1,27 @@
+{# Empty collection placeholder — encourages creating content #}
+{# Usage: {% include "components/empty-collection.njk" %} with postType set before include #}
+{% set typeInfo = null %}
+{% for pt in enabledPostTypes %}
+ {% if pt.type == postType %}{% set typeInfo = pt %}{% endif %}
+{% endfor %}
+
+
+
+
No {{ title | lower }} yet
+
+ This is where your {{ title | lower }} will appear once you start creating content.
+
+ {% if typeInfo %}
+
+
+
+
+ Create your first {{ postType }}
+
+ {% endif %}
+
diff --git a/theme/_includes/components/fediverse-modal.njk b/theme/_includes/components/fediverse-modal.njk
new file mode 100644
index 0000000..30699e0
--- /dev/null
+++ b/theme/_includes/components/fediverse-modal.njk
@@ -0,0 +1,81 @@
+{# Shared fediverse instance picker modal #}
+{# Used by post.njk (interact), fediverse-follow.njk (follow), share.njk (share) #}
+{# Requires: modalTitle, modalDescription variables set before include #}
+
+
+ {# Backdrop #}
+
+ {# Panel #}
+
+
{{ modalTitle }}
+
{{ modalDescription }}
+
+ {# Saved domains list #}
+
+
+
+
Use a different instance
+
+
+ Cancel
+
+
+
+
+
+ {# New domain input #}
+
+
+
+
+
+
+
+
+
+
+ Go
+
+
+
+
+
+
+
diff --git a/theme/_includes/components/funkwhale-stats-content.njk b/theme/_includes/components/funkwhale-stats-content.njk
new file mode 100644
index 0000000..b7123a2
--- /dev/null
+++ b/theme/_includes/components/funkwhale-stats-content.njk
@@ -0,0 +1,66 @@
+{# Stats Summary Cards #}
+{% if summary %}
+
+
+ {{ summary.totalPlays or 0 }}
+ Plays
+
+
+ {{ summary.uniqueTracks or 0 }}
+ Tracks
+
+
+ {{ summary.uniqueArtists or 0 }}
+ Artists
+
+
+ {{ summary.totalDurationFormatted or '0m' }}
+ Listened
+
+
+{% endif %}
+
+{# Top Artists #}
+{% if topArtists and topArtists.length %}
+
+
Top Artists
+
+ {% for artist in topArtists | head(5) %}
+
+ {{ loop.index }}
+ {{ artist.name }}
+ {{ artist.playCount }} plays
+
+ {% endfor %}
+
+
+{% endif %}
+
+{# Top Albums #}
+{% if topAlbums and topAlbums.length %}
+
+
Top Albums
+
+ {% for album in topAlbums | head(5) %}
+
+ {% if album.coverUrl %}
+
+ {% else %}
+
+ {% endif %}
+
{{ album.title }}
+
{{ album.artist }}
+
{{ album.playCount }} plays
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if not summary and not topArtists and not topAlbums %}
+No statistics available for this period.
+{% endif %}
diff --git a/theme/_includes/components/h-card.njk b/theme/_includes/components/h-card.njk
new file mode 100644
index 0000000..8e1dc83
--- /dev/null
+++ b/theme/_includes/components/h-card.njk
@@ -0,0 +1,111 @@
+{# h-card - IndieWeb identity microformat #}
+{# See: https://microformats.org/wiki/h-card #}
+{#
+ This is the canonical h-card component for the site.
+ Include in sidebar widgets, author cards, etc.
+#}
+{% 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 %}
+
+
+ {# Hidden u-photo for reliable microformat parsing (some parsers struggle with img inside links) #}
+
+
+
+
+
+
+
+
+ {{ authorName }}
+
+ {% if authorPronoun %}
+
({{ authorPronoun }})
+ {% endif %}
+
{{ authorTitle }}
+ {# Structured address #}
+
+ {% if authorLocality %}
+ {{ authorLocality }} {% if authorCountry %}, {% endif %}
+ {% endif %}
+ {% if authorCountry %}
+ {{ authorCountry }}
+ {% endif %}
+ {# Fallback to legacy location field #}
+ {% if not authorLocality and authorLocation %}
+ {{ authorLocation }}
+ {% endif %}
+
+
+
+
+ {# Bio #}
+
{{ authorBio }}
+
+ {# Organization #}
+ {% if authorOrg %}
+
+ {{ authorOrg }}
+
+ {% endif %}
+
+ {# Email and PGP Key #}
+
+ {% if authorEmail %}
+ {# Display text obfuscated to deter spam harvesters; href kept plain for browser compatibility #}
+
+ ✉️ {{ authorEmail | obfuscateEmail | safe }}
+
+ {% endif %}
+ {% if authorKeyUrl %}
+
+ 🔐 PGP Key
+
+ {% endif %}
+
+
+ {# Categories / Skills #}
+ {% if authorCategories and authorCategories.length %}
+
+ {% for category in authorCategories %}
+ {{ category }}
+ {% endfor %}
+
+ {% endif %}
+
+ {# Social links with rel="me" - critical for IndieWeb identity verification #}
+ {% from "components/social-icon.njk" import socialIcon %}
+ {% if socialLinks and socialLinks.length %}
+
+ {% for link in socialLinks %}
+
+ {{ socialIcon(link.icon, "w-5 h-5") }}
+
+ {% endfor %}
+
+ {% endif %}
+
diff --git a/theme/_includes/components/homepage-builder.njk b/theme/_includes/components/homepage-builder.njk
new file mode 100644
index 0000000..b0ba263
--- /dev/null
+++ b/theme/_includes/components/homepage-builder.njk
@@ -0,0 +1,86 @@
+{#
+ Homepage Builder - renders configured layout, sections, and sidebar
+ from homepageConfig (written by indiekit-endpoint-homepage plugin)
+#}
+
+{% set layout = homepageConfig.layout or "two-column" %}
+{% set hasSidebar = homepageConfig.sidebar and homepageConfig.sidebar.length %}
+
+{# Hero — rendered before layout wrapper when enabled #}
+{% if homepageConfig.hero and homepageConfig.hero.enabled %}
+ {% include "components/sections/hero.njk" %}
+{% endif %}
+
+{# Layout wrapper #}
+{% if layout == "single-column" %}
+
+ {# Single column — no sidebar, full width sections #}
+
+ {% for section in homepageConfig.sections %}
+ {% if section.type != "hero" %}
+ {% include "components/homepage-section.njk" %}
+ {% endif %}
+ {% endfor %}
+
+
+{% elif layout == "two-column" and hasSidebar %}
+
+ {# Two column — sections + sidebar #}
+
+
+{% elif layout == "full-width-hero" %}
+
+ {# Full width hero (already rendered above), then two-column below #}
+ {% if hasSidebar %}
+
+ {% else %}
+
+ {% for section in homepageConfig.sections %}
+ {% if section.type != "hero" %}
+ {% include "components/homepage-section.njk" %}
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+{% else %}
+
+ {# Fallback — two-column without sidebar, or unknown layout #}
+
+ {% for section in homepageConfig.sections %}
+ {% if section.type != "hero" %}
+ {% include "components/homepage-section.njk" %}
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
+
+{# Footer — rendered after the main layout, full width #}
+{% include "components/homepage-footer.njk" %}
diff --git a/theme/_includes/components/homepage-footer.njk b/theme/_includes/components/homepage-footer.njk
new file mode 100644
index 0000000..d6f917b
--- /dev/null
+++ b/theme/_includes/components/homepage-footer.njk
@@ -0,0 +1,26 @@
+{# Homepage Builder Footer — renders footer items in a responsive 3-column grid #}
+{% if homepageConfig.footer and homepageConfig.footer.length %}
+
+{% endif %}
diff --git a/theme/_includes/components/homepage-section.njk b/theme/_includes/components/homepage-section.njk
new file mode 100644
index 0000000..e7b8ee0
--- /dev/null
+++ b/theme/_includes/components/homepage-section.njk
@@ -0,0 +1,56 @@
+{# Homepage Section Dispatcher — maps section.type to the right partial #}
+{% if section.type == "featured-posts" %}
+ {% include "components/sections/featured-posts.njk" %}
+{% elif section.type == "recent-posts" %}
+ {% include "components/sections/recent-posts.njk" %}
+{% elif section.type == "custom-html" %}
+ {% include "components/sections/custom-html.njk" %}
+{% elif section.type == "cv-experience" %}
+ {% include "components/sections/cv-experience.njk" ignore missing %}
+{% elif section.type == "cv-projects" %}
+ {% include "components/sections/cv-projects.njk" ignore missing %}
+{% elif section.type == "cv-projects-personal" %}
+ {% include "components/sections/cv-projects-personal.njk" ignore missing %}
+{% elif section.type == "cv-projects-work" %}
+ {% include "components/sections/cv-projects-work.njk" ignore missing %}
+{% elif section.type == "cv-skills" %}
+ {% include "components/sections/cv-skills.njk" ignore missing %}
+{% elif section.type == "cv-education" %}
+ {% include "components/sections/cv-education.njk" ignore missing %}
+{% elif section.type == "cv-interests" %}
+ {% include "components/sections/cv-interests.njk" ignore missing %}
+{% elif section.type == "cv-experience-personal" %}
+ {% include "components/sections/cv-experience-personal.njk" ignore missing %}
+{% elif section.type == "cv-experience-work" %}
+ {% include "components/sections/cv-experience-work.njk" ignore missing %}
+{% elif section.type == "cv-education-personal" %}
+ {% include "components/sections/cv-education-personal.njk" ignore missing %}
+{% elif section.type == "cv-education-work" %}
+ {% include "components/sections/cv-education-work.njk" ignore missing %}
+{% elif section.type == "cv-skills-personal" %}
+ {% include "components/sections/cv-skills-personal.njk" ignore missing %}
+{% elif section.type == "cv-skills-work" %}
+ {% include "components/sections/cv-skills-work.njk" ignore missing %}
+{% elif section.type == "cv-interests-personal" %}
+ {% include "components/sections/cv-interests-personal.njk" ignore missing %}
+{% elif section.type == "cv-interests-work" %}
+ {% include "components/sections/cv-interests-work.njk" ignore missing %}
+{% elif section.type == "cv-languages" %}
+ {% include "components/sections/cv-languages.njk" ignore missing %}
+{% elif section.type == "blogroll" %}
+ {% include "components/sections/blogroll.njk" ignore missing %}
+{% elif section.type == "podroll" %}
+ {% include "components/sections/podroll.njk" ignore missing %}
+{% elif section.type == "github-activity" %}
+ {% include "components/sections/github-activity.njk" ignore missing %}
+{% elif section.type == "youtube" %}
+ {% include "components/sections/youtube.njk" ignore missing %}
+{% elif section.type == "funkwhale" %}
+ {% include "components/sections/funkwhale.njk" ignore missing %}
+{% elif section.type == "lastfm" %}
+ {% include "components/sections/lastfm.njk" ignore missing %}
+{% elif section.type == "posting-activity" %}
+ {% include "components/sections/posting-activity.njk" ignore missing %}
+{% else %}
+
+{% endif %}
diff --git a/theme/_includes/components/homepage-sidebar.njk b/theme/_includes/components/homepage-sidebar.njk
new file mode 100644
index 0000000..5fec890
--- /dev/null
+++ b/theme/_includes/components/homepage-sidebar.njk
@@ -0,0 +1,137 @@
+{# Homepage Builder Sidebar — renders widgets from homepageConfig.sidebar #}
+{# Each widget is wrapped in a collapsible container with localStorage persistence #}
+{% from "components/icon.njk" import icon %}
+
+{% 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 %}
+
+ {# Resolve widget icon and accent border #}
+ {% if widget.type == "social-activity" %}
+ {% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
+ {% elif widget.type == "github-repos" %}
+ {% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
+ {% elif widget.type == "funkwhale" %}
+ {% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
+ {% elif widget.type == "blogroll" %}
+ {% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "feedland" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "subscribe" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
+ {% elif widget.type == "fediverse-follow" %}
+ {% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
+ {% elif widget.type == "author-card" %}
+ {% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-posts" %}
+ {% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "categories" %}
+ {% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-comments" %}
+ {% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "search" %}
+ {% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "webmentions" %}
+ {% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% else %}
+ {% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
+ {% 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 #}
+
+
+ {% endfor %}
+{% endif %}
diff --git a/theme/_includes/components/icon.njk b/theme/_includes/components/icon.njk
new file mode 100644
index 0000000..d538ddd
--- /dev/null
+++ b/theme/_includes/components/icon.njk
@@ -0,0 +1,67 @@
+{#
+ Centralized UI icon macro
+ Usage: {% from "components/icon.njk" import icon %}
+ {{ icon("heart", "w-5 h-5 text-red-500") }}
+
+ All icons use stroke-width="2" unless they are filled icons.
+ Default size: w-5 h-5 (override via cssClass parameter)
+#}
+
+{% macro icon(name, cssClass) %}
+{% set cls = cssClass or "w-5 h-5" %}
+{%- if name == "heart" -%}
+
+{%- elif name == "bookmark" -%}
+
+{%- elif name == "repost" -%}
+
+{%- elif name == "reply" -%}
+
+{%- elif name == "camera" -%}
+
+{%- elif name == "article" -%}
+
+{%- elif name == "note" -%}
+
+{%- elif name == "music" -%}
+
+{%- elif name == "tag" -%}
+
+{%- elif name == "rss" -%}
+
+{%- elif name == "chat" -%}
+
+{%- elif name == "user" -%}
+
+{%- elif name == "search" -%}
+
+{%- elif name == "star" -%}
+
+{%- elif name == "external-link" -%}
+
+{%- elif name == "chevron-down" -%}
+
+{%- elif name == "chevron-right" -%}
+
+{%- elif name == "globe" -%}
+
+{%- elif name == "github" -%}
+
+{%- elif name == "list" -%}
+
+{%- elif name == "share" -%}
+
+{%- elif name == "book-open" -%}
+
+{%- elif name == "headphones" -%}
+
+{%- elif name == "mail" -%}
+
+{%- elif name == "podcast" -%}
+
+{%- elif name == "user-plus" -%}
+
+{%- else -%}
+
+{%- endif -%}
+{% endmacro %}
diff --git a/theme/_includes/components/post-navigation.njk b/theme/_includes/components/post-navigation.njk
new file mode 100644
index 0000000..0103704
--- /dev/null
+++ b/theme/_includes/components/post-navigation.njk
@@ -0,0 +1,103 @@
+{# Post Navigation - Previous/Next (image-first, inspired by zachleat.com) #}
+{% set _prevPost = collections.posts | previousInCollection(page) %}
+{% set _nextPost = collections.posts | nextInCollection(page) %}
+
+{% if _prevPost or _nextPost %}
+
+
+
+ {# ── Previous Post ── #}
+ {% if _prevPost %}
+ {% set _prevOgSlug = _prevPost.url | ogSlug %}
+ {% set _prevHasOg = _prevOgSlug | hasOgImage %}
+ {% set _prevTitle = _prevPost.data.title or _prevPost.data.name %}
+
+ {# Derive display text for non-article post types #}
+ {% set _likedUrl = _prevPost.data.likeOf or _prevPost.data.like_of %}
+ {% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
+ {% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
+ {% set _replyToUrl = _prevPost.data.inReplyTo or _prevPost.data.in_reply_to %}
+ {% if not _prevTitle %}
+ {% if _likedUrl %}
+ {% set _prevTitle = "Liked " + (_likedUrl | replace("https://", "") | truncate(40)) %}
+ {% elif _bookmarkedUrl %}
+ {% set _prevTitle = "Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(35)) %}
+ {% elif _repostedUrl %}
+ {% set _prevTitle = "Reposted " + (_repostedUrl | replace("https://", "") | truncate(35)) %}
+ {% elif _replyToUrl %}
+ {% set _prevTitle = "Reply to " + (_replyToUrl | replace("https://", "") | truncate(35)) %}
+ {% else %}
+ {% set _prevTitle = (_prevPost.templateContent | striptags | truncate(60)) or "Note" %}
+ {% endif %}
+ {% endif %}
+
+
+ {% if _prevHasOg %}
+
+
+ ← Previous
+
+ {% else %}
+
+ ← Previous
+
+ {{ _prevTitle }}
+
+ {{ _prevPost.date | dateDisplay }}
+
+ {% endif %}
+
+
+ {% else %}
+
+ {% endif %}
+
+ {# ── Next Post ── #}
+ {% if _nextPost %}
+ {% set _nextOgSlug = _nextPost.url | ogSlug %}
+ {% set _nextHasOg = _nextOgSlug | hasOgImage %}
+ {% set _nextTitle = _nextPost.data.title or _nextPost.data.name %}
+
+ {# Derive display text for non-article post types #}
+ {% set _likedUrl = _nextPost.data.likeOf or _nextPost.data.like_of %}
+ {% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
+ {% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
+ {% set _replyToUrl = _nextPost.data.inReplyTo or _nextPost.data.in_reply_to %}
+ {% if not _nextTitle %}
+ {% if _likedUrl %}
+ {% set _nextTitle = "Liked " + (_likedUrl | replace("https://", "") | truncate(40)) %}
+ {% elif _bookmarkedUrl %}
+ {% set _nextTitle = "Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(35)) %}
+ {% elif _repostedUrl %}
+ {% set _nextTitle = "Reposted " + (_repostedUrl | replace("https://", "") | truncate(35)) %}
+ {% elif _replyToUrl %}
+ {% set _nextTitle = "Reply to " + (_replyToUrl | replace("https://", "") | truncate(35)) %}
+ {% else %}
+ {% set _nextTitle = (_nextPost.templateContent | striptags | truncate(60)) or "Note" %}
+ {% endif %}
+ {% endif %}
+
+
+ {% if _nextHasOg %}
+
+
+ Next →
+
+ {% else %}
+
+ Next →
+
+ {{ _nextTitle }}
+
+ {{ _nextPost.date | dateDisplay }}
+
+ {% endif %}
+
+
+ {% else %}
+
+ {% endif %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/reply-context.njk b/theme/_includes/components/reply-context.njk
new file mode 100644
index 0000000..bfa2781
--- /dev/null
+++ b/theme/_includes/components/reply-context.njk
@@ -0,0 +1,74 @@
+{# Reply Context Component #}
+{# Displays rich context for replies, likes, reposts, and bookmarks #}
+{# Uses h-cite microformat for citing external content #}
+{# Includes unfurl card for rich link preview (OpenGraph metadata) #}
+
+{# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
+{% set replyTo = inReplyTo or in_reply_to %}
+{% set likedUrl = likeOf or like_of %}
+{% set repostedUrl = repostOf or repost_of %}
+{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
+
+{% if replyTo or likedUrl or repostedUrl or bookmarkedUrl %}
+
+ {% if replyTo %}
+
+ {% endif %}
+
+ {% if likedUrl %}
+
+ {% endif %}
+
+ {% if repostedUrl %}
+
+
+
+
+
+ Reposted:
+
+ {% unfurl repostedUrl %}
+
+ {{ repostedUrl }}
+
+
+ {% endif %}
+
+ {% if bookmarkedUrl %}
+
+ {% endif %}
+
+{% endif %}
diff --git a/theme/_includes/components/sections/custom-html.njk b/theme/_includes/components/sections/custom-html.njk
new file mode 100644
index 0000000..5045160
--- /dev/null
+++ b/theme/_includes/components/sections/custom-html.njk
@@ -0,0 +1,18 @@
+{#
+ Custom HTML Section - freeform HTML/markdown content block
+ Rendered by homepage-builder when custom-html section is configured
+#}
+
+{% set sectionConfig = section.config or {} %}
+
+
+ {% if sectionConfig.title %}
+
+ {{ sectionConfig.title }}
+
+ {% endif %}
+
+
+ {{ sectionConfig.content | safe }}
+
+
diff --git a/theme/_includes/components/sections/cv-education-personal.njk b/theme/_includes/components/sections/cv-education-personal.njk
new file mode 100644
index 0000000..fd937a7
--- /dev/null
+++ b/theme/_includes/components/sections/cv-education-personal.njk
@@ -0,0 +1,2 @@
+{% set filterType = "personal" %}
+{% include "components/sections/cv-education.njk" %}
diff --git a/theme/_includes/components/sections/cv-education-work.njk b/theme/_includes/components/sections/cv-education-work.njk
new file mode 100644
index 0000000..6d2e3ea
--- /dev/null
+++ b/theme/_includes/components/sections/cv-education-work.njk
@@ -0,0 +1,2 @@
+{% set filterType = "work" %}
+{% include "components/sections/cv-education.njk" %}
diff --git a/theme/_includes/components/sections/cv-education.njk b/theme/_includes/components/sections/cv-education.njk
new file mode 100644
index 0000000..3ff1ecf
--- /dev/null
+++ b/theme/_includes/components/sections/cv-education.njk
@@ -0,0 +1,88 @@
+{#
+ CV Education Section - collapsible education cards (accordion)
+ Data fetched from /cv/data.json via homepage plugin
+ Each card gets a distinct color via cycling palette
+#}
+
+{% set hasEducation = cv and cv.education and cv.education.length %}
+
+{% if hasEducation %}
+
+
+ {{ section.config.title or "Education" }}
+
+
+
+ {% for item in cv.education %}
+ {% if not filterType or item.educationType == filterType or not item.educationType %}
+ {% set ci = loop.index0 % 8 %}
+
+ {# Summary row — always visible, clickable #}
+
+
+
{{ item.degree }}
+
+ {{ item.institution }}{% if item.location %} · {{ item.location }}{% endif %}
+
+
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% elif item.year %}
+
{{ item.year }}
+ {% endif %}
+
+
+
+
+
+
+ {# Detail section — collapsible #}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% elif item.year %}
+
{{ item.year }}
+ {% endif %}
+
+ {% if item.description %}
+
{{ item.description }}
+ {% endif %}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-experience-personal.njk b/theme/_includes/components/sections/cv-experience-personal.njk
new file mode 100644
index 0000000..7d4c858
--- /dev/null
+++ b/theme/_includes/components/sections/cv-experience-personal.njk
@@ -0,0 +1,2 @@
+{% set filterType = "personal" %}
+{% include "components/sections/cv-experience.njk" %}
diff --git a/theme/_includes/components/sections/cv-experience-work.njk b/theme/_includes/components/sections/cv-experience-work.njk
new file mode 100644
index 0000000..57b91b3
--- /dev/null
+++ b/theme/_includes/components/sections/cv-experience-work.njk
@@ -0,0 +1,2 @@
+{% set filterType = "work" %}
+{% include "components/sections/cv-experience.njk" %}
diff --git a/theme/_includes/components/sections/cv-experience.njk b/theme/_includes/components/sections/cv-experience.njk
new file mode 100644
index 0000000..b9ad71d
--- /dev/null
+++ b/theme/_includes/components/sections/cv-experience.njk
@@ -0,0 +1,48 @@
+{#
+ CV Experience Section - work experience timeline
+ Data fetched from /cv/data.json via homepage plugin
+#}
+
+{% set sectionConfig = section.config or {} %}
+{% set maxItems = sectionConfig.maxItems or 10 %}
+{% set showHighlights = sectionConfig.showHighlights if sectionConfig.showHighlights is defined else true %}
+
+{% if cv and cv.experience and cv.experience.length %}
+
+
+ {{ sectionConfig.title or "Experience" }}
+
+
+
+ {% for item in cv.experience | head(maxItems) %}
+ {% if not filterType or item.experienceType == filterType or not item.experienceType %}
+
+
+
{{ item.title }}
+
+ {{ item.company }}{% if item.location %} · {{ item.location }}{% endif %}
+ {% if item.type %} · {{ item.type }} {% endif %}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+ {% if item.description %}
+
{{ item.description }}
+ {% endif %}
+ {% if showHighlights and item.highlights and item.highlights.length %}
+
+ {% for h in item.highlights %}
+
+ {{ h }}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-interests-personal.njk b/theme/_includes/components/sections/cv-interests-personal.njk
new file mode 100644
index 0000000..0d053a3
--- /dev/null
+++ b/theme/_includes/components/sections/cv-interests-personal.njk
@@ -0,0 +1,2 @@
+{% set filterType = "personal" %}
+{% include "components/sections/cv-interests.njk" %}
diff --git a/theme/_includes/components/sections/cv-interests-work.njk b/theme/_includes/components/sections/cv-interests-work.njk
new file mode 100644
index 0000000..7cbe270
--- /dev/null
+++ b/theme/_includes/components/sections/cv-interests-work.njk
@@ -0,0 +1,2 @@
+{% set filterType = "work" %}
+{% include "components/sections/cv-interests.njk" %}
diff --git a/theme/_includes/components/sections/cv-interests.njk b/theme/_includes/components/sections/cv-interests.njk
new file mode 100644
index 0000000..997f791
--- /dev/null
+++ b/theme/_includes/components/sections/cv-interests.njk
@@ -0,0 +1,50 @@
+{#
+ CV Interests Section - interests grouped by category
+ Data fetched from /cv/data.json via homepage plugin
+ Each family gets a distinct color via cycling palette
+#}
+
+{% if cv and cv.interests and (cv.interests | dictsort | length) %}
+
+
+ {{ section.config.title or "Interests" }}
+
+
+
+ {% for category, items in cv.interests %}
+ {% if not filterType or (cv.interestTypes and cv.interestTypes[category] == filterType) or not cv.interestTypes or not cv.interestTypes[category] %}
+ {# Cycle through 8 distinct colors per family using loop.index0 #}
+ {% set ci = loop.index0 % 8 %}
+
+
+ {{ category }}
+
+
+ {% for interest in items %}
+ {% if ci == 0 %}
+
+ {% elif ci == 1 %}
+
+ {% elif ci == 2 %}
+
+ {% elif ci == 3 %}
+
+ {% elif ci == 4 %}
+
+ {% elif ci == 5 %}
+
+ {% elif ci == 6 %}
+
+ {% elif ci == 7 %}
+
+ {% endif %}
+ {{ interest }}
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-languages.njk b/theme/_includes/components/sections/cv-languages.njk
new file mode 100644
index 0000000..438905f
--- /dev/null
+++ b/theme/_includes/components/sections/cv-languages.njk
@@ -0,0 +1,21 @@
+{#
+ CV Languages Section
+ Data fetched from /cv/data.json via homepage plugin
+#}
+
+{% if cv and cv.languages and cv.languages.length %}
+
+
+ {{ section.config.title or "Languages" }}
+
+
+
+ {% for lang in cv.languages %}
+
+ {{ lang.name }}
+ {{ lang.level }}
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-projects-personal.njk b/theme/_includes/components/sections/cv-projects-personal.njk
new file mode 100644
index 0000000..e3e6ab0
--- /dev/null
+++ b/theme/_includes/components/sections/cv-projects-personal.njk
@@ -0,0 +1,124 @@
+{#
+ CV Personal Projects Section - collapsible project cards (accordion)
+ Filters projects by projectType == "personal" (or unset)
+ 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 %}
+
+{% set personalProjects = [] %}
+{% if cv and cv.projects %}
+ {% for item in cv.projects %}
+ {% if item.projectType == "personal" or not item.projectType %}
+ {% set personalProjects = (personalProjects.push(item), personalProjects) %}
+ {% endif %}
+ {% endfor %}
+{% endif %}
+
+{% if personalProjects.length %}
+
+
+ {{ sectionConfig.title or "Personal Projects" }}
+
+
+
+ {% for item in personalProjects | head(maxItems) %}
+ {% set ci = loop.index0 % 8 %}
+
+ {# Summary row — always visible, clickable #}
+
+
+
+ {% if item.url %}
+ {{ item.name }}
+ {% else %}
+ {{ item.name }}
+ {% endif %}
+
+ {% if item.status %}
+
+ {{ item.status }}
+
+ {% endif %}
+
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+
+
+
+
+
+ {# Detail section — collapsible #}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+ {% if item.description %}
+
{{ item.description }}
+ {% endif %}
+
+ {% if showTechnologies and item.technologies and item.technologies.length %}
+
+ {% for tech in item.technologies %}
+
+ {{ tech }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-projects-work.njk b/theme/_includes/components/sections/cv-projects-work.njk
new file mode 100644
index 0000000..47d7014
--- /dev/null
+++ b/theme/_includes/components/sections/cv-projects-work.njk
@@ -0,0 +1,124 @@
+{#
+ CV Work Projects Section - collapsible project cards (accordion)
+ Filters projects by projectType == "work"
+ 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 %}
+
+{% set workProjects = [] %}
+{% if cv and cv.projects %}
+ {% for item in cv.projects %}
+ {% if item.projectType == "work" %}
+ {% set workProjects = (workProjects.push(item), workProjects) %}
+ {% endif %}
+ {% endfor %}
+{% endif %}
+
+{% if workProjects.length %}
+
+
+ {{ sectionConfig.title or "Work Projects" }}
+
+
+
+ {% for item in workProjects | head(maxItems) %}
+ {% set ci = loop.index0 % 8 %}
+
+ {# Summary row — always visible, clickable #}
+
+
+
+ {% if item.url %}
+ {{ item.name }}
+ {% else %}
+ {{ item.name }}
+ {% endif %}
+
+ {% if item.status %}
+
+ {{ item.status }}
+
+ {% endif %}
+
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+
+
+
+
+
+ {# Detail section — collapsible #}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+ {% if item.description %}
+
{{ item.description }}
+ {% endif %}
+
+ {% if showTechnologies and item.technologies and item.technologies.length %}
+
+ {% for tech in item.technologies %}
+
+ {{ tech }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-projects.njk b/theme/_includes/components/sections/cv-projects.njk
new file mode 100644
index 0000000..99cc152
--- /dev/null
+++ b/theme/_includes/components/sections/cv-projects.njk
@@ -0,0 +1,114 @@
+{#
+ 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 %}
+
+
+ {{ sectionConfig.title or "Projects" }}
+
+
+
+ {% for item in cv.projects | head(maxItems) %}
+ {% set ci = loop.index0 % 8 %}
+
+ {# Summary row — always visible, clickable #}
+
+
+
+ {% if item.url %}
+ {{ item.name }}
+ {% else %}
+ {{ item.name }}
+ {% endif %}
+
+ {% if item.status %}
+
+ {{ item.status }}
+
+ {% endif %}
+
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+
+
+
+
+
+ {# Detail section — collapsible #}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+ {% if item.description %}
+
{{ item.description }}
+ {% endif %}
+
+ {% if showTechnologies and item.technologies and item.technologies.length %}
+
+ {% for tech in item.technologies %}
+
+ {{ tech }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/cv-skills-personal.njk b/theme/_includes/components/sections/cv-skills-personal.njk
new file mode 100644
index 0000000..baa52be
--- /dev/null
+++ b/theme/_includes/components/sections/cv-skills-personal.njk
@@ -0,0 +1,2 @@
+{% set filterType = "personal" %}
+{% include "components/sections/cv-skills.njk" %}
diff --git a/theme/_includes/components/sections/cv-skills-work.njk b/theme/_includes/components/sections/cv-skills-work.njk
new file mode 100644
index 0000000..cc1332a
--- /dev/null
+++ b/theme/_includes/components/sections/cv-skills-work.njk
@@ -0,0 +1,2 @@
+{% set filterType = "work" %}
+{% include "components/sections/cv-skills.njk" %}
diff --git a/theme/_includes/components/sections/cv-skills.njk b/theme/_includes/components/sections/cv-skills.njk
new file mode 100644
index 0000000..6b55cde
--- /dev/null
+++ b/theme/_includes/components/sections/cv-skills.njk
@@ -0,0 +1,50 @@
+{#
+ CV Skills Section - skills grouped by category
+ Data fetched from /cv/data.json via homepage plugin
+ Each family gets a distinct color via cycling palette
+#}
+
+{% if cv and cv.skills and (cv.skills | dictsort | length) %}
+
+
+ {{ section.config.title or "Skills" }}
+
+
+
+ {% for category, items in cv.skills %}
+ {% if not filterType or (cv.skillTypes and cv.skillTypes[category] == filterType) or not cv.skillTypes or not cv.skillTypes[category] %}
+ {# Cycle through 8 distinct colors per family using loop.index0 #}
+ {% set ci = loop.index0 % 8 %}
+
+
+ {{ category }}
+
+
+ {% for skill in items %}
+ {% if ci == 0 %}
+
+ {% elif ci == 1 %}
+
+ {% elif ci == 2 %}
+
+ {% elif ci == 3 %}
+
+ {% elif ci == 4 %}
+
+ {% elif ci == 5 %}
+
+ {% elif ci == 6 %}
+
+ {% elif ci == 7 %}
+
+ {% endif %}
+ {{ skill }}
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/featured-posts.njk b/theme/_includes/components/sections/featured-posts.njk
new file mode 100644
index 0000000..730fdbe
--- /dev/null
+++ b/theme/_includes/components/sections/featured-posts.njk
@@ -0,0 +1,259 @@
+{#
+ Featured Posts Section - displays curated posts with `featured: true` frontmatter
+ Rendered by homepage-builder when featured-posts section is configured
+ Supports type-aware rendering for articles, notes, likes, bookmarks, reposts, replies, photos
+#}
+
+{% set sectionConfig = section.config or {} %}
+{% set maxItems = sectionConfig.maxItems or 6 %}
+{% set showSummary = sectionConfig.showSummary if sectionConfig.showSummary is defined else true %}
+
+{% if collections.featuredPosts and collections.featuredPosts.length %}
+
+
+
+
+
+ {{ sectionConfig.title or "Featured" }}
+
+
+
+ {% for post in collections.featuredPosts | head(maxItems) %}
+ {# Detect post type from frontmatter properties #}
+ {% 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 %}
+
+ {# Determine border color by post type #}
+ {% set borderClass = "" %}
+ {% 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-sky-400 dark:border-l-sky-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 %}
+
+
+
+ {% if likedUrl %}
+ {# ── Like card ── #}
+
+
+
+
+ Liked
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ likedUrl | unfurlCard | safe }}
+
+ {{ likedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif bookmarkedUrl %}
+ {# ── Bookmark card ── #}
+
+
+
+
+ Bookmarked
+
+ {{ post.date | dateDisplay }}
+
+
+ {% if post.data.title %}
+
+ {% endif %}
+ {{ bookmarkedUrl | unfurlCard | safe }}
+
+ {{ bookmarkedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif repostedUrl %}
+ {# ── Repost card ── #}
+
+
+
+
+ Reposted
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ repostedUrl | unfurlCard | safe }}
+
+ {{ repostedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif replyToUrl %}
+ {# ── Reply card ── #}
+
+
+
+
+ In reply to
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ replyToUrl | unfurlCard | safe }}
+
+ {{ replyToUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif hasPhotos %}
+ {# ── Photo card ── #}
+
+
+
+
+ Photo
+
+ {{ post.date | dateDisplay }}
+
+
+
+ {% for img in post.data.photo | head(2) %}
+ {% set photoUrl = img.url %}
+ {% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
+ {% set photoUrl = '/' + photoUrl %}
+ {% endif %}
+
+
+
+ {% endfor %}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif post.data.title %}
+ {# ── Article/Page card ── #}
+
+ {% if showSummary and post.templateContent %}
+
+ {{ post.templateContent | striptags | truncate(250) }}
+
+ {% endif %}
+
+
+ {{ post.date | dateDisplay }}
+
+ {% if post.data.postType %}
+
+ {{ post.data.postType }}
+
+ {% endif %}
+
+
+ {% else %}
+ {# ── Note card ── #}
+
+
+
+ {{ post.date | dateDisplay }}
+
+
+ {% if post.data.postType %}
+
+ {{ post.data.postType }}
+
+ {% endif %}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
+ Permalink
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% if collections.featuredPosts.length > maxItems %}
+
+ {% endif %}
+
+{% endif %}
diff --git a/theme/_includes/components/sections/hero.njk b/theme/_includes/components/sections/hero.njk
new file mode 100644
index 0000000..0366965
--- /dev/null
+++ b/theme/_includes/components/sections/hero.njk
@@ -0,0 +1,66 @@
+{#
+ Hero Section - author intro with avatar, name, title, bio
+ Rendered by homepage-builder when hero is enabled
+#}
+
+{% set heroConfig = homepageConfig.hero or {} %}
+{% 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 %}
+
+
+
+ {# Avatar #}
+ {% if heroConfig.showAvatar != false %}
+
+ {% endif %}
+
+ {# Introduction #}
+
+
+ {{ authorName }}
+
+
+ {{ authorTitle }}
+
+ {% if authorBio %}
+
+ {{ authorBio }}
+
+ {% endif %}
+ {% if siteDescription %}
+
+ {{ siteDescription }}
+ Read more →
+
+ {% endif %}
+
+ {# Social Links #}
+ {% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
+ {% if heroConfig.showSocial != false and socialLinks %}
+
+ {% endif %}
+
+
+
diff --git a/theme/_includes/components/sections/posting-activity.njk b/theme/_includes/components/sections/posting-activity.njk
new file mode 100644
index 0000000..976dba5
--- /dev/null
+++ b/theme/_includes/components/sections/posting-activity.njk
@@ -0,0 +1,24 @@
+{# Posting Activity Section — configurable post-graph contribution grid #}
+{% set sectionConfig = section.config or {} %}
+{% set graphTitle = sectionConfig.title or "Posting Activity" %}
+
+{% if collections.posts and collections.posts.length %}
+
+
+ {{ graphTitle }}
+
+ {% set graphOptions = {} %}
+ {% if sectionConfig.years and sectionConfig.years.length %}
+ {% set graphOptions = { only: sectionConfig.years } %}
+ {% elif sectionConfig.limit %}
+ {% set graphOptions = { limit: sectionConfig.limit } %}
+ {% endif %}
+ {% postGraph collections.posts, graphOptions %}
+
+ View full history
+
+
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/sections/recent-posts.njk b/theme/_includes/components/sections/recent-posts.njk
new file mode 100644
index 0000000..6c89b14
--- /dev/null
+++ b/theme/_includes/components/sections/recent-posts.njk
@@ -0,0 +1,257 @@
+{#
+ Recent Posts Section - displays latest posts from any collection
+ Rendered by homepage-builder when recent-posts section is configured
+ Supports type-aware rendering for likes, bookmarks, reposts, replies, photos
+#}
+
+{% set sectionConfig = section.config or {} %}
+{% set maxItems = sectionConfig.maxItems or 5 %}
+{% set showSummary = sectionConfig.showSummary if sectionConfig.showSummary is defined else true %}
+
+{% if collections.posts and collections.posts.length %}
+
+
+ {{ sectionConfig.title or "Recent Posts" }}
+
+
+
+ {% for post in collections.posts | head(maxItems) %}
+ {# Detect post type from frontmatter properties #}
+ {% 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 %}
+
+ {# Determine border color by post type #}
+ {% set borderClass = "" %}
+ {% 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-sky-400 dark:border-l-sky-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 %}
+
+
+
+ {% if likedUrl %}
+ {# ── Like card ── #}
+
+
+
+
+ Liked
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ likedUrl | unfurlCard | safe }}
+
+ {{ likedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif bookmarkedUrl %}
+ {# ── Bookmark card ── #}
+
+
+
+
+ Bookmarked
+
+ {{ post.date | dateDisplay }}
+
+
+ {% if post.data.title %}
+
+ {% endif %}
+ {{ bookmarkedUrl | unfurlCard | safe }}
+
+ {{ bookmarkedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif repostedUrl %}
+ {# ── Repost card ── #}
+
+
+
+
+ Reposted
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ repostedUrl | unfurlCard | safe }}
+
+ {{ repostedUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif replyToUrl %}
+ {# ── Reply card ── #}
+
+
+
+
+ In reply to
+
+ {{ post.date | dateDisplay }}
+
+
+ {{ replyToUrl | unfurlCard | safe }}
+
+ {{ replyToUrl }}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif hasPhotos %}
+ {# ── Photo card ── #}
+
+
+
+
+ Photo
+
+ {{ post.date | dateDisplay }}
+
+
+
+ {% for img in post.data.photo | head(2) %}
+ {% set photoUrl = img.url %}
+ {% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
+ {% set photoUrl = '/' + photoUrl %}
+ {% endif %}
+
+
+
+ {% endfor %}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
Permalink
+
+
+
+ {% elif post.data.title %}
+ {# ── Article card ── #}
+
+ {% if showSummary and post.templateContent %}
+
+ {{ post.templateContent | striptags | truncate(250) }}
+
+ {% endif %}
+
+
+ {{ post.date | dateDisplay }}
+
+ {% if post.data.postType %}
+
+ {{ post.data.postType }}
+
+ {% endif %}
+
+
+ {% else %}
+ {# ── Note card ── #}
+
+
+
+ {{ post.date | dateDisplay }}
+
+
+ {% if post.data.postType %}
+
+ {{ post.data.postType }}
+
+ {% endif %}
+
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
+ Permalink
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% if sectionConfig.showViewAll != false %}
+
+ {{ sectionConfig.viewAllText or "View all posts" }}
+
+
+
+
+ {% endif %}
+
+{% endif %}
diff --git a/theme/_includes/components/sidebar.njk b/theme/_includes/components/sidebar.njk
new file mode 100644
index 0000000..a6378eb
--- /dev/null
+++ b/theme/_includes/components/sidebar.njk
@@ -0,0 +1,280 @@
+{# Sidebar — for blog listing pages (/blog/, /notes/, /articles/...) #}
+{# Data-driven when homepageConfig.blogListingSidebar is configured, otherwise falls back to default widgets #}
+{# Each widget is wrapped in a collapsible container with localStorage persistence #}
+{% from "components/icon.njk" import icon %}
+
+{% if homepageConfig and homepageConfig.blogListingSidebar and homepageConfig.blogListingSidebar.length %}
+ {# === Data-driven mode: render configured widgets === #}
+ {% for widget in homepageConfig.blogListingSidebar %}
+
+ {# 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 == "author-card-compact" %}{% set widgetTitle = "Author" %}
+ {% elif widget.type == "subscribe" %}{% set widgetTitle = "Subscribe" %}
+ {% 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 %}
+
+ {# Resolve widget icon and accent border #}
+ {% if widget.type == "social-activity" %}
+ {% set widgetIcon = "globe" %}{% set widgetIconClass = "w-5 h-5 text-[#0085ff]" %}{% set widgetBorder = "border-l-[3px] border-l-[#0085ff]" %}
+ {% elif widget.type == "github-repos" %}
+ {% set widgetIcon = "github" %}{% set widgetIconClass = "w-5 h-5 text-surface-800 dark:text-surface-200" %}{% set widgetBorder = "border-l-[3px] border-l-surface-400 dark:border-l-surface-500" %}
+ {% elif widget.type == "funkwhale" %}
+ {% set widgetIcon = "headphones" %}{% set widgetIconClass = "w-5 h-5 text-purple-500" %}{% set widgetBorder = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
+ {% elif widget.type == "blogroll" %}
+ {% set widgetIcon = "book-open" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "feedland" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-amber-500" %}{% set widgetBorder = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif widget.type == "subscribe" %}
+ {% set widgetIcon = "rss" %}{% set widgetIconClass = "w-5 h-5 text-orange-500" %}{% set widgetBorder = "border-l-[3px] border-l-orange-400 dark:border-l-orange-500" %}
+ {% elif widget.type == "fediverse-follow" %}
+ {% set widgetIcon = "user-plus" %}{% set widgetIconClass = "w-5 h-5 text-[#a730b8]" %}{% set widgetBorder = "border-l-[3px] border-l-[#a730b8]" %}
+ {% elif widget.type == "author-card" or widget.type == "author-card-compact" %}
+ {% set widgetIcon = "user" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-posts" %}
+ {% set widgetIcon = "list" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "categories" %}
+ {% set widgetIcon = "tag" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "recent-comments" %}
+ {% set widgetIcon = "chat" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "search" %}
+ {% set widgetIcon = "search" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% elif widget.type == "webmentions" %}
+ {% set widgetIcon = "share" %}{% set widgetIconClass = "w-5 h-5 text-surface-500" %}{% set widgetBorder = "" %}
+ {% else %}
+ {% set widgetIcon = "" %}{% set widgetIconClass = "" %}{% set widgetBorder = "" %}
+ {% endif %}
+
+ {% set widgetKey = "listing-widget-" + widget.type + "-" + loop.index0 %}
+ {% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
+
+ {# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
+
+
+ {% endfor %}
+{% else %}
+ {# === Fallback: current hardcoded sidebar (backward compatibility) === #}
+ {# Each widget wrapped in collapsible container #}
+
+ {# Author Card (h-card) — always shown #}
+ {% set widgetKey = "listing-fb-author-card" %}
+
+
+ {# Social Activity — Bluesky/Mastodon feeds #}
+ {% set widgetKey = "listing-fb-social-activity" %}
+
+
+ {# GitHub Repos #}
+ {% set widgetKey = "listing-fb-github-repos" %}
+
+
+ {# Funkwhale — Now Playing / Listening Stats #}
+ {% set widgetKey = "listing-fb-funkwhale" %}
+
+
+ {# Recent Posts #}
+ {% set widgetKey = "listing-fb-recent-posts" %}
+
+
+ {# Blogroll — only when backend is available #}
+ {% if blogrollStatus and blogrollStatus.source == "indiekit" %}
+ {% set widgetKey = "listing-fb-blogroll" %}
+
+ {% endif %}
+
+ {# FeedLand — only when backend is available #}
+ {% if blogrollStatus and blogrollStatus.source == "indiekit" %}
+ {% set widgetKey = "listing-fb-feedland" %}
+
+ {% endif %}
+
+ {# Recent Comments #}
+ {% set widgetKey = "listing-fb-recent-comments" %}
+
+
+ {# Categories/Tags #}
+ {% set widgetKey = "listing-fb-categories" %}
+
+{% endif %}
diff --git a/theme/_includes/components/social-icon.njk b/theme/_includes/components/social-icon.njk
new file mode 100644
index 0000000..54638b2
--- /dev/null
+++ b/theme/_includes/components/social-icon.njk
@@ -0,0 +1,131 @@
+{#
+ Social Icon Macro
+ Usage: {% from "components/social-icon.njk" import socialIcon, socialIconColorClass %}
+ {{ socialIcon("github", "w-5 h-5") }}
+ {{ socialIcon("github", "w-5 h-5") }}
+
+ SVG paths sourced from Simple Icons (simpleicons.org) - CC0 1.0 Universal
+ All icons render at 24x24 viewBox with fill="currentColor"
+ Brand colors from official brand guidelines
+#}
+
+{# Returns Tailwind color classes for an icon's brand color (light + dark) #}
+{% macro socialIconColorClass(name) %}
+{%- if name == "activitypub" -%}text-[#f1027e]
+{%- elif name == "github" -%}text-[#181717] dark:text-[#e6edf3]
+{%- elif name == "gitlab" -%}text-[#FC6D26]
+{%- elif name == "forgejo" -%}text-[#609926]
+{%- elif name == "codeberg" -%}text-[#2185D0]
+{%- elif name == "mastodon" -%}text-[#6364FF]
+{%- elif name == "bluesky" -%}text-[#0085FF]
+{%- elif name == "pixelfed" -%}text-[#6C42C9]
+{%- elif name == "linkedin" -%}text-[#0A66C2]
+{%- elif name == "twitter" -%}text-[#000000] dark:text-[#e7e9ea]
+{%- elif name == "threads" -%}text-[#000000] dark:text-[#f5f5f5]
+{%- elif name == "youtube" -%}text-[#FF0000]
+{%- elif name == "twitch" -%}text-[#9146FF]
+{%- elif name == "spotify" -%}text-[#1DB954]
+{%- elif name == "bandcamp" -%}text-[#629aa9]
+{%- elif name == "soundcloud" -%}text-[#FF5500]
+{%- elif name == "rss" -%}text-[#F26522]
+{%- elif name == "discord" -%}text-[#5865F2]
+{%- elif name == "signal" -%}text-[#3A76F0]
+{%- elif name == "telegram" -%}text-[#26A5E4]
+{%- elif name == "matrix" -%}text-[#000000] dark:text-[#e6e6e6]
+{%- elif name == "reddit" -%}text-[#FF4500]
+{%- elif name == "hackernews" -%}text-[#FF6600]
+{%- elif name == "funkwhale" -%}text-[#0D47A1]
+{%- elif name == "lastfm" -%}text-[#D51007]
+{%- elif name == "peertube" -%}text-[#F1680D]
+{%- elif name == "bookwyrm" -%}text-[#002200] dark:text-[#78b578]
+{%- elif name == "indieweb" -%}text-[#FF5C00]
+{%- elif name == "email" -%}text-surface-600 dark:text-surface-400
+{%- elif name == "website" -%}text-surface-600 dark:text-surface-400
+{%- elif name == "keybase" -%}text-[#33A0FF]
+{%- elif name == "orcid" -%}text-[#A6CE39]
+{%- elif name == "flickr" -%}text-[#0063DC]
+{%- elif name == "xmpp" -%}text-[#002B5C] dark:text-[#5badff]
+{%- elif name == "sourcehut" -%}text-[#000000] dark:text-[#e0e0e0]
+{%- elif name == "facebook" -%}text-[#0866FF]
+{%- elif name == "instagram" -%}text-[#E4405F]
+{%- else -%}text-surface-600 dark:text-surface-400
+{%- endif -%}
+{% endmacro %}
+
+{% macro socialIcon(name, cssClass) %}
+{%- if name == "github" -%}
+
+{%- elif name == "gitlab" -%}
+
+{%- elif name == "forgejo" -%}
+
+{%- elif name == "codeberg" -%}
+
+{%- elif name == "sourcehut" -%}
+
+{%- elif name == "linkedin" -%}
+
+{%- elif name == "bluesky" -%}
+
+{%- elif name == "mastodon" -%}
+
+{%- elif name == "activitypub" -%}
+
+{%- elif name == "pixelfed" -%}
+
+{%- elif name == "twitter" -%}
+
+{%- elif name == "facebook" -%}
+
+{%- elif name == "instagram" -%}
+
+{%- elif name == "threads" -%}
+
+{%- elif name == "youtube" -%}
+
+{%- elif name == "twitch" -%}
+
+{%- elif name == "flickr" -%}
+
+{%- elif name == "spotify" -%}
+
+{%- elif name == "bandcamp" -%}
+
+{%- elif name == "soundcloud" -%}
+
+{%- elif name == "rss" -%}
+
+{%- elif name == "matrix" -%}
+
+{%- elif name == "discord" -%}
+
+{%- elif name == "signal" -%}
+
+{%- elif name == "telegram" -%}
+
+{%- elif name == "xmpp" -%}
+
+{%- elif name == "reddit" -%}
+
+{%- elif name == "hackernews" -%}
+
+{%- elif name == "keybase" -%}
+
+{%- elif name == "orcid" -%}
+
+{%- elif name == "indieweb" -%}
+
+{%- elif name == "website" -%}
+
+{%- elif name == "email" -%}
+
+{%- elif name == "funkwhale" -%}
+
+{%- elif name == "lastfm" -%}
+
+{%- elif name == "peertube" -%}
+
+{%- elif name == "bookwyrm" -%}
+
+{%- endif -%}
+{% endmacro %}
diff --git a/theme/_includes/components/webmentions.njk b/theme/_includes/components/webmentions.njk
new file mode 100644
index 0000000..971984b
--- /dev/null
+++ b/theme/_includes/components/webmentions.njk
@@ -0,0 +1,206 @@
+{# Webmentions Component #}
+{# Displays likes, reposts, and replies for a post #}
+{# Also checks legacy URLs from micro.blog and old blog for historical webmentions #}
+{# Client-side JS supplements build-time data with real-time fetches #}
+
+{% set mentions = webmentions | webmentionsForUrl(page.url, urlAliases, conversationMentions) %}
+{% set absoluteUrl = site.url + page.url %}
+{% set buildTimestamp = "" | timestamp %}
+
+{# Data container for client-side JS to fetch new webmentions #}
+
+
+{% if mentions.length %}
+
+
+ Webmentions ({{ mentions.length }})
+
+
+ {# Likes #}
+ {% set likes = mentions | webmentionsByType('likes') %}
+ {% if likes.length %}
+
+
+ {{ likes.length }} Like{% if likes.length != 1 %}s{% endif %}
+
+
+
+ {% for like in likes %}
+
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# Reposts #}
+ {% set reposts = mentions | webmentionsByType('reposts') %}
+ {% if reposts.length %}
+
+
+ {{ reposts.length }} Repost{% if reposts.length != 1 %}s{% endif %}
+
+
+
+ {% for repost in reposts %}
+
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# Bookmarks #}
+ {% set bookmarks = mentions | webmentionsByType('bookmarks') %}
+ {% if bookmarks.length %}
+
+
+ {{ bookmarks.length }} Bookmark{% if bookmarks.length != 1 %}s{% endif %}
+
+
+
+ {% for bookmark in bookmarks %}
+
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# Replies #}
+ {% set replies = mentions | webmentionsByType('replies') %}
+ {% if replies.length %}
+
+
+ {{ replies.length }} Repl{% if replies.length != 1 %}ies{% else %}y{% endif %}
+
+
+ {% for reply in replies %}
+
+
+
+
+
+
+
+
+ {{ reply.content.html | safe if reply.content.html else reply.content.text }}
+
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {# Other mentions #}
+ {% set otherMentions = mentions | webmentionsByType('mentions') %}
+ {% if otherMentions.length %}
+
+
+ {{ otherMentions.length }} Mention{% if otherMentions.length != 1 %}s{% endif %}
+
+
+
+ {% endif %}
+
+{% endif %}
+
+{# Webmention send form — collapsed by default #}
+
+
+
+
+
+ Send a Webmention
+
+
+
+ Have you written a response to this post? Send a webmention by entering your post URL below.
+
+
+
+
diff --git a/theme/_includes/components/widgets/author-card-compact.njk b/theme/_includes/components/widgets/author-card-compact.njk
new file mode 100644
index 0000000..b139b66
--- /dev/null
+++ b/theme/_includes/components/widgets/author-card-compact.njk
@@ -0,0 +1,30 @@
+{# Author Compact Card - h-card microformat (compact version for blog sidebars) #}
+
+
+
diff --git a/theme/_includes/components/widgets/author-card.njk b/theme/_includes/components/widgets/author-card.njk
new file mode 100644
index 0000000..f205d58
--- /dev/null
+++ b/theme/_includes/components/widgets/author-card.njk
@@ -0,0 +1,6 @@
+{# Author Card Widget - includes the canonical h-card component #}
+
+
+ {% include "components/h-card.njk" %}
+
+
diff --git a/theme/_includes/components/widgets/blogroll.njk b/theme/_includes/components/widgets/blogroll.njk
new file mode 100644
index 0000000..572cff0
--- /dev/null
+++ b/theme/_includes/components/widgets/blogroll.njk
@@ -0,0 +1,110 @@
+{# Blogroll Widget - Dynamic loading from API with source tabs #}
+
+
+
+
+
diff --git a/theme/_includes/components/widgets/categories.njk b/theme/_includes/components/widgets/categories.njk
new file mode 100644
index 0000000..b071191
--- /dev/null
+++ b/theme/_includes/components/widgets/categories.njk
@@ -0,0 +1,15 @@
+{# Categories/Tags Widget #}
+{% if categories and categories.length %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/fediverse-follow.njk b/theme/_includes/components/widgets/fediverse-follow.njk
new file mode 100644
index 0000000..10d081f
--- /dev/null
+++ b/theme/_includes/components/widgets/fediverse-follow.njk
@@ -0,0 +1,38 @@
+{# Fediverse Follow Me Widget — uses the fediverseInteract Alpine.js component #}
+{# Requires fediverse-interact.js loaded in base.njk (already present) #}
+{# Determines actor URI from site social links: prefers self-hosted AP, falls back to Mastodon #}
+
+{% set actorUrl = "" %}
+{% for link in site.social %}
+ {% if link.icon == "activitypub" and not actorUrl %}
+ {% set actorUrl = link.url %}
+ {% endif %}
+{% endfor %}
+{% if not actorUrl %}
+ {% for link in site.social %}
+ {% if link.icon == "mastodon" and not actorUrl %}
+ {% set actorUrl = link.url %}
+ {% endif %}
+ {% endfor %}
+{% endif %}
+
+{% if actorUrl %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/feedland.njk b/theme/_includes/components/widgets/feedland.njk
new file mode 100644
index 0000000..5a4b1c3
--- /dev/null
+++ b/theme/_includes/components/widgets/feedland.njk
@@ -0,0 +1,383 @@
+{# FeedLand Widget - Matches Dave Winer's blogroll.js visual rendering #}
+{# Uses Alpine.js + blogroll API instead of jQuery + external blogroll.js #}
+
+
+
+
+
+
+
+
+
+
diff --git a/theme/_includes/components/widgets/funkwhale.njk b/theme/_includes/components/widgets/funkwhale.njk
new file mode 100644
index 0000000..0f9bbac
--- /dev/null
+++ b/theme/_includes/components/widgets/funkwhale.njk
@@ -0,0 +1,115 @@
+{# Listening Widget — combined Funkwhale + Last.fm recent tracks #}
+{% set hasListening = (funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.listenings.length)) or (lastfmActivity and (lastfmActivity.nowPlaying or lastfmActivity.scrobbles.length)) %}
+{% if hasListening %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/github-repos.njk b/theme/_includes/components/widgets/github-repos.njk
new file mode 100644
index 0000000..ee27453
--- /dev/null
+++ b/theme/_includes/components/widgets/github-repos.njk
@@ -0,0 +1,213 @@
+{# GitHub Activity Widget - Tabbed Commits/Repos/Featured/PRs with live API data #}
+
+
+
+
+
diff --git a/theme/_includes/components/widgets/post-categories.njk b/theme/_includes/components/widgets/post-categories.njk
new file mode 100644
index 0000000..0d015d5
--- /dev/null
+++ b/theme/_includes/components/widgets/post-categories.njk
@@ -0,0 +1,21 @@
+{# Categories for This Post #}
+{% if category %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/post-navigation.njk b/theme/_includes/components/widgets/post-navigation.njk
new file mode 100644
index 0000000..f0b0155
--- /dev/null
+++ b/theme/_includes/components/widgets/post-navigation.njk
@@ -0,0 +1,66 @@
+{# Post Navigation Widget - Previous/Next #}
+{# Uses previousInCollection/nextInCollection filters to find adjacent posts #}
+{% set _prevPost = collections.posts | previousInCollection(page) %}
+{% set _nextPost = collections.posts | nextInCollection(page) %}
+
+{% if _prevPost or _nextPost %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/recent-comments.njk b/theme/_includes/components/widgets/recent-comments.njk
new file mode 100644
index 0000000..9e0b78a
--- /dev/null
+++ b/theme/_includes/components/widgets/recent-comments.njk
@@ -0,0 +1,27 @@
+{# Recent Comments Widget — sidebar #}
+{% if recentComments and recentComments.length %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/recent-posts-blog.njk b/theme/_includes/components/widgets/recent-posts-blog.njk
new file mode 100644
index 0000000..af6e224
--- /dev/null
+++ b/theme/_includes/components/widgets/recent-posts-blog.njk
@@ -0,0 +1,85 @@
+{# Recent Posts Widget — type-aware, for blog/post sidebars #}
+{# Uses collections.posts directly (all post types, not just recentPosts collection) #}
+{% if collections.posts %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/recent-posts.njk b/theme/_includes/components/widgets/recent-posts.njk
new file mode 100644
index 0000000..7fef78d
--- /dev/null
+++ b/theme/_includes/components/widgets/recent-posts.njk
@@ -0,0 +1,93 @@
+{# Recent Posts Widget (sidebar) - compact type-aware list #}
+{% set recentPosts = recentPosts or collections.recentPosts %}
+{% if recentPosts and recentPosts.length %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/search.njk b/theme/_includes/components/widgets/search.njk
new file mode 100644
index 0000000..c547241
--- /dev/null
+++ b/theme/_includes/components/widgets/search.njk
@@ -0,0 +1,10 @@
+{# Search Widget — redirects to /search/?q=query #}
+
diff --git a/theme/_includes/components/widgets/share.njk b/theme/_includes/components/widgets/share.njk
new file mode 100644
index 0000000..0c8d9b1
--- /dev/null
+++ b/theme/_includes/components/widgets/share.njk
@@ -0,0 +1,31 @@
+{# Share Widget #}
+{% set shareText = title + " " + site.url + page.url %}
+
+
+
diff --git a/theme/_includes/components/widgets/social-activity.njk b/theme/_includes/components/widgets/social-activity.njk
new file mode 100644
index 0000000..21910a5
--- /dev/null
+++ b/theme/_includes/components/widgets/social-activity.njk
@@ -0,0 +1,96 @@
+{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
+{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/subscribe.njk b/theme/_includes/components/widgets/subscribe.njk
new file mode 100644
index 0000000..61f4005
--- /dev/null
+++ b/theme/_includes/components/widgets/subscribe.njk
@@ -0,0 +1,20 @@
+{# Subscribe Widget #}
+
+
+
diff --git a/theme/_includes/components/widgets/toc.njk b/theme/_includes/components/widgets/toc.njk
new file mode 100644
index 0000000..19a86d3
--- /dev/null
+++ b/theme/_includes/components/widgets/toc.njk
@@ -0,0 +1,19 @@
+{# Table of Contents Widget (for articles with headings) #}
+{% if toc and toc.length %}
+
+
+
+{% endif %}
diff --git a/theme/_includes/components/widgets/webmentions.njk b/theme/_includes/components/widgets/webmentions.njk
new file mode 100644
index 0000000..ae51b4d
--- /dev/null
+++ b/theme/_includes/components/widgets/webmentions.njk
@@ -0,0 +1,168 @@
+{# Recent Webmentions Widget - site-wide inbound/outbound activity #}
+{# Uses client-side fetch from /webmentions/api/mentions (same as /interactions page) #}
+{# Outbound tab uses Eleventy collections (likes, replies, bookmarks, reposts) #}
+
+
+
+
+
diff --git a/theme/_includes/layouts/base.njk b/theme/_includes/layouts/base.njk
new file mode 100644
index 0000000..cf5c196
--- /dev/null
+++ b/theme/_includes/layouts/base.njk
@@ -0,0 +1,549 @@
+
+
+
+ {# OG image resolution handled by og-fix transform in eleventy.config.js
+ to bypass Eleventy 3.x parallel rendering race condition (#3183).
+ Template outputs __OG_IMAGE_PLACEHOLDER__ and __TWITTER_CARD_PLACEHOLDER__
+ which the transform replaces using the correct slug derived from outputPath. #}
+
+
+
+ {% if title %}{{ title }} - {% endif %}{{ site.name }}
+
+ {# OpenGraph meta tags #}
+ {% set ogTitle = title | default(site.name) %}
+ {% set ogDesc = description | default(content | ogDescription(200)) | default(site.description) %}
+ {# Normalize photo - could be array for multi-photo posts #}
+ {% set ogPhoto = photo %}
+ {% if ogPhoto %}
+ {% if ogPhoto[0] and (ogPhoto[0] | length) > 10 %}
+ {% set ogPhoto = ogPhoto[0] %}
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
+ {% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
+
+ {% elif image and image != "" and (image | length) > 10 %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+ {# Twitter Card meta tags #}
+ {% set hasExplicitImage = (ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10) or (image and image != "" and (image | length) > 10) %}
+
+
+
+ {% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
+
+ {% elif image and image != "" and (image | length) > 10 %}
+
+ {% else %}
+
+ {% endif %}
+
+ {# Favicon #}
+
+
+
+ {# Critical CSS — inlined for fast first paint #}
+
+ {# Defer full stylesheet — loads after first paint #}
+
+
+
+
+
+
+
+
+
+ {# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
+
+
+
+
+
+
+ {# Graceful no-JS fallback: show content that Alpine would normally control #}
+
+
+
+
+
+
+
+ {% if site.markdownAgents.enabled and page.url and page.url.startsWith('/articles/') and page.url != '/articles/' %}
+
+ {% endif %}
+ {% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {# Fediverse creator meta tag for Mastodon verification #}
+ {% if site.fediverseCreator %}
+
+ {% endif %}
+
+ {# IndieAuth rel="me" links for identity verification #}
+ {# Note: Bluesky links use "me atproto" for verification #}
+ {% for social in site.social %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% if withSidebar and page.url == "/" and homepageConfig and homepageConfig.sections %}
+ {# Homepage: builder controls its own layout and sidebar #}
+ {{ content | safe }}
+ {% elif withSidebar %}
+
+ {% elif withBlogSidebar %}
+
+ {% else %}
+ {{ content | safe }}
+ {% endif %}
+
+
+
+
+ {# Island architecture - lazy hydration for widgets #}
+
+ {# Relative date display - progressively enhances elements #}
+
+ {# Responsive tables - auto-enhances on narrow screens #}
+
+ {# Client-side filtering for archive pages #}
+
+ {# Client-side webmention fetcher - supplements build-time cache with real-time data #}
+
+ {# Admin auth detection - shows dashboard link + FAB when logged in #}
+
+ {# Save for Later buttons — active when logged in #}
+
+ {# Share Post buttons — opens share form popup when logged in #}
+
+
+ {# Floating Action Button - visible only when logged in #}
+
+ {# Backdrop #}
+
+ {# Menu items #}
+
+ {# FAB button #}
+
+
+
+
+
+
+ {# Pagefind — load at end of body so all DOM elements exist, then process queue #}
+
+
+
+
diff --git a/theme/_includes/layouts/fullwidth.njk b/theme/_includes/layouts/fullwidth.njk
new file mode 100644
index 0000000..7a84d59
--- /dev/null
+++ b/theme/_includes/layouts/fullwidth.njk
@@ -0,0 +1,25 @@
+---
+layout: layouts/base.njk
+---
+{# Full-width layout for rich HTML pages (interactive guides, architecture diagrams, etc.)
+ Inherits site header + footer from base.njk but renders content at full container width
+ with no sidebar, no post metadata, and no prose constraints. #}
+
+
+ {% if title %}
+
+
+ {{ title }}
+
+ {% if description %}
+
+ {{ description }}
+
+ {% endif %}
+
+ {% endif %}
+
+
+ {{ content | safe }}
+
+
diff --git a/theme/_includes/layouts/home.njk b/theme/_includes/layouts/home.njk
new file mode 100644
index 0000000..0f26556
--- /dev/null
+++ b/theme/_includes/layouts/home.njk
@@ -0,0 +1,157 @@
+---
+layout: layouts/base.njk
+withSidebar: true
+---
+
+{# Homepage content — two-tier fallback: #}
+{# 1. Plugin config (homepageConfig) — homepage builder controls everything #}
+{# 2. Default — show recent posts with default hero #}
+
+{# Default hero — only shown for Tier 2 (plugin controls its own hero) #}
+{% if not (homepageConfig and homepageConfig.sections) %}
+
+
+ {# Avatar #}
+
+
+ {# Introduction #}
+
+
+ {{ site.author.name }}
+
+
+ {{ site.author.title }}
+
+ {% if site.author.bio %}
+
+ {{ site.author.bio }}
+
+ {% endif %}
+ {% if site.description %}
+
+ {{ site.description }}
+ Read more →
+
+ {% endif %}
+
+ {# Social Links #}
+
+
+
+
+{% endif %}
+
+{# --- Tier 1: Plugin-driven layout --- #}
+{% if homepageConfig and homepageConfig.sections %}
+ {% include "components/homepage-builder.njk" %}
+
+{# --- Tier 2: Default — recent posts and explore links --- #}
+{% else %}
+
+{# Recent Posts #}
+{% if collections.posts and collections.posts.length %}
+
+ Recent Posts
+
+ {% for post in collections.posts | head(10) %}
+
+
+ {% if post.data.summary %}
+ {{ post.data.summary }}
+ {% endif %}
+
+
+ {{ (post.data.published or post.date) | date("MMM d, yyyy") }}
+
+ {% if post.data.postType %}
+ {{ post.data.postType }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ View all posts
+
+
+
+{% endif %}
+
+{# Explore — quick links to key sections #}
+
+
+{# Posting Activity — contribution graph (Tier 2 default only) #}
+{% if collections.posts and collections.posts.length %}
+
+{% endif %}
+
+{% endif %} {# end two-tier fallback #}
diff --git a/theme/_includes/layouts/page.njk b/theme/_includes/layouts/page.njk
new file mode 100644
index 0000000..5dc14a6
--- /dev/null
+++ b/theme/_includes/layouts/page.njk
@@ -0,0 +1,135 @@
+---
+layout: layouts/base.njk
+withSidebar: true
+---
+{# Layout for slash pages (/about, /now, /uses, etc.) #}
+{# These are root-level pages created via Indiekit's page post type #}
+
+
+
+
+
+ {{ content | safe }}
+
+
+ {# AI post-graph — shown only on the /ai/ page #}
+ {% if page.url == "/ai/" and collections.posts %}
+ {% set stats = collections.posts | aiStats %}
+ {% set aiPostsList = collections.posts | aiPosts %}
+
+ AI Usage Across Posts
+
+
+
{{ stats.total }}
+
Total posts
+
+
+
{{ stats.aiCount }}
+
AI-involved
+
+
+
{{ stats.total - stats.aiCount }}
+
Human-only
+
+
+
{{ stats.percentage }}%
+
AI ratio
+
+
+
+ {# Breakdown by level #}
+
+
+ Level 0 (None): {{ stats.byLevel[0] }}
+
+
+ Level 1 (Editorial): {{ stats.byLevel[1] }}
+
+
+ Level 2 (Co-drafted): {{ stats.byLevel[2] }}
+
+
+ Level 3 (AI-generated): {{ stats.byLevel[3] }}
+
+
+
+ {# Post graph showing AI posts (highlighted) on the full year grid #}
+ AI-Involved Posts Over Time
+ Highlighted days had posts with AI involvement (level 1+). Empty boxes represent days with no AI-involved posts.
+ {% postGraph aiPostsList, { prefix: "ai", highlightColorLight: "#d97706", highlightColorDark: "#fbbf24" } %}
+
+ {% endif %}
+
+ {# AI usage disclosure #}
+ {% set aiTextLevel = aiTextLevel or ai_text_level %}
+ {% set aiCodeLevel = aiCodeLevel or ai_code_level %}
+ {% set aiTools = aiTools or ai_tools %}
+ {% set aiDescription = aiDescription or ai_description %}
+ {% if aiTextLevel or aiCodeLevel or aiTools %}
+
+
+
+ {% if aiTextLevel %}
+
+ Text: {% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial{% elif aiTextLevel === "2" %}Co-drafted{% elif aiTextLevel === "3" %}AI-generated{% endif %}
+
+ {% endif %}
+ {% if aiCodeLevel %}
+
+ Code: {% if aiCodeLevel === "0" %}Human{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}
+
+ {% endif %}
+ {% if aiTools %}
+
+ Tools: {{ aiTools }}
+
+ {% endif %}
+
+ {% if aiDescription %}
+ {{ aiDescription }}
+ {% endif %}
+
+ {% endif %}
+
+ {# Categories/tags if present #}
+ {% if category %}
+
+ {% endif %}
+
+ {# Hidden metadata for microformats #}
+
+
+
diff --git a/theme/_includes/layouts/post.njk b/theme/_includes/layouts/post.njk
new file mode 100644
index 0000000..babf064
--- /dev/null
+++ b/theme/_includes/layouts/post.njk
@@ -0,0 +1,263 @@
+---
+layout: layouts/base.njk
+withBlogSidebar: true
+---
+
+ {# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
+ {% set bookmarkedUrl = bookmarkOf or bookmark_of %}
+ {% set likedUrl = likeOf or like_of %}
+ {% set replyTo = inReplyTo or in_reply_to %}
+ {% set repostedUrl = repostOf or repost_of %}
+
+ {% if title %}
+ {{ title }}
+ {% else %}
+
+
+ {% if replyTo %}↩ Reply{% elif likedUrl %}♥ Like{% elif repostedUrl %}♻ Repost{% elif bookmarkedUrl %}🔖 Bookmark{% else %}✎ Note{% endif %}
+
+
+ {% endif %}
+
+
+
+ {{ date | dateDisplay }}
+
+ {% if category %}
+
+ {# Handle both string and array categories #}
+ {% if category is string %}
+ {{ category }}
+ {% else %}
+ {% for cat in category %}
+ {{ cat }}
+ {% endfor %}
+ {% endif %}
+
+ {% endif %}
+
+
+ {# Bridgy syndication content - controls what gets posted to social networks #}
+ {# For interaction types (bookmarks, likes, replies, reposts), include the target URL #}
+ {% set bridgySummary = description or summary or (content | ogDescription(280)) %}
+ {% set interactionUrl = bookmarkedUrl or likedUrl or replyTo or repostedUrl %}
+
+ {% if bridgySummary or interactionUrl %}
+ {% if bookmarkedUrl %}🔖 {{ bookmarkedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif likedUrl %}❤️ {{ likedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif replyTo %}↩️ {{ replyTo }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif repostedUrl %}🔁 {{ repostedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% else %}{{ bridgySummary }}{% endif %}
+ {% endif %}
+
+ {# Render photo(s) from frontmatter for photo posts - use eleventy:ignore to skip image transform #}
+ {% if photo %}
+
+ {% for img in photo %}
+ {% set photoUrl = img.url %}
+ {% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
+ {% set photoUrl = '/' + photoUrl %}
+ {% endif %}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% set isInteraction = replyTo or likedUrl or repostedUrl or bookmarkedUrl %}
+ {% set hasContent = content and content | striptags | trim %}
+
+ {{ content | safe }}
+
+
+ {# Rich reply context with h-cite microformat #}
+ {% include "components/reply-context.njk" %}
+
+ {# AI usage disclosure — always shown, collapsed by default, placed after reply context #}
+ {% set aiTextLevel = aiTextLevel or ai_text_level or "0" %}
+ {% set aiCodeLevel = aiCodeLevel or ai_code_level %}
+ {% set aiTools = aiTools or ai_tools %}
+ {% set aiDescription = aiDescription or ai_description %}
+
+
+
+
+
+ AI:
+ Text {% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial{% elif aiTextLevel === "2" %}Co-drafted{% elif aiTextLevel === "3" %}AI-generated{% endif %}{% if aiCodeLevel %} · Code {% if aiCodeLevel === "0" %}Human{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}{% endif %}{% if aiTools %} · {{ aiTools }}{% endif %}
+
+
+ {% if aiDescription %}
+ {{ aiDescription }}
+ {% endif %}
+
+
+ {# Pending syndication targets (for services like IndieNews that require u-syndication before webmention) #}
+ {% if mpSyndicateTo %}
+
+ {% for url in mpSyndicateTo %}
+ {% if "news.indieweb.org" in url %}
+
IndieNews
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+ {# Syndication Footer - shows where this post was also published #}
+ {# Separate self-hosted AP URLs from external syndication targets #}
+ {% set externalSyndication = [] %}
+ {% set selfHostedApUrl = "" %}
+ {% if syndication %}
+ {% for url in syndication %}
+ {% if url.indexOf(site.url) == 0 %}
+ {% set selfHostedApUrl = url %}
+ {% else %}
+ {% set externalSyndication = (externalSyndication.push(url), externalSyndication) %}
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+ {% if externalSyndication.length or selfHostedApUrl %}
+
+ {% endif %}
+
+ Permalink
+
+ {# Author h-card for IndieWeb authorship #}
+
+ {{ site.author.name }}
+
+
+
+ {# JSON-LD Structured Data for SEO #}
+ {# Handle photo as potentially an array #}
+ {% set postImage = photo %}
+ {% if postImage %}
+ {# If photo is an array, use first element (check if first element looks like a URL) #}
+ {% if postImage[0] and (postImage[0] | length) > 10 %}
+ {% set postImage = postImage[0] %}
+ {% endif %}
+ {% endif %}
+ {% if not postImage or postImage == "" %}
+ {% set postImage = image or (content | extractFirstImage) %}
+ {% endif %}
+ {% set postDesc = description | default(content | ogDescription(160)) %}
+
+
+ {# Lightbox overlay for article images #}
+
+
+
×
+
+ ‹
+
+
+ ›
+
+
+
+
+
+
+
+{# Comments section #}
+{% include "components/comments.njk" %}
+
+{# Webmentions display - likes, reposts, replies #}
+{% include "components/webmentions.njk" %}
+
+{# Post Navigation - Previous/Next #}
+{% include "components/post-navigation.njk" %}
diff --git a/theme/about.njk b/theme/about.njk
new file mode 100644
index 0000000..5cd5a40
--- /dev/null
+++ b/theme/about.njk
@@ -0,0 +1,69 @@
+---
+layout: layouts/base.njk
+title: About
+permalink: false
+eleventyExcludeFromCollections: true
+---
+
+
+
+
+
{{ site.author.bio }}
+
+
About This Site
+
+ This site is powered by Indiekit , an IndieWeb
+ server that supports Micropub, Webmentions, and other IndieWeb standards. It runs on
+ Cloudron for easy self-hosting.
+
+
+
IndieWeb
+
+ I'm part of the IndieWeb movement - owning my content
+ and identity online. You can interact with my posts through Webmentions - reply, like,
+ or repost from your own website and it will show up here.
+
+
+ {% if site.social.length > 0 %}
+
Connect
+
Find me on:
+
+ {% endif %}
+
+ {% if site.author.email %}
+
+ Or send me an email at
+ {{ site.author.email }}
+
+ {% endif %}
+
+
diff --git a/theme/articles.njk b/theme/articles.njk
new file mode 100644
index 0000000..5b6f2bb
--- /dev/null
+++ b/theme/articles.njk
@@ -0,0 +1,100 @@
+---
+layout: layouts/base.njk
+title: Articles
+withSidebar: true
+pagination:
+ data: collections.articles
+ size: 20
+ alias: paginatedArticles
+ generatePageOnEmptyData: true
+permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
+---
+
+
+
Articles
+ {% set sparklineSvg = collections.articles | postingFrequency %}
+ {% if sparklineSvg %}
+ {{ sparklineSvg | safe }}
+ {% endif %}
+
+
+ Long-form posts and essays.
+ ({{ collections.articles.length }} total)
+
+
+ {% if paginatedArticles.length > 0 %}
+
+ {% for post in paginatedArticles %}
+
+
+
+
+ {{ post.date | dateDisplay }}
+
+ {% if post.data.category %}
+
+ {% if post.data.category is string %}
+ {{ post.data.category }}
+ {% else %}
+ {% for cat in post.data.category %}
+ {{ cat }}
+ {% endfor %}
+ {% endif %}
+
+ {% endif %}
+
+
+ {{ post.templateContent | striptags | truncate(250) }}
+
+
+ Read more →
+
+
+ {% endfor %}
+
+
+ {# Pagination controls #}
+ {% if pagination.pages.length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+ {% set postType = "article" %}
+ {% include "components/empty-collection.njk" %}
+ {% endif %}
+
diff --git a/theme/blog.njk b/theme/blog.njk
new file mode 100644
index 0000000..c68087d
--- /dev/null
+++ b/theme/blog.njk
@@ -0,0 +1,389 @@
+---
+layout: layouts/base.njk
+title: Blog
+withSidebar: true
+pagination:
+ data: collections.posts
+ size: 20
+ alias: paginatedPosts
+permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
+---
+
+
+
Blog
+ {% set sparklineSvg = collections.posts | postingFrequency %}
+ {% if sparklineSvg %}
+ {{ sparklineSvg | safe }}
+ {% endif %}
+
+
+ All posts including articles and notes.
+ ({{ collections.posts.length }} total)
+
+
+ {% if paginatedPosts.length > 0 %}
+
+
+
+ All Types
+ Articles
+ Notes
+ Photos
+ Bookmarks
+ Likes
+ Replies
+ Reposts
+
+
+
+
+ {% for post in paginatedPosts %}
+ {# Detect post type from frontmatter properties #}
+ {% 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 %}
+ {% set _postType %}{% if likedUrl %}like{% elif bookmarkedUrl %}bookmark{% elif repostedUrl %}repost{% elif replyToUrl %}reply{% elif hasPhotos %}photo{% elif post.data.title %}article{% else %}note{% endif %}{% endset %}
+ {% set borderClass = "" %}
+ {% 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-sky-400 dark:border-l-sky-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 %}
+
+
+ {% if likedUrl %}
+ {# ── Like card ── #}
+
+
+ {% elif bookmarkedUrl %}
+ {# ── Bookmark card ── #}
+
+
+ {% elif repostedUrl %}
+ {# ── Repost card ── #}
+
+
+ {% elif replyToUrl %}
+ {# ── Reply card ── #}
+
+
+ {% elif hasPhotos %}
+ {# ── Photo card ── #}
+
+
+ {% elif post.data.title %}
+ {# ── Article card (unchanged) ── #}
+
+
+ {{ post.templateContent | striptags | truncate(250) }}
+
+
+ Read more →
+
+
+ {% else %}
+ {# ── Note card (unchanged) ── #}
+
+
+ {{ post.templateContent | safe }}
+
+
+ {% endif %}
+
+ {# AI usage badge — only show when AI was actually used (level > 0) #}
+ {% set postAiText = post.data.aiTextLevel or post.data.ai_text_level %}
+ {% set postAiCode = post.data.aiCodeLevel or post.data.ai_code_level %}
+ {% if (postAiText and postAiText !== "0") or (postAiCode and postAiCode !== "0") %}
+
+
+ AI{% if postAiText %}: T{{ postAiText }}{% endif %}{% if postAiCode %}/C{{ postAiCode }}{% endif %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+
+ {# Pagination controls #}
+ {% if pagination.pages.length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+
No posts yet. Create your first post using a Micropub client!
+
Some popular Micropub clients:
+
+ Quill - Web-based
+ IndiePass - Mobile app
+ Micropublish - Web-based
+
+ {% endif %}
+
diff --git a/theme/bookmarks.njk b/theme/bookmarks.njk
new file mode 100644
index 0000000..0feb73e
--- /dev/null
+++ b/theme/bookmarks.njk
@@ -0,0 +1,109 @@
+---
+layout: layouts/base.njk
+title: Bookmarks
+withSidebar: true
+pagination:
+ data: collections.bookmarks
+ size: 20
+ alias: paginatedBookmarks
+ generatePageOnEmptyData: true
+permalink: "bookmarks/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
+---
+
+
+
Bookmarks
+ {% set sparklineSvg = collections.bookmarks | postingFrequency %}
+ {% if sparklineSvg %}
+ {{ sparklineSvg | safe }}
+ {% endif %}
+
+
+ Links I've saved for later.
+ ({{ collections.bookmarks.length }} total)
+
+
+ {% if paginatedBookmarks.length > 0 %}
+
+ {% for post in paginatedBookmarks %}
+
+
+
+
+ {{ post.date | dateDisplay }}
+
+ {% if post.data.category %}
+
+ {% if post.data.category is string %}
+ {{ post.data.category }}
+ {% else %}
+ {% for cat in post.data.category %}
+ {{ cat }}
+ {% endfor %}
+ {% endif %}
+
+ {% endif %}
+
+ {# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
+ {% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
+ {% if bookmarkedUrl %}
+ {% unfurl bookmarkedUrl %}
+
+ {{ bookmarkedUrl }}
+
+ {% endif %}
+ {% if post.templateContent %}
+
+ {{ post.templateContent | safe }}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {# Pagination controls #}
+ {% if pagination.pages.length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+ {% set postType = "bookmark" %}
+ {% include "components/empty-collection.njk" %}
+ {% endif %}
+
diff --git a/theme/categories-index.njk b/theme/categories-index.njk
new file mode 100644
index 0000000..3c4d298
--- /dev/null
+++ b/theme/categories-index.njk
@@ -0,0 +1,30 @@
+---
+layout: layouts/base.njk
+title: Categories
+withSidebar: true
+permalink: categories/
+eleventyImport:
+ collections:
+ - categories
+---
+
+
Categories
+
+ Browse posts by category.
+ ({{ collections.categories.length }} categories)
+
+
+ {% if collections.categories.length > 0 %}
+
+ {% for cat in collections.categories %}
+
+
+ {{ cat }}
+
+
+ {% endfor %}
+
+ {% else %}
+
No categories yet.
+ {% endif %}
+
diff --git a/theme/categories.njk b/theme/categories.njk
new file mode 100644
index 0000000..087aa52
--- /dev/null
+++ b/theme/categories.njk
@@ -0,0 +1,83 @@
+---
+layout: layouts/base.njk
+withSidebar: true
+pagination:
+ data: collections.categories
+ size: 1
+ alias: category
+permalink: "categories/{{ category | slugify }}/"
+eleventyComputed:
+ title: "{{ category }}"
+---
+
+
{{ category }}
+
+ Posts tagged with "{{ category }}".
+
+
+ {% set categoryPosts = [] %}
+ {% for post in collections.posts %}
+ {% if post.data.category %}
+ {% if post.data.category is string %}
+ {% if post.data.category == category %}
+ {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
+ {% endif %}
+ {% else %}
+ {% if category in post.data.category %}
+ {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+
+ {% if categoryPosts.length > 0 %}
+
{{ categoryPosts.length }} post{% if categoryPosts.length != 1 %}s{% endif %}
+
+ {% for post in categoryPosts %}
+ {% set postType = post.inputPath | replace("./content/", "") %}
+ {% set postType = postType.split("/")[0] %}
+ {% set borderClass = "" %}
+ {% if postType == "likes" %}
+ {% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
+ {% elif postType == "bookmarks" %}
+ {% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif postType == "reposts" %}
+ {% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
+ {% elif postType == "replies" %}
+ {% set borderClass = "border-l-[3px] border-l-accent-400 dark:border-l-accent-500" %}
+ {% elif postType == "photos" %}
+ {% 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 %}
+
+
+
+
+ {{ post.date | dateDisplay }}
+
+ {{ postType }}
+
+
+ {{ post.templateContent | striptags | truncate(250) }}
+
+
+ View →
+
+
+ {% endfor %}
+
+ {% else %}
+
No posts found with this category.
+ {% endif %}
+
+
+
diff --git a/theme/category-feed-json.njk b/theme/category-feed-json.njk
new file mode 100644
index 0000000..d7612f0
--- /dev/null
+++ b/theme/category-feed-json.njk
@@ -0,0 +1,69 @@
+---
+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 %}
+ ]
+}
diff --git a/theme/category-feed.njk b/theme/category-feed.njk
new file mode 100644
index 0000000..0ada9b6
--- /dev/null
+++ b/theme/category-feed.njk
@@ -0,0 +1,47 @@
+---
+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 %}
+
+
diff --git a/theme/changelog.njk b/theme/changelog.njk
new file mode 100644
index 0000000..68c0a72
--- /dev/null
+++ b/theme/changelog.njk
@@ -0,0 +1,208 @@
+---
+layout: layouts/base.njk
+title: Changelog
+permalink: /changelog/
+eleventyExcludeFromCollections: true
+pagefindIgnore: true
+withSidebar: false
+---
+
+
+
+
+ {# Tab navigation #}
+
+
+
+
+
+
+
+
+
+ {# Loading state #}
+
+
+
+
+
+
Loading changelog...
+
+
+ {# Commit list #}
+
+
+ No recent activity in this category.
+
+
+
+
+
+
+
+
+
+
+
+
+ Show details
+
+
+
+
+
+
+
+
+
+ {# Load more button #}
+
+
+ {# Summary #}
+
+
+ from the last days
+ (all time)
+
+
+
+
+
diff --git a/theme/chardonsbleus.njk b/theme/chardonsbleus.njk
new file mode 100644
index 0000000..3368bc0
--- /dev/null
+++ b/theme/chardonsbleus.njk
@@ -0,0 +1,28 @@
+---
+layout: layouts/base.njk
+title: ChardonsBleus
+permalink: /chardonsbleus/
+eleventyExcludeFromCollections: true
+---
+
+ ChardonsBleus
+
+
diff --git a/theme/css/critical.css b/theme/css/critical.css
new file mode 100644
index 0000000..d662e18
--- /dev/null
+++ b/theme/css/critical.css
@@ -0,0 +1,57 @@
+/* Critical CSS — inlined in for first paint */
+/* Covers: layout shell, header, dark mode toggle, font display, basic typography */
+
+*,*::before,*::after{box-sizing:border-box}
+body{margin:0;font-family:"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased}
+
+/* Dark mode base — warm stone palette */
+body{background-color:#faf8f5;color:#1c1b19}
+.dark body{background-color:#0f0e0d;color:#faf8f5}
+
+/* Container */
+.container{max-width:64rem;margin-left:auto;margin-right:auto;padding-left:1rem;padding-right:1rem}
+
+/* Header — sticky, visible immediately */
+.site-header{background-color:#faf8f5;border-bottom:1px solid #e8e5df;padding-top:1rem;padding-bottom:1rem;position:sticky;top:0;z-index:50}
+.dark .site-header{background-color:#1c1b19;border-bottom-color:#3f3b35}
+.header-container{display:flex;align-items:center;justify-content:space-between}
+.site-title{font-size:1.25rem;font-weight:700;color:#1c1b19;text-decoration:none}
+.dark .site-title{color:#faf8f5}
+
+/* Header actions — hidden on mobile */
+.header-actions{display:none}
+@media(min-width:768px){.header-actions{display:flex;align-items:center;gap:1rem}}
+
+/* Mobile menu toggle */
+.menu-toggle{display:block;padding:0.5rem;border-radius:0.5rem;background:none;border:none;color:#5c5750;cursor:pointer}
+@media(min-width:768px){.menu-toggle{display:none}}
+.dark .menu-toggle{color:#a09a90}
+
+/* Hidden utility */
+.hidden{display:none!important}
+[x-cloak]{display:none!important}
+
+/* Dark mode theme toggle icons */
+.theme-toggle .sun-icon{display:none}
+.theme-toggle .moon-icon{display:block}
+.dark .theme-toggle .sun-icon{display:block}
+.dark .theme-toggle .moon-icon{display:none}
+
+/* Main content padding */
+main.container{padding-top:1.5rem;padding-bottom:1.5rem}
+@media(min-width:768px){main.container{padding-top:2rem;padding-bottom:2rem}}
+
+/* Layout with sidebar */
+.layout-with-sidebar{display:grid;grid-template-columns:1fr;gap:1.5rem}
+@media(min-width:1024px){.layout-with-sidebar{grid-template-columns:2fr 1fr;gap:2rem}}
+.main-content{min-width:0;overflow-x:hidden}
+
+/* Basic typography — prevent FOUT */
+h1,h2,h3,h4{margin:0;line-height:1.25}
+a{color:#b45309}
+.dark a{color:#fbbf24}
+
+/* Prevent flash of unstyled content for nav */
+.site-nav{display:flex;align-items:center;gap:1rem}
+.site-nav>a,.site-nav .nav-dropdown-trigger{color:#5c5750;text-decoration:none;padding-top:0.5rem;padding-bottom:0.5rem}
+.dark .site-nav>a,.dark .site-nav .nav-dropdown-trigger{color:#a09a90}
diff --git a/theme/css/lite-yt-embed.css b/theme/css/lite-yt-embed.css
new file mode 100644
index 0000000..c96a472
--- /dev/null
+++ b/theme/css/lite-yt-embed.css
@@ -0,0 +1,95 @@
+lite-youtube {
+ background-color: #000;
+ position: relative;
+ display: block;
+ contain: content;
+ background-position: center center;
+ background-size: cover;
+ cursor: pointer;
+ max-width: 720px;
+}
+
+/* gradient */
+lite-youtube::before {
+ content: attr(data-title);
+ display: block;
+ position: absolute;
+ top: 0;
+ /* Pixel-perfect port of YT's gradient PNG, using https://github.com/bluesmoon/pngtocss plus optimizations */
+ background-image: linear-gradient(180deg, rgb(0 0 0 / 67%) 0%, rgb(0 0 0 / 54%) 14%, rgb(0 0 0 / 15%) 54%, rgb(0 0 0 / 5%) 72%, rgb(0 0 0 / 0%) 94%);
+ height: 99px;
+ width: 100%;
+ font-family: "YouTube Noto",Roboto,Arial,Helvetica,sans-serif;
+ color: hsl(0deg 0% 93.33%);
+ text-shadow: 0 0 2px rgba(0,0,0,.5);
+ font-size: 18px;
+ padding: 25px 20px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ box-sizing: border-box;
+}
+
+lite-youtube:hover::before {
+ color: white;
+}
+
+/* responsive iframe with a 16:9 aspect ratio
+ thanks https://css-tricks.com/responsive-iframes/
+*/
+lite-youtube::after {
+ content: "";
+ display: block;
+ padding-bottom: calc(100% / (16 / 9));
+}
+lite-youtube > iframe {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ border: 0;
+}
+
+/* play button */
+lite-youtube > .lty-playbtn {
+ display: block;
+ /* Make the button element cover the whole area for a large hover/click target… */
+ width: 100%;
+ height: 100%;
+ /* …but visually it's still the same size */
+ background: no-repeat center/68px 48px;
+ /* YT's actual play button svg */
+ background-image: url('data:image/svg+xml;utf8, ');
+ position: absolute;
+ cursor: pointer;
+ z-index: 1;
+ filter: grayscale(100%);
+ transition: filter .1s cubic-bezier(0, 0, 0.2, 1);
+ border: 0;
+}
+
+lite-youtube:hover > .lty-playbtn,
+lite-youtube .lty-playbtn:focus {
+ filter: none;
+}
+
+/* Post-click styles */
+lite-youtube.lyt-activated {
+ cursor: unset;
+}
+lite-youtube.lyt-activated::before,
+lite-youtube.lyt-activated > .lty-playbtn {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.lyt-visually-hidden {
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ }
diff --git a/theme/css/prism-theme.css b/theme/css/prism-theme.css
new file mode 100644
index 0000000..90bf299
--- /dev/null
+++ b/theme/css/prism-theme.css
@@ -0,0 +1,201 @@
+/* Syntax Highlighting — PrismJS theme for indiekit-eleventy-theme
+ Light mode: clean, high-contrast colors
+ Dark mode: scoped under .dark (Tailwind darkMode: "class") */
+
+/* ── Base code block styling ── */
+code[class*="language-"],
+pre[class*="language-"] {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ font-size: 0.875em;
+ line-height: 1.7;
+ direction: ltr;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ tab-size: 2;
+ hyphens: none;
+}
+
+pre[class*="language-"] {
+ padding: 1.25em;
+ margin: 1.5em 0;
+ overflow: auto;
+ border-radius: 0.5rem;
+}
+
+:not(pre) > code[class*="language-"] {
+ padding: 0.2em 0.4em;
+ border-radius: 0.25rem;
+ white-space: normal;
+}
+
+/* ── Light Mode ── */
+code[class*="language-"],
+pre[class*="language-"] {
+ color: #24292e;
+}
+
+pre[class*="language-"] {
+ background: #f4f2ee;
+ border: 1px solid #e1e4e8;
+}
+
+:not(pre) > code[class*="language-"] {
+ background: #f4f2ee;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+ color: #6a737d;
+}
+
+.token.punctuation {
+ color: #24292e;
+}
+
+.token.namespace {
+ opacity: 0.7;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+ color: #005cc5;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+ color: #032f62;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+ color: #d73a49;
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+ color: #d73a49;
+}
+
+.token.function,
+.token.class-name {
+ color: #6f42c1;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+ color: #e36209;
+}
+
+.token.important,
+.token.bold {
+ font-weight: bold;
+}
+
+.token.italic {
+ font-style: italic;
+}
+
+.token.entity {
+ cursor: help;
+}
+
+/* Line highlighting */
+.highlight-line-active {
+ background-color: #fffbdd;
+ display: inline-block;
+ width: calc(100% + 2.5em);
+ margin-left: -1.25em;
+ padding-left: 1.25em;
+}
+
+/* ── Dark Mode ── */
+.dark code[class*="language-"],
+.dark pre[class*="language-"] {
+ color: #e1e4e8;
+}
+
+.dark pre[class*="language-"] {
+ background: #161b22;
+ border-color: #30363d;
+}
+
+.dark :not(pre) > code[class*="language-"] {
+ background: #161b22;
+}
+
+.dark .token.comment,
+.dark .token.prolog,
+.dark .token.doctype,
+.dark .token.cdata {
+ color: #8b949e;
+}
+
+.dark .token.punctuation {
+ color: #e1e4e8;
+}
+
+.dark .token.property,
+.dark .token.tag,
+.dark .token.boolean,
+.dark .token.number,
+.dark .token.constant,
+.dark .token.symbol,
+.dark .token.deleted {
+ color: #79c0ff;
+}
+
+.dark .token.selector,
+.dark .token.attr-name,
+.dark .token.string,
+.dark .token.char,
+.dark .token.builtin,
+.dark .token.inserted {
+ color: #a5d6ff;
+}
+
+.dark .token.operator,
+.dark .token.entity,
+.dark .token.url,
+.dark .language-css .token.string,
+.dark .style .token.string {
+ color: #ff7b72;
+}
+
+.dark .token.atrule,
+.dark .token.attr-value,
+.dark .token.keyword {
+ color: #ff7b72;
+}
+
+.dark .token.function,
+.dark .token.class-name {
+ color: #d2a8ff;
+}
+
+.dark .token.regex,
+.dark .token.important,
+.dark .token.variable {
+ color: #ffa657;
+}
+
+.dark .highlight-line-active {
+ background-color: rgba(56, 139, 253, 0.15);
+}
diff --git a/theme/css/tailwind.css b/theme/css/tailwind.css
new file mode 100644
index 0000000..fe7d4b4
--- /dev/null
+++ b/theme/css/tailwind.css
@@ -0,0 +1,986 @@
+/* Inter font — latin + latin-ext subsets, weights 400/500/600/700 */
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 400;
+ src: url(/fonts/inter-latin-ext-400-normal.woff2) format('woff2');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 400;
+ src: url(/fonts/inter-latin-400-normal.woff2) format('woff2');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 500;
+ src: url(/fonts/inter-latin-ext-500-normal.woff2) format('woff2');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 500;
+ src: url(/fonts/inter-latin-500-normal.woff2) format('woff2');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 600;
+ src: url(/fonts/inter-latin-ext-600-normal.woff2) format('woff2');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 600;
+ src: url(/fonts/inter-latin-600-normal.woff2) format('woff2');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 700;
+ src: url(/fonts/inter-latin-ext-700-normal.woff2) format('woff2');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 700;
+ src: url(/fonts/inter-latin-700-normal.woff2) format('woff2');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Accessibility utilities */
+@layer utilities {
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .skip-link {
+ @apply absolute -top-full left-0 z-50 bg-accent-600 text-white px-4 py-2;
+ }
+
+ .skip-link:focus {
+ @apply top-0;
+ }
+}
+
+/* Reduce motion for accessibility */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* Body background — warm stone canvas */
+@layer base {
+ body {
+ @apply bg-surface-50 dark:bg-surface-950 text-surface-900 dark:text-surface-100;
+ }
+
+ /* P1: Date typography — all elements get monospace for technical texture */
+ time {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+
+ /* P2: Focus-visible states — keyboard-only focus rings for accessibility (WCAG 2.4.7) */
+ button:focus-visible,
+ [role="button"]:focus-visible,
+ summary:focus-visible {
+ @apply outline-none ring-2 ring-amber-500/70 ring-offset-2 ring-offset-surface-50 rounded-sm;
+ }
+
+ .dark button:focus-visible,
+ .dark [role="button"]:focus-visible,
+ .dark summary:focus-visible {
+ @apply ring-offset-surface-900;
+ }
+
+ input:focus-visible,
+ select:focus-visible,
+ textarea:focus-visible {
+ @apply outline-none ring-2 ring-amber-500/70 border-transparent;
+ }
+
+ a:focus-visible {
+ @apply outline-none ring-2 ring-amber-500/70 ring-offset-1 ring-offset-surface-50 rounded-sm;
+ }
+
+ .dark a:focus-visible {
+ @apply ring-offset-surface-900;
+ }
+}
+
+/* Layout styles */
+@layer components {
+ /* Site header */
+ .site-header {
+ @apply bg-surface-50 dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 py-4 sticky top-0 z-50;
+ }
+
+ .header-container {
+ @apply flex items-center justify-between;
+ }
+
+ .site-title {
+ @apply text-xl font-bold text-surface-900 dark:text-white no-underline hover:text-surface-600 dark:hover:text-surface-300 transition-colors;
+ }
+
+ /* Header actions (nav + theme toggle) */
+ .header-actions {
+ @apply hidden md:flex items-center gap-4;
+ }
+
+ .site-nav {
+ @apply flex items-center gap-4;
+ }
+
+ .site-nav > a,
+ .site-nav .nav-dropdown-trigger {
+ @apply text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 no-underline transition-colors py-2;
+ }
+
+ /* Navigation dropdown */
+ .nav-dropdown {
+ @apply relative;
+ }
+
+ .nav-dropdown-trigger {
+ @apply flex items-center gap-1 cursor-pointer bg-transparent border-none text-base;
+ }
+
+ .nav-dropdown-menu {
+ @apply absolute top-full left-0 mt-1 py-2 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg min-w-[160px] z-50 overflow-y-auto;
+ max-height: calc(100vh - 5rem);
+ max-height: calc(100dvh - 5rem);
+ }
+
+ .nav-dropdown-menu a {
+ @apply block px-4 py-2 text-sm text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-900 dark:hover:text-surface-100 no-underline;
+ }
+
+ .nav-dropdown-divider {
+ @apply my-2 border-t border-surface-200 dark:border-surface-700;
+ }
+
+ /* Mobile menu toggle button */
+ .menu-toggle {
+ @apply md:hidden p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors;
+ }
+
+ /* Mobile navigation dropdown */
+ .mobile-nav {
+ @apply md:hidden border-t border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-900 overflow-y-auto;
+ max-height: calc(100vh - 4rem);
+ max-height: calc(100dvh - 4rem);
+ }
+
+ .mobile-nav a {
+ @apply block px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 no-underline transition-colors border-b border-surface-100 dark:border-surface-800 last:border-b-0;
+ }
+
+ /* Mobile nav collapsible sections */
+ .mobile-nav-section {
+ @apply border-b border-surface-100 dark:border-surface-800;
+ }
+
+ .mobile-nav-toggle {
+ @apply flex items-center justify-between w-full px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors bg-transparent border-none text-base text-left cursor-pointer;
+ }
+
+ .mobile-nav-submenu {
+ @apply bg-surface-50 dark:bg-surface-800;
+ }
+
+ .mobile-nav-submenu a {
+ @apply pl-8 py-2 text-sm border-b-0;
+ }
+
+ .mobile-nav-divider {
+ @apply my-2 mx-4 border-t border-surface-200 dark:border-surface-700;
+ }
+
+ /* Theme toggle button */
+ .theme-toggle {
+ @apply p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors;
+ }
+
+ .theme-toggle .sun-icon {
+ @apply hidden;
+ }
+
+ .theme-toggle .moon-icon {
+ @apply block;
+ }
+
+ .dark .theme-toggle .sun-icon {
+ @apply block;
+ }
+
+ .dark .theme-toggle .moon-icon {
+ @apply hidden;
+ }
+
+ /* Mobile theme toggle */
+ .mobile-theme-toggle {
+ @apply flex items-center justify-between w-full px-4 py-3 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors bg-transparent border-none text-base text-left cursor-pointer border-t border-surface-200 dark:border-surface-700;
+ }
+
+ .mobile-theme-toggle .theme-label {
+ @apply font-normal;
+ }
+
+ .mobile-theme-toggle .theme-icons {
+ @apply flex items-center;
+ }
+
+ .mobile-theme-toggle .sun-icon {
+ @apply hidden;
+ }
+
+ .mobile-theme-toggle .moon-icon {
+ @apply block;
+ }
+
+ .dark .mobile-theme-toggle .sun-icon {
+ @apply block;
+ }
+
+ .dark .mobile-theme-toggle .moon-icon {
+ @apply hidden;
+ }
+
+ /* Container */
+ .container {
+ @apply max-w-5xl mx-auto px-4;
+ }
+
+ /* Site footer */
+ .site-footer {
+ @apply mt-12 py-8 border-t border-surface-200 dark:border-surface-700 text-center text-sm text-surface-500;
+ }
+
+ .site-footer a {
+ @apply text-accent-600 dark:text-accent-400 hover:underline;
+ }
+
+ /* Layout with sidebar - mobile-first with responsive grid */
+ .layout-with-sidebar {
+ @apply grid grid-cols-1 gap-6 md:gap-8 lg:grid-cols-3;
+ }
+
+ .main-content {
+ @apply lg:col-span-2 min-w-0 overflow-x-hidden; /* min-w-0 + overflow-x-hidden prevents layout breaking */
+ }
+
+ .sidebar {
+ @apply space-y-6 lg:sticky lg:top-24 lg:self-start overflow-hidden;
+ }
+
+ /* Main content area - adjust padding for mobile */
+ main.container {
+ @apply py-6 md:py-8;
+ }
+}
+
+/* Custom component styles */
+@layer components {
+ /* Post list */
+ .post-list {
+ @apply list-none p-0 m-0 space-y-6;
+ }
+
+ .post-list li {
+ @apply pb-6 border-b border-b-surface-200 dark:border-b-surface-700 last:border-0;
+ }
+
+ /* Post meta */
+ .post-meta {
+ @apply text-sm text-surface-600 dark:text-surface-400 flex flex-wrap gap-2 items-center;
+ }
+
+ /* Category tags (post metadata pills) */
+ .p-category {
+ @apply inline-block px-2 py-0.5 text-xs bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 rounded border border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 transition-colors;
+ }
+
+ /* Inline hashtags in post content — styled as subtle links, not pills */
+ .e-content a.hashtag,
+ .prose a.hashtag {
+ @apply text-accent-600 dark:text-accent-400 no-underline hover:underline font-medium;
+ /* Override prose default link styling (no border-bottom, no color shift) */
+ text-decoration: none;
+ }
+
+ /* Webmention facepile - overlapping avatar display */
+ .facepile {
+ @apply flex flex-wrap items-center;
+ }
+
+ .facepile-avatar {
+ @apply inline-block -ml-2 first:ml-0 transition-transform hover:z-10 hover:scale-110;
+ }
+
+ .facepile-avatar img {
+ @apply w-8 h-8 rounded-full;
+ }
+
+ /* GitHub components */
+ .repo-card {
+ @apply p-4 border border-surface-200 dark:border-surface-700 rounded-lg;
+ }
+
+ .repo-meta {
+ @apply flex gap-4 text-sm text-surface-600 dark:text-surface-400 mt-2;
+ }
+
+ /* Timeline for CV */
+ .timeline {
+ @apply relative pl-6 border-l-2 border-accent-500;
+ }
+
+ .timeline-item {
+ @apply relative pb-6 last:pb-0;
+ }
+
+ .timeline-item::before {
+ content: '';
+ @apply absolute -left-[calc(1.5rem+5px)] top-1.5 w-3 h-3 bg-accent-500 rounded-full;
+ }
+
+ /* Skills badges */
+ .skill-badge {
+ @apply inline-block px-3 py-1 text-sm bg-surface-100 dark:bg-surface-800 rounded-full;
+ }
+
+ /* Ensure is-land custom elements don't break block layout flow */
+ is-land {
+ @apply block;
+ }
+
+ /* Widget cards */
+ .widget {
+ @apply p-4 mb-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
+ }
+
+ .widget-title {
+ @apply font-bold text-lg mb-4;
+ }
+
+ /* 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;
+ }
+
+ /* Neutralize inner widget card styling when inside collapsible wrapper */
+ .widget-collapsible .widget {
+ @apply border-0 shadow-none rounded-none mb-0 bg-transparent;
+ }
+
+ /* Post cards */
+ .post-card {
+ @apply p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
+ }
+
+ .post-header {
+ @apply flex flex-wrap items-center gap-2;
+ }
+
+ .post-footer {
+ @apply pt-3 border-t border-surface-100 dark:border-surface-700;
+ }
+
+ /* Photo gallery on listing pages */
+ .photo-list li {
+ @apply pb-8;
+ }
+
+ .photo-gallery {
+ @apply my-4 grid gap-2;
+ }
+
+ .photo-gallery img {
+ @apply w-full max-h-[500px] object-cover rounded-lg;
+ }
+
+ .photo-link {
+ @apply block;
+ }
+
+ .photo-caption {
+ @apply mt-3 text-surface-600 dark:text-surface-400;
+ }
+
+ /* Multi-photo grid */
+ .photo-gallery:has(img:nth-child(2)) {
+ @apply grid-cols-2;
+ }
+
+ .photo-gallery:has(img:nth-child(3)) {
+ @apply grid-cols-2;
+ }
+
+ .photo-gallery:has(img:nth-child(4)) {
+ @apply grid-cols-2;
+ }
+
+ /* Pagination */
+ .pagination {
+ @apply mt-12 pt-8 border-t border-surface-200 dark:border-surface-700 flex flex-col sm:flex-row items-center justify-between gap-4;
+ }
+
+ .pagination-info {
+ @apply text-sm text-surface-600 dark:text-surface-400;
+ }
+
+ .pagination-links {
+ @apply flex items-center gap-2;
+ }
+
+ .pagination-link {
+ @apply inline-flex items-center gap-1 px-4 py-2 text-sm font-medium bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors;
+ }
+
+ .pagination-link.disabled {
+ @apply opacity-50 cursor-not-allowed hover:bg-surface-100 dark:hover:bg-surface-800;
+ }
+}
+
+/* Focus states */
+@layer base {
+ a:focus-visible,
+ button:focus-visible,
+ input:focus-visible,
+ textarea:focus-visible,
+ select:focus-visible {
+ @apply outline-2 outline-offset-2 outline-accent-500;
+ }
+}
+
+/* Active states — subtle press feedback on buttons */
+@layer base {
+ button:active:not(:disabled),
+ .pagination-link:active:not(.disabled) {
+ transform: scale(0.97);
+ }
+}
+
+/* Video embeds */
+@layer components {
+ .video-embed {
+ @apply relative w-full aspect-video my-4;
+ }
+
+ .video-embed iframe {
+ @apply absolute inset-0 w-full h-full rounded-lg;
+ }
+}
+
+/* Admin UI - FAB and dashboard link */
+@layer components {
+ .fab-container {
+ @apply fixed bottom-6 right-6 z-50 flex flex-col items-end;
+ }
+
+ .fab-backdrop {
+ @apply fixed inset-0 bg-black/20 dark:bg-black/40 z-40;
+ }
+
+ .fab-button {
+ @apply relative z-50 w-14 h-14 rounded-full bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center;
+ }
+
+ .fab-button:focus-visible {
+ @apply outline-2 outline-offset-2 outline-accent-500;
+ }
+
+ .fab-menu {
+ @apply relative z-50 mb-3 flex flex-col gap-2 items-end;
+ }
+
+ .fab-menu-item {
+ @apply flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-50 dark:bg-surface-800 shadow-md hover:shadow-lg border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 hover:text-accent-600 dark:hover:text-accent-400 no-underline transition-all duration-150 text-sm font-medium;
+ }
+
+ .fab-menu-divider {
+ @apply border-t border-surface-200 dark:border-surface-700 my-1 w-full;
+ }
+
+ .admin-nav-link {
+ @apply text-accent-600 dark:text-accent-400 hover:text-accent-700 dark:hover:text-accent-300 no-underline transition-colors py-2 inline-flex items-center gap-1;
+ }
+}
+
+/* Performance: content-visibility for off-screen rendering optimization */
+@layer utilities {
+ .content-auto {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 500px;
+ }
+}
+
+/* Dates — monospace for technical texture (system.md: every gets font-mono) */
+@layer base {
+ time {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+}
+
+/* Apply content-visibility to images and post items for performance */
+@layer base {
+ /* Responsive typography */
+ html {
+ @apply text-base md:text-lg;
+ }
+
+ /* Prevent horizontal overflow */
+ body {
+ @apply overflow-x-hidden;
+ }
+
+ /* Images - prevent overflow and add content-visibility */
+ img {
+ @apply max-w-full h-auto;
+ content-visibility: auto;
+ }
+
+ /* Pre/code blocks - prevent overflow on mobile */
+ pre {
+ @apply overflow-x-auto max-w-full;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ code {
+ @apply break-words;
+ }
+
+ pre code {
+ word-break: normal;
+ overflow-wrap: normal;
+ }
+
+ /* Links in content - break long URLs */
+ .e-content a,
+ .prose a {
+ @apply break-words;
+ word-break: break-word;
+ }
+
+ /* Content containers - clip horizontal overflow but allow pre blocks to scroll */
+ .e-content,
+ .prose {
+ overflow-x: clip;
+ max-width: 100%;
+ }
+
+ article {
+ scroll-margin-top: 80px; /* Prevent header overlap when scrolling to anchors */
+ }
+
+ /* Heading anchors — generated by markdown-it-anchor */
+ .prose h2[id],
+ .prose h3[id],
+ .prose h4[id] {
+ scroll-margin-top: 80px;
+ }
+ .prose :is(h2, h3, h4) > a.header-anchor {
+ color: inherit;
+ text-decoration: none;
+ }
+ .prose :is(h2, h3, h4) > a.header-anchor:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--tw-prose-links, currentColor);
+ text-underline-offset: 4px;
+ }
+ .prose :is(h2, h3, h4) > a.header-anchor::after {
+ content: " #";
+ opacity: 0;
+ font-weight: normal;
+ transition: opacity 0.15s;
+ }
+ .prose :is(h2, h3, h4):hover > a.header-anchor::after {
+ opacity: 0.4;
+ }
+
+ .post-list li {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 200px;
+ }
+
+ /* Tables - responsive handling */
+ table {
+ @apply w-full;
+ display: block;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Ensure truncate works properly in flex containers */
+ .truncate {
+ @apply overflow-hidden text-ellipsis whitespace-nowrap;
+ }
+
+ /* Video embeds - maintain aspect ratio */
+ lite-youtube,
+ iframe[src*="youtube"],
+ iframe[src*="vimeo"] {
+ @apply max-w-full;
+ }
+}
+
+/* Pagefind UI theme overrides — outside @layer for higher specificity over Pagefind's :root defaults */
+#search .pagefind-ui {
+ --pagefind-ui-scale: 1;
+ --pagefind-ui-primary: #b45309;
+ --pagefind-ui-text: #1c1b19;
+ --pagefind-ui-background: #faf8f5;
+ --pagefind-ui-border: #e8e5df;
+ --pagefind-ui-tag: #f4f2ee;
+ --pagefind-ui-border-width: 1px;
+ --pagefind-ui-border-radius: 8px;
+ --pagefind-ui-image-border-radius: 8px;
+ --pagefind-ui-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.dark #search .pagefind-ui {
+ --pagefind-ui-primary: #fbbf24;
+ --pagefind-ui-text: #faf8f5;
+ --pagefind-ui-background: #0f0e0d;
+ --pagefind-ui-border: #3f3b35;
+ --pagefind-ui-tag: #2a2722;
+}
+
+/* Search input */
+#search .pagefind-ui__search-input {
+ background-color: #faf8f5;
+ color: #1c1b19;
+ border-color: #e8e5df;
+ font-weight: 400;
+}
+
+.dark #search .pagefind-ui__search-input {
+ background-color: #1c1b19;
+ color: #faf8f5;
+ border-color: #3f3b35;
+}
+
+#search .pagefind-ui__search-input:focus {
+ outline: 2px solid #d97706;
+ outline-offset: 2px;
+ border-color: #d97706;
+}
+
+.dark #search .pagefind-ui__search-input:focus {
+ outline-color: #fbbf24;
+ border-color: #fbbf24;
+}
+
+/* Search clear button */
+#search .pagefind-ui__search-clear {
+ color: #5c5750;
+ background-color: #faf8f5;
+}
+
+.dark #search .pagefind-ui__search-clear {
+ color: #a09a90;
+ background-color: #1c1b19;
+}
+
+#search .pagefind-ui__search-clear:hover {
+ color: #1c1b19;
+}
+
+.dark #search .pagefind-ui__search-clear:hover {
+ color: #faf8f5;
+}
+
+/* Result links */
+#search .pagefind-ui__result-link {
+ color: #b45309;
+}
+
+#search .pagefind-ui__result-link:hover {
+ text-decoration: underline;
+}
+
+.dark #search .pagefind-ui__result-link {
+ color: #fbbf24;
+}
+
+/* Result excerpts */
+#search .pagefind-ui__result-excerpt {
+ color: #5c5750;
+}
+
+.dark #search .pagefind-ui__result-excerpt {
+ color: #a09a90;
+}
+
+/* Highlighted search terms in results */
+#search .pagefind-ui__result-excerpt mark,
+#search mark {
+ background-color: #fef3c7;
+ color: #92400e;
+ padding: 0.1em 0.2em;
+ border-radius: 2px;
+}
+
+.dark #search .pagefind-ui__result-excerpt mark,
+.dark #search mark {
+ background-color: #78350f;
+ color: #fde68a;
+}
+
+/* Message (result count) */
+#search .pagefind-ui__message {
+ color: #5c5750;
+}
+
+.dark #search .pagefind-ui__message {
+ color: #a09a90;
+}
+
+/* "Load more" button */
+#search .pagefind-ui__button {
+ color: #b45309;
+ background-color: #faf8f5;
+ border-color: #e8e5df;
+ cursor: pointer;
+}
+
+#search .pagefind-ui__button:hover {
+ background-color: #fffbeb;
+ border-color: #b45309;
+}
+
+.dark #search .pagefind-ui__button {
+ color: #fbbf24;
+ background-color: #0f0e0d;
+ border-color: #3f3b35;
+}
+
+.dark #search .pagefind-ui__button:hover {
+ background-color: #1c1b19;
+ border-color: #fbbf24;
+}
+
+/* Filter panel labels */
+#search .pagefind-ui__filter-name,
+#search .pagefind-ui__filter-label {
+ color: #18181b;
+}
+
+.dark #search .pagefind-ui__filter-name,
+.dark #search .pagefind-ui__filter-label {
+ color: #f4f4f5;
+}
+
+/* Result tags */
+#search .pagefind-ui__result-tag {
+ background-color: #f4f4f5;
+ color: #52525b;
+}
+
+.dark #search .pagefind-ui__result-tag {
+ background-color: #27272a;
+ color: #a1a1aa;
+}
+
+/* Sub-result nested links */
+#search .pagefind-ui__result-nested .pagefind-ui__result-link {
+ color: #2563eb;
+ font-weight: 400;
+}
+
+.dark #search .pagefind-ui__result-nested .pagefind-ui__result-link {
+ color: #60a5fa;
+}
+
+/* Mobile-specific improvements */
+@layer utilities {
+ /* Ensure proper touch scrolling on overflow containers */
+ .overflow-x-auto {
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Hide scrollbar but allow scrolling */
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+/* Sparkline — inline SVG posting frequency chart */
+.sparkline {
+ width: 120px;
+ height: 28px;
+ display: block;
+}
+
+@media (min-width: 640px) {
+ .sparkline {
+ width: 180px;
+ height: 32px;
+ }
+}
+
+/* Save for Later buttons — hidden until auth confirmed */
+.save-later-btn {
+ display: none;
+}
+
+body[data-indiekit-auth="true"] .save-later-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ padding: 2px 8px;
+ font-size: 0.75rem;
+ color: #6b7280;
+ transition: all 0.2s ease;
+}
+
+body[data-indiekit-auth="true"] .save-later-btn:hover {
+ border-color: #d1d5db;
+ color: #4a9eff;
+}
+
+.save-later--saved {
+ color: #4a9eff !important;
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+/* Share Post buttons — hidden until auth confirmed */
+.share-post-btn {
+ display: none;
+}
+
+body[data-indiekit-auth="true"] .share-post-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ padding: 2px 8px;
+ font-size: 0.75rem;
+ color: #6b7280;
+ transition: all 0.2s ease;
+}
+
+body[data-indiekit-auth="true"] .share-post-btn:hover {
+ border-color: #d1d5db;
+ color: #10b981;
+}
+
+/* Post type dropdown */
+.post-type-dropdown {
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 4px;
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); /* shadow-sm */
+ z-index: 50;
+ min-width: 120px;
+ overflow: hidden;
+}
+
+.post-type-dropdown.open {
+ display: block;
+}
+
+.post-type-dropdown-item {
+ display: block;
+ width: 100%;
+ padding: 6px 12px;
+ font-size: 13px;
+ color: #374151;
+ text-align: left;
+ background: none;
+ border: none;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.post-type-dropdown-item:hover {
+ background: #f3f4f6;
+ color: #10b981;
+}
+
+/* Dark mode */
+@media (prefers-color-scheme: dark) {
+ .post-type-dropdown {
+ background: #1f2937;
+ border-color: #374151;
+ }
+ .post-type-dropdown-item {
+ color: #d1d5db;
+ }
+ .post-type-dropdown-item:hover {
+ background: #374151;
+ color: #34d399;
+ }
+}
diff --git a/theme/cv.njk b/theme/cv.njk
new file mode 100644
index 0000000..afb1116
--- /dev/null
+++ b/theme/cv.njk
@@ -0,0 +1,146 @@
+---
+layout: layouts/base.njk
+withSidebar: false
+title: CV
+permalink: /cv/
+pagefindIgnore: true
+---
+
+{# CV page — uses configurable layout when cvPageConfig exists, falls back to hardcoded layout #}
+
+{% set hasCvData = (cv.experience and cv.experience.length) or
+ (cv.projects and cv.projects.length) or
+ (cv.skills and ((cv.skills or {}) | dictsort | length)) or
+ (cv.interests and ((cv.interests or {}) | dictsort | length)) %}
+
+{% if hasCvData %}
+
+ {# Configurable layout — use cvPageConfig if available #}
+ {% if cvPageConfig and cvPageConfig.sections %}
+ {% include "components/cv-builder.njk" %}
+
+ {# Fallback — hardcoded layout for backward compatibility #}
+ {% else %}
+
+ {# CV identity — check cvPageConfig.identity first, fall back to site.author #}
+ {% set cvId = cvPageConfig.identity if (cvPageConfig and cvPageConfig.identity) else {} %}
+ {% set authorName = cvId.name or site.author.name %}
+ {% set authorAvatar = cvId.avatar or site.author.avatar %}
+ {% set authorTitle = cvId.title or site.author.title %}
+ {% set authorBio = cvId.bio or site.author.bio %}
+ {% set socialLinks = cvId.social if (cvId.social and cvId.social.length) else site.social %}
+ {% set cvLocality = cvId.locality or site.author.locality %}
+ {% set cvCountry = cvId.country or site.author.country %}
+ {% set cvOrg = cvId.org or site.author.org %}
+ {% set cvUrl = cvId.url or '' %}
+ {% set cvEmail = cvId.email or site.author.email %}
+ {% set cvKeyUrl = cvId.keyUrl or site.author.keyUrl %}
+
+ {# Hero / intro #}
+
+
+
+
+
+ {{ authorName }}
+
+ {% if authorTitle %}
+
+ {{ authorTitle }}
+
+ {% endif %}
+ {% if authorBio %}
+
+ {{ authorBio }}
+
+ {% endif %}
+ {% from "components/social-icon.njk" import socialIcon %}
+ {% if socialLinks %}
+
+ {% endif %}
+ {# Contact details #}
+ {% if cvLocality or cvCountry or cvOrg or cvUrl or cvEmail or cvKeyUrl %}
+
+ {% if cvLocality or cvCountry %}
+
{% if cvLocality %}{{ cvLocality }}{% endif %}{% if cvLocality and cvCountry %}, {% endif %}{% if cvCountry %}{{ cvCountry }}{% endif %}
+ {% endif %}
+ {% if cvOrg %}
+
{{ cvOrg }}
+ {% endif %}
+ {% if cvUrl %}
+
{{ cvUrl | replace("https://", "") | replace("http://", "") }}
+ {% endif %}
+ {% if cvEmail %}
+
{{ cvEmail }}
+ {% endif %}
+ {% if cvKeyUrl %}
+
PGP Key
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+ {# Experience — work-only variant #}
+ {% set section = { type: "cv-experience-work", config: {} } %}
+ {% include "components/sections/cv-experience-work.njk" ignore missing %}
+
+ {# Skills — work-only variant #}
+ {% set section = { type: "cv-skills-work", config: {} } %}
+ {% include "components/sections/cv-skills-work.njk" ignore missing %}
+
+ {# Work Projects (only work-related projects on the CV page) #}
+ {% set section = { type: "cv-projects-work", config: {} } %}
+ {% include "components/sections/cv-projects-work.njk" ignore missing %}
+
+ {# Education — work-only variant #}
+ {% set section = { type: "cv-education-work", config: {} } %}
+ {% include "components/sections/cv-education-work.njk" ignore missing %}
+
+ {# Languages — standalone section #}
+ {% set section = { type: "cv-languages", config: {} } %}
+ {% include "components/sections/cv-languages.njk" ignore missing %}
+
+ {# Interests — work-only variant #}
+ {% set section = { type: "cv-interests-work", config: {} } %}
+ {% include "components/sections/cv-interests-work.njk" ignore missing %}
+
+ {# Last Updated #}
+ {% if cv.lastUpdated %}
+
+ Last updated: {{ cv.lastUpdated | date("PPP") }}
+
+ {% endif %}
+
+ {% endif %}
+
+{% else %}
+
+
+
CV
+
+ No CV data available yet. Add your experience, projects, and skills via the
+ admin dashboard .
+
+
+
+{% endif %}
diff --git a/theme/digest-feed.njk b/theme/digest-feed.njk
new file mode 100644
index 0000000..6505876
--- /dev/null
+++ b/theme/digest-feed.njk
@@ -0,0 +1,31 @@
+---
+eleventyExcludeFromCollections: true
+eleventyImport:
+ collections:
+ - weeklyDigests
+permalink: /digest/feed.xml
+---
+
+
+
+ {{ 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 %}
+
+
diff --git a/theme/digest-index.njk b/theme/digest-index.njk
new file mode 100644
index 0000000..959788e
--- /dev/null
+++ b/theme/digest-index.njk
@@ -0,0 +1,83 @@
+---
+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 %}"
+---
+
+
Weekly Digest
+
+ A weekly summary of all posts. Subscribe via RSS for one update per week.
+
+
+ {% if paginatedDigests.length > 0 %}
+
+
+ {% if pagination.pages.length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+
No digests yet. Posts will be grouped into weekly digests automatically.
+ {% endif %}
+
diff --git a/theme/digest.njk b/theme/digest.njk
new file mode 100644
index 0000000..aa40bf8
--- /dev/null
+++ b/theme/digest.njk
@@ -0,0 +1,168 @@
+---
+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 }}/"
+---
+
+
+ {{ digest.label }}
+
+
+ {{ digest.startDate | dateDisplay }} – {{ digest.endDate | dateDisplay }}
+ ({{ digest.posts.length }} post{% if digest.posts.length != 1 %}s{% endif %})
+
+
+ {# 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 %}
+
+
+ {{ typeInfo.label }}
+ ({{ typePosts.length }})
+
+
+ {% for post in typePosts %}
+
+ {% if typeInfo.key == "likes" %}
+ {% set targetUrl = post.data.likeOf or post.data.like_of %}
+
+
+ {% elif typeInfo.key == "bookmarks" %}
+ {% set targetUrl = post.data.bookmarkOf or post.data.bookmark_of %}
+
+
+ {% elif typeInfo.key == "reposts" %}
+ {% set targetUrl = post.data.repostOf or post.data.repost_of %}
+
+
+ {% elif typeInfo.key == "photos" %}
+
+ {% 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 %}
+
+
+
+ {% endif %}
+ {% if post.data.title %}
+
{{ post.data.title }}
+ {% elif post.templateContent %}
+
{{ post.templateContent | striptags | truncate(120) }}
+ {% endif %}
+
+
{{ post.date | dateDisplay }}
+ ·
Permalink
+
+
+
+ {% elif typeInfo.key == "articles" %}
+
+
+ {% else %}
+
+
{{ post.templateContent | striptags | truncate(200) }}
+
+
{{ post.date | dateDisplay }}
+ ·
Permalink
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+
+ {% if currentIndex > 0 %}
+ {% set newer = allDigests[currentIndex - 1] %}
+
+ ← {{ newer.label }}
+
+ {% else %}
+
+ {% endif %}
+ {% if currentIndex < allDigests.length - 1 %}
+ {% set older = allDigests[currentIndex + 1] %}
+
+ {{ older.label }} →
+
+ {% else %}
+
+ {% endif %}
+
+
diff --git a/theme/eleventy.config.js b/theme/eleventy.config.js
new file mode 100644
index 0000000..64954c6
--- /dev/null
+++ b/theme/eleventy.config.js
@@ -0,0 +1,1299 @@
+import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions";
+import pluginRss from "@11ty/eleventy-plugin-rss";
+import embedEverything from "eleventy-plugin-embed-everything";
+import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
+import sitemap from "@quasibit/eleventy-plugin-sitemap";
+import markdownIt from "markdown-it";
+import markdownItAnchor from "markdown-it-anchor";
+import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
+import { minify } from "html-minifier-terser";
+import registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js";
+import matter from "gray-matter";
+import { createHash, createHmac } from "crypto";
+import { createRequire } from "module";
+import { execFileSync } from "child_process";
+import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const esmRequire = createRequire(import.meta.url);
+const postGraph = esmRequire("@rknightuk/eleventy-plugin-post-graph");
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const siteUrl = process.env.SITE_URL || "https://example.com";
+
+export default function (eleventyConfig) {
+ // Don't use .gitignore for determining what to process
+ // (content/ is in .gitignore because it's a symlink, but we need to process it)
+ eleventyConfig.setUseGitIgnore(false);
+
+ // Ignore output directory (prevents re-processing generated files via symlink)
+ eleventyConfig.ignores.add("_site");
+ eleventyConfig.ignores.add("_site/**");
+ eleventyConfig.ignores.add("/app/data/site");
+ eleventyConfig.ignores.add("/app/data/site/**");
+ eleventyConfig.ignores.add("node_modules");
+ eleventyConfig.ignores.add("node_modules/**");
+ eleventyConfig.ignores.add("CLAUDE.md");
+ eleventyConfig.ignores.add("README.md");
+
+ // Ignore Pagefind output directory
+ eleventyConfig.ignores.add("pagefind");
+ eleventyConfig.ignores.add("pagefind/**");
+ // Ignore interactive assets (served via passthrough copy, not processed as templates)
+ eleventyConfig.ignores.add("interactive");
+ eleventyConfig.ignores.add("interactive/**");
+
+ // Configure watch targets to exclude output directory
+ eleventyConfig.watchIgnores.add("_site");
+ eleventyConfig.watchIgnores.add("_site/**");
+ eleventyConfig.watchIgnores.add("/app/data/site");
+ eleventyConfig.watchIgnores.add("/app/data/site/**");
+ eleventyConfig.watchIgnores.add("pagefind");
+ eleventyConfig.watchIgnores.add("pagefind/**");
+ eleventyConfig.watchIgnores.add(".cache/og");
+ eleventyConfig.watchIgnores.add(".cache/og/**");
+ eleventyConfig.watchIgnores.add(".cache/unfurl");
+ eleventyConfig.watchIgnores.add(".cache/unfurl/**");
+
+ // Watcher tuning: handle rapid successive file changes
+ // When a post is created via Micropub, the file is written twice in quick
+ // succession: first the initial content, then ~2s later a Micropub update
+ // adds syndication URLs. awaitWriteFinish delays the watcher event until
+ // the file is stable (no writes for 2s), so both changes are captured in
+ // one build. The throttle adds a 3s build-level debounce on top.
+ eleventyConfig.setChokidarConfig({
+ awaitWriteFinish: {
+ stabilityThreshold: 2000,
+ pollInterval: 100,
+ },
+ });
+ eleventyConfig.setWatchThrottleWaitTime(3000);
+
+ // Configure markdown-it with linkify enabled (auto-convert URLs to links)
+ const md = markdownIt({
+ html: true,
+ linkify: true, // Auto-convert URLs to clickable links
+ typographer: true,
+ });
+ md.use(markdownItAnchor, {
+ permalink: markdownItAnchor.permalink.headerLink(),
+ slugify: (s) => s.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""),
+ level: [2, 3, 4],
+ });
+
+ // Hashtag plugin: converts #tag to category links on-site
+ // Syndication targets (Bluesky, Mastodon) handle raw #tag natively via facet detection
+ md.inline.ruler.push("hashtag", (state, silent) => {
+ const pos = state.pos;
+ if (state.src.charCodeAt(pos) !== 0x23 /* # */) return false;
+
+ // Must be at start of string or preceded by whitespace/punctuation (not part of a URL fragment or hex color)
+ if (pos > 0) {
+ const prevChar = state.src.charAt(pos - 1);
+ if (!/[\s()\[\]{},;:!?"'«»""'']/.test(prevChar)) return false;
+ }
+
+ // Match hashtag: # followed by letter/underscore, then word chars (letters, digits, underscores)
+ const tail = state.src.slice(pos + 1);
+ const match = tail.match(/^([a-zA-Z_]\w*)/);
+ if (!match) return false;
+
+ const tag = match[1];
+
+ // Skip pure hex color codes (3, 4, 6, or 8 hex digits with nothing else)
+ if (/^[0-9a-fA-F]{3,8}$/.test(tag)) return false;
+
+ if (!silent) {
+ const slug = tag.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
+ const tokenOpen = state.push("link_open", "a", 1);
+ tokenOpen.attrSet("href", `/categories/${slug}/`);
+ tokenOpen.attrSet("class", "p-category hashtag");
+
+ const tokenText = state.push("text", "", 0);
+ tokenText.content = `#${tag}`;
+
+ state.push("link_close", "a", -1);
+ }
+
+ state.pos = pos + 1 + tag.length;
+ return true;
+ });
+
+ eleventyConfig.setLibrary("md", md);
+
+ // Syntax highlighting for fenced code blocks (```lang)
+ eleventyConfig.addPlugin(syntaxHighlight);
+
+ // RSS plugin for feed filters (dateToRfc822, absoluteUrl, etc.)
+ // Custom feed templates in feed.njk and feed-json.njk use these filters
+ eleventyConfig.addPlugin(pluginRss);
+
+ // Post graph — GitHub-style contribution grid for posting frequency
+ eleventyConfig.addPlugin(postGraph, {
+ sort: "desc",
+ limit: 2,
+ dayBoxTitle: true,
+ selectorLight: ":root",
+ selectorDark: ".dark",
+ boxColorLight: "#e7e5e4", // surface-200 (warm stone)
+ highlightColorLight: "#d97706", // amber-600 (accent)
+ textColorLight: "#1c1917", // surface-900
+ boxColorDark: "#292524", // surface-800
+ highlightColorDark: "#fbbf24", // amber-400
+ textColorDark: "#fafaf9", // surface-50
+ });
+
+ // JSON encode filter for JSON feed
+ eleventyConfig.addFilter("jsonEncode", (value) => {
+ return JSON.stringify(value);
+ });
+
+ // Guess MIME type from URL extension
+ function guessMimeType(url, category) {
+ const lower = (typeof url === "string" ? url : "").toLowerCase();
+ if (category === "photo") {
+ if (lower.includes(".png")) return "image/png";
+ if (lower.includes(".gif")) return "image/gif";
+ if (lower.includes(".webp")) return "image/webp";
+ if (lower.includes(".svg")) return "image/svg+xml";
+ return "image/jpeg";
+ }
+ if (category === "audio") {
+ if (lower.includes(".ogg") || lower.includes(".opus")) return "audio/ogg";
+ if (lower.includes(".flac")) return "audio/flac";
+ if (lower.includes(".wav")) return "audio/wav";
+ return "audio/mpeg";
+ }
+ if (category === "video") {
+ if (lower.includes(".webm")) return "video/webm";
+ if (lower.includes(".mov")) return "video/quicktime";
+ return "video/mp4";
+ }
+ return "application/octet-stream";
+ }
+
+ // Extract URL string from value that may be a string or {url, alt} object
+ function resolveMediaUrl(value) {
+ if (typeof value === "string") return value;
+ if (value && typeof value === "object" && value.url) return value.url;
+ return null;
+ }
+
+ // Feed attachments filter — builds JSON Feed attachments array from post data
+ eleventyConfig.addFilter("feedAttachments", (postData) => {
+ const attachments = [];
+ const processMedia = (items, category) => {
+ const list = Array.isArray(items) ? items : [items];
+ for (const item of list) {
+ const rawUrl = resolveMediaUrl(item);
+ if (!rawUrl) continue;
+ const url = rawUrl.startsWith("http") ? rawUrl : `${siteUrl}${rawUrl}`;
+ attachments.push({ url, mime_type: guessMimeType(rawUrl, category) });
+ }
+ };
+ if (postData.photo) processMedia(postData.photo, "photo");
+ if (postData.audio) processMedia(postData.audio, "audio");
+ if (postData.video) processMedia(postData.video, "video");
+ return attachments;
+ });
+
+ // Textcasting support filter — builds clean support object excluding null values
+ eleventyConfig.addFilter("textcastingSupport", (support) => {
+ if (!support) return {};
+ const obj = {};
+ if (support.url) obj.url = support.url;
+ if (support.stripe) obj.stripe = support.stripe;
+ if (support.lightning) obj.lightning = support.lightning;
+ if (support.paymentPointer) obj.payment_pointer = support.paymentPointer;
+ return obj;
+ });
+
+ // Protocol type filter — classifies a URL by its origin protocol/network
+ eleventyConfig.addFilter("protocolType", (url) => {
+ if (!url || typeof url !== "string") return "web";
+ const lower = url.toLowerCase();
+ if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
+ // Match Fediverse instances by known domain patterns (avoid overly broad "social")
+ if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
+ lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.") ||
+ lower.includes("fediverse")) return "fediverse";
+ return "web";
+ });
+
+ // Email obfuscation filter - converts email to HTML entities
+ // Blocks ~95% of spam harvesters while remaining valid for microformat parsers
+ // Usage: {{ email | obfuscateEmail }} or {{ email | obfuscateEmail("href") }}
+ eleventyConfig.addFilter("obfuscateEmail", (email, mode = "display") => {
+ if (!email) return "";
+ // Convert each character to HTML decimal entity
+ const encoded = [...email].map(char => `${char.charCodeAt(0)};`).join("");
+ if (mode === "href") {
+ // For mailto: links, also encode the "mailto:" prefix
+ const mailto = [...("mailto:")].map(char => `${char.charCodeAt(0)};`).join("");
+ return mailto + encoded;
+ }
+ return encoded;
+ });
+
+ // Alias dateToRfc822 (plugin provides dateToRfc2822)
+ eleventyConfig.addFilter("dateToRfc822", (date) => {
+ return pluginRss.dateToRfc2822(date);
+ });
+
+ // Embed Everything - auto-embed YouTube, Vimeo, Bluesky, Mastodon, etc.
+ eleventyConfig.addPlugin(embedEverything, {
+ use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"],
+ youtube: {
+ options: {
+ lite: false,
+ recommendSelfOnly: true,
+ },
+ },
+ mastodon: {
+ options: {
+ server: "indieweb.social",
+ },
+ },
+ });
+
+ // Unfurl shortcode — renders any URL as a rich card (OpenGraph/Twitter Card metadata)
+ // Usage in templates: {% unfurl "https://example.com/article" %}
+ registerUnfurlShortcode(eleventyConfig);
+
+ // Synchronous unfurl filter — reads from pre-populated disk cache.
+ // Safe for deeply nested includes where async shortcodes fail silently.
+ // Usage: {{ url | unfurlCard | safe }}
+ eleventyConfig.addFilter("unfurlCard", getCachedCard);
+
+ // Custom transform to convert YouTube links to embeds
+ eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
+ if (!outputPath || !outputPath.endsWith(".html")) {
+ return content;
+ }
+ // Match tags where href contains youtube.com/watch or youtu.be
+ // Link text can be: URL, www.youtube..., youtube..., or youtube-related text
+ const youtubePattern = / ]+href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)[^"]*"[^>]*>(?:https?:\/\/)?(?:www\.)?[^<]*(?:youtube|youtu\.be)[^<]*<\/a>/gi;
+
+ content = content.replace(youtubePattern, (match, videoId) => {
+ // Use standard YouTube iframe with exact oEmbed parameters
+ return `VIDEO
`;
+ });
+
+ // Clean up empty
tags created by the replacement
+ content = content.replace(/\s*<\/p>/g, '');
+
+ return content;
+ });
+
+ // Image optimization - transforms tags automatically
+ // PROCESS_REMOTE_IMAGES: set to "true" to let Sharp download and re-encode remote images.
+ // Default "false" — skips remote URLs (adds eleventy:ignore) to avoid OOM from Sharp's
+ // native memory usage when processing hundreds of external images (bookmarks, webmentions).
+ const processRemoteImages = process.env.PROCESS_REMOTE_IMAGES === "true";
+ if (!processRemoteImages) {
+ eleventyConfig.htmlTransformer.addPosthtmlPlugin("html", () => {
+ return (tree) => {
+ tree.match({ tag: "img" }, (node) => {
+ if (node.attrs?.src && /^https?:\/\//.test(node.attrs.src)) {
+ node.attrs["eleventy:ignore"] = "";
+ }
+ return node;
+ });
+ return tree;
+ };
+ }, { priority: 1 }); // priority > 0 runs before image plugin (priority -1)
+ }
+
+ eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
+ extensions: "html",
+ formats: ["webp", "jpeg"],
+ widths: ["auto"],
+ failOnError: false,
+ cacheOptions: {
+ duration: process.env.ELEVENTY_RUN_MODE === "build" ? "1d" : "30d",
+ },
+ concurrency: 4,
+ defaultAttributes: {
+ loading: "lazy",
+ decoding: "async",
+ sizes: "auto",
+ alt: "",
+ },
+ });
+
+ // Sitemap generation
+ eleventyConfig.addPlugin(sitemap, {
+ sitemap: {
+ hostname: siteUrl,
+ },
+ });
+
+ // Wrap
elements in for responsive tables
+ eleventyConfig.addTransform("table-saw-wrap", function (content, outputPath) {
+ if (outputPath && outputPath.endsWith(".html")) {
+ return content.replace(/)/g, " ");
+ }
+ return content;
+ });
+
+ // Fix OG image meta tags post-rendering — bypasses Eleventy 3.x race condition (#3183).
+ // page.url is unreliable during parallel rendering, but outputPath IS correct
+ // since files are written to the correct location. Derives the OG slug from
+ // outputPath and replaces placeholders emitted by base.njk.
+ eleventyConfig.addTransform("og-fix", function (content, outputPath) {
+ if (!outputPath || !outputPath.endsWith(".html")) return content;
+
+ // Derive correct page URL and OG slug from outputPath (immune to race condition)
+ // Content pages match: .../type/yyyy/MM/dd/slug/index.html
+ const dateMatch = outputPath.match(
+ /\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/index\.html$/
+ );
+
+ if (dateMatch) {
+ const [, type, year, month, day, slug] = dateMatch;
+ const pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
+ const correctFullUrl = `${siteUrl}${pageUrlPath}`;
+ const ogSlug = `${year}-${month}-${day}-${slug}`;
+ const hasOg = existsSync(resolve(__dirname, ".cache", "og", `${ogSlug}.png`));
+ const ogImageUrl = hasOg
+ ? `${siteUrl}/og/${ogSlug}.png`
+ : `${siteUrl}/images/og-default.png`;
+ const twitterCard = hasOg ? "summary_large_image" : "summary";
+
+ // Fix og:url and canonical (also affected by race condition)
+ content = content.replace(
+ /( {
+ if (!dateObj) return "";
+ const date = new Date(dateObj);
+ return date.toLocaleDateString("en-GB", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ });
+
+ // ISO date filter
+ eleventyConfig.addFilter("isoDate", (dateObj) => {
+ if (!dateObj) return "";
+ return new Date(dateObj).toISOString();
+ });
+
+ // 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 {
+ const content = post.templateContent || "";
+ label = content.replace(/<[^>]*>/g, "").slice(0, 120).trim() || "Untitled";
+ }
+ html += `${label} `;
+ }
+ html += ` `;
+ }
+
+ return html;
+ });
+
+ // Truncate filter
+ eleventyConfig.addFilter("truncate", (str, len = 200) => {
+ if (!str) return "";
+ if (str.length <= len) return str;
+ return str.slice(0, len).trim() + "...";
+ });
+
+ // Clean excerpt for OpenGraph - strips HTML, decodes entities, removes extra whitespace
+ eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
+ if (!content) return "";
+ // Strip HTML tags
+ let text = content.replace(/<[^>]+>/g, ' ');
+ // Decode common HTML entities
+ text = text.replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ');
+ // Remove extra whitespace
+ text = text.replace(/\s+/g, ' ').trim();
+ // Truncate
+ if (text.length > len) {
+ text = text.slice(0, len).trim() + "...";
+ }
+ return text;
+ });
+
+ // Extract first image from content for OpenGraph fallback
+ eleventyConfig.addFilter("extractFirstImage", (content) => {
+ if (!content) return null;
+ // Match all tags, skip hidden ones and data URIs
+ const imgRegex = / ]*?\ssrc=["']([^"']+)["'][^>]*>/gi;
+ let match;
+ while ((match = imgRegex.exec(content)) !== null) {
+ const fullTag = match[0];
+ const src = match[1];
+ if (src.startsWith("data:")) continue;
+ if (/\bhidden\b/.test(fullTag)) continue;
+ return src;
+ }
+ return null;
+ });
+
+ // Head filter for arrays
+ eleventyConfig.addFilter("head", (array, n) => {
+ if (!Array.isArray(array) || n < 1) return array;
+ return array.slice(0, n);
+ });
+
+ // Slugify filter
+ eleventyConfig.addFilter("slugify", (str) => {
+ if (!str) return "";
+ return str
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, "")
+ .replace(/[\s_-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ });
+
+ eleventyConfig.addFilter("stripTrailingSlash", (url) => {
+ if (!url || typeof url !== "string") return url || "";
+ return url.endsWith("/") ? url.slice(0, -1) : url;
+ });
+
+ // Hash filter for cache busting - generates MD5 hash of file content
+ eleventyConfig.addFilter("hash", (filePath) => {
+ try {
+ const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
+ const content = readFileSync(fullPath);
+ return createHash("md5").update(content).digest("hex").slice(0, 8);
+ } catch {
+ // Return timestamp as fallback if file not found
+ return Date.now().toString(36);
+ }
+ });
+
+ // Derive OG slug from page.url (reliable) instead of page.fileSlug
+ // (which suffers from Nunjucks race conditions in Eleventy 3.x parallel rendering).
+ // OG images are named with the full date prefix to match URL segments exactly.
+ eleventyConfig.addFilter("ogSlug", (url) => {
+ if (!url) return "";
+ const segments = url.split("/").filter(Boolean);
+ // Date-based URL: /type/yyyy/MM/dd/slug/ → 5 segments → "yyyy-MM-dd-slug"
+ if (segments.length === 5) {
+ const [, year, month, day, slug] = segments;
+ return `${year}-${month}-${day}-${slug}`;
+ }
+ // Fallback: last segment (for pages, legacy URLs)
+ return segments[segments.length - 1] || "";
+ });
+
+ // Check if a generated OG image exists for this slug
+ eleventyConfig.addFilter("hasOgImage", (slug) => {
+ if (!slug) return false;
+ const ogPath = resolve(__dirname, ".cache", "og", `${slug}.png`);
+ return existsSync(ogPath);
+ });
+
+ // Inline file contents (for critical CSS inlining)
+ eleventyConfig.addFilter("inlineFile", (filePath) => {
+ try {
+ const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
+ return readFileSync(fullPath, "utf-8");
+ } catch {
+ return "";
+ }
+ });
+
+ // Extract raw Markdown body from a source file (strips front matter)
+ eleventyConfig.addFilter("rawMarkdownBody", (inputPath) => {
+ try {
+ const src = readFileSync(inputPath, "utf-8");
+ const { content } = matter(src);
+ return content.trim();
+ } catch {
+ return "";
+ }
+ });
+
+ // Current timestamp filter (for client-side JS buildtime)
+ eleventyConfig.addFilter("timestamp", () => Date.now());
+
+ // Date filter (for sidebar dates)
+ eleventyConfig.addFilter("date", (dateObj, format) => {
+ if (!dateObj) return "";
+ const date = new Date(dateObj);
+ const options = {};
+
+ if (format.includes("MMM")) options.month = "short";
+ if (format.includes("d")) options.day = "numeric";
+ if (format.includes("yyyy")) options.year = "numeric";
+
+ return date.toLocaleDateString("en-US", options);
+ });
+
+ // Webmention filters - with legacy URL support
+ // This filter checks both current URL and any legacy URLs from redirects
+ // Merges webmentions + conversations with deduplication (conversations first)
+ eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases, conversationMentions = []) {
+ if (!url) return [];
+
+ // Merge conversations + webmentions with deduplication
+ const seen = new Set();
+ const merged = [];
+
+ // Add conversations first (richer metadata)
+ for (const item of conversationMentions) {
+ const key = item['wm-id'] || item.url;
+ if (key && !seen.has(key)) {
+ seen.add(key);
+ merged.push(item);
+ }
+ }
+
+ // Add webmentions (skip duplicates)
+ if (webmentions) {
+ for (const item of webmentions) {
+ const key = item['wm-id'];
+ if (!key || seen.has(key)) continue;
+ if (item.url && seen.has(item.url)) continue;
+ seen.add(key);
+ merged.push(item);
+ }
+ }
+
+ // Build list of all URLs to check (current + legacy)
+ const urlsToCheck = new Set();
+
+ // Add current URL variations
+ const absoluteUrl = url.startsWith("http") ? url : `${siteUrl}${url}`;
+ urlsToCheck.add(absoluteUrl);
+ urlsToCheck.add(absoluteUrl.replace(/\/$/, ""));
+ urlsToCheck.add(absoluteUrl.endsWith("/") ? absoluteUrl : `${absoluteUrl}/`);
+
+ // Add legacy URLs from aliases (if provided)
+ if (urlAliases?.aliases) {
+ const normalizedUrl = url.replace(/\/$/, "");
+ const oldUrls = urlAliases.aliases[normalizedUrl] || [];
+ for (const oldUrl of oldUrls) {
+ urlsToCheck.add(`${siteUrl}${oldUrl}`);
+ urlsToCheck.add(`${siteUrl}${oldUrl}/`);
+ urlsToCheck.add(`${siteUrl}${oldUrl}`.replace(/\/$/, ""));
+ }
+ }
+
+ // Compute legacy /content/ URL from current URL for old webmention.io targets
+ // Pattern: /type/yyyy/MM/dd/slug/ → /content/type/yyyy-MM-dd-slug/
+ const pathSegments = url.replace(/\/$/, "").split("/").filter(Boolean);
+ if (pathSegments.length === 5) {
+ const [type, year, month, day, slug] = pathSegments;
+ const contentUrl = `/content/${type}/${year}-${month}-${day}-${slug}/`;
+ urlsToCheck.add(`${siteUrl}${contentUrl}`);
+ urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
+ }
+
+ // Filter merged data matching any of our URLs
+ const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));
+
+ // Deduplicate cross-source: same author + same interaction type = same mention
+ // (webmention.io and conversations API may both report the same like/reply)
+ const deduped = [];
+ const authorActions = new Set();
+ for (const wm of matched) {
+ const authorUrl = wm.author?.url || wm.url || "";
+ const action = wm["wm-property"] || "mention";
+ const key = `${authorUrl}::${action}`;
+ if (authorActions.has(key)) continue;
+ authorActions.add(key);
+ deduped.push(wm);
+ }
+ return deduped;
+ });
+
+ eleventyConfig.addFilter("webmentionsByType", function (mentions, type) {
+ if (!mentions) return [];
+ const typeMap = {
+ likes: "like-of",
+ reposts: "repost-of",
+ bookmarks: "bookmark-of",
+ replies: "in-reply-to",
+ mentions: "mention-of",
+ };
+ const wmProperty = typeMap[type] || type;
+ return mentions.filter((m) => m["wm-property"] === wmProperty);
+ });
+
+ // Post navigation — find previous/next post in a collection
+ // (Nunjucks {% set %} inside {% for %} doesn't propagate, so we need filters)
+ eleventyConfig.addFilter("previousInCollection", function (collection, page) {
+ if (!collection || !page) return null;
+ const index = collection.findIndex((p) => p.url === page.url);
+ return index > 0 ? collection[index - 1] : null;
+ });
+
+ eleventyConfig.addFilter("nextInCollection", function (collection, page) {
+ if (!collection || !page) return null;
+ const index = collection.findIndex((p) => p.url === page.url);
+ return index >= 0 && index < collection.length - 1
+ ? collection[index + 1]
+ : null;
+ });
+
+ // Posting frequency — compute posts-per-month for last 12 months (for sparkline).
+ // Returns an inline SVG that uses currentColor for stroke and a semi-transparent
+ // gradient fill. Wrap in a colored span to set the domain color via Tailwind.
+ eleventyConfig.addFilter("postingFrequency", (posts) => {
+ if (!Array.isArray(posts) || posts.length === 0) return "";
+ const now = new Date();
+ const counts = new Array(12).fill(0);
+ for (const post of posts) {
+ const postDate = new Date(post.date || post.data?.date);
+ if (isNaN(postDate.getTime())) continue;
+ const monthsAgo = (now.getFullYear() - postDate.getFullYear()) * 12 + (now.getMonth() - postDate.getMonth());
+ if (monthsAgo >= 0 && monthsAgo < 12) {
+ counts[11 - monthsAgo]++;
+ }
+ }
+
+ // Extrapolate the current (partial) month to avoid false downward trend.
+ // e.g. 51 posts in 5 days of a 31-day month projects to ~316.
+ const dayOfMonth = now.getDate();
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+ if (dayOfMonth < daysInMonth && counts[11] > 0) {
+ counts[11] = Math.round(counts[11] / dayOfMonth * daysInMonth);
+ }
+
+ const max = Math.max(...counts, 1);
+ const w = 200;
+ const h = 32;
+ const pad = 2;
+ const step = w / (counts.length - 1);
+ const points = counts.map((v, i) => {
+ const x = i * step;
+ const y = h - pad - ((v / max) * (h - pad * 2));
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(" ");
+ // Closed polygon for gradient fill (line path + bottom corners)
+ const fillPoints = `${points} ${w},${h} 0,${h}`;
+ return [
+ ``,
+ ``,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ].join("");
+ });
+
+ // Filter AI-involved posts (ai-text-level > "0" or aiTextLevel > "0")
+ eleventyConfig.addFilter("aiPosts", (posts) => {
+ if (!Array.isArray(posts)) return [];
+ return posts.filter((post) => {
+ const level = post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0";
+ return level !== "0" && level !== 0;
+ });
+ });
+
+ // AI stats — returns { total, aiCount, percentage, byLevel }
+ eleventyConfig.addFilter("aiStats", (posts) => {
+ if (!Array.isArray(posts)) return { total: 0, aiCount: 0, percentage: 0, byLevel: {} };
+ const total = posts.length;
+ const byLevel = { 0: 0, 1: 0, 2: 0, 3: 0 };
+ for (const post of posts) {
+ const level = parseInt(post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0", 10);
+ byLevel[level] = (byLevel[level] || 0) + 1;
+ }
+ const aiCount = total - byLevel[0];
+ return {
+ total,
+ aiCount,
+ percentage: total > 0 ? ((aiCount / total) * 100).toFixed(1) : "0",
+ byLevel,
+ };
+ });
+
+ // Helper: exclude drafts from collections
+ const isPublished = (item) => !item.data.draft;
+
+ // Collections for different post types
+ // Note: content path is content/ due to symlink structure
+ // "posts" shows ALL content types combined
+ eleventyConfig.addCollection("posts", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ eleventyConfig.addCollection("notes", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/notes/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ eleventyConfig.addCollection("articles", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/articles/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ eleventyConfig.addCollection("bookmarks", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/bookmarks/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ eleventyConfig.addCollection("photos", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/photos/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ eleventyConfig.addCollection("likes", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/likes/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date);
+ });
+
+ // Replies collection - posts with inReplyTo/in_reply_to property
+ // Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
+ eleventyConfig.addCollection("replies", function (collectionApi) {
+ return collectionApi
+ .getAll()
+ .filter((item) => isPublished(item) && (item.data.inReplyTo || item.data.in_reply_to))
+ .sort((a, b) => b.date - a.date);
+ });
+
+ // Reposts collection - posts with repostOf/repost_of property
+ // Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
+ eleventyConfig.addCollection("reposts", function (collectionApi) {
+ return collectionApi
+ .getAll()
+ .filter((item) => isPublished(item) && (item.data.repostOf || item.data.repost_of))
+ .sort((a, b) => b.date - a.date);
+ });
+
+ // Pages collection - root-level slash pages (about, now, uses, etc.)
+ // Includes both content/*.md (legacy) and content/pages/*.md (new post-type-page)
+ // Created via Indiekit's page post type
+ eleventyConfig.addCollection("pages", function (collectionApi) {
+ const rootPages = collectionApi.getFilteredByGlob("content/*.md");
+ const pagesDir = collectionApi.getFilteredByGlob("content/pages/*.md");
+ return [...rootPages, ...pagesDir]
+ .filter(page => isPublished(page) && !page.inputPath.includes('content.json') && !page.inputPath.includes('pages.json'))
+ .sort((a, b) => (a.data.title || a.data.name || "").localeCompare(b.data.title || b.data.name || ""));
+ });
+
+ // All content combined for homepage feed
+ eleventyConfig.addCollection("feed", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date)
+ .slice(0, 20);
+ });
+
+ // Categories collection - deduplicated by slug to avoid duplicate permalinks
+ eleventyConfig.addCollection("categories", function (collectionApi) {
+ const categoryMap = new Map(); // slug -> original name (first seen)
+ const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
+
+ collectionApi.getAll().filter(isPublished).forEach((item) => {
+ if (item.data.category) {
+ const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
+ cats.forEach((cat) => {
+ if (cat && typeof cat === 'string' && cat.trim()) {
+ const slug = slugify(cat.trim());
+ if (slug && !categoryMap.has(slug)) {
+ categoryMap.set(slug, cat.trim());
+ }
+ }
+ });
+ }
+ });
+ return [...categoryMap.values()].sort();
+ });
+
+ // 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));
+ });
+
+ // Recent posts for sidebar
+ eleventyConfig.addCollection("recentPosts", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/**/*.md")
+ .filter(isPublished)
+ .sort((a, b) => b.date - a.date)
+ .slice(0, 5);
+ });
+
+ // Featured posts — curated selection via `pinned: true` frontmatter
+ // Property named "pinned" to avoid conflict with "featured" (hero image) in MF2/Micropub
+ eleventyConfig.addCollection("featuredPosts", function (collectionApi) {
+ return collectionApi
+ .getFilteredByGlob("content/**/*.md")
+ .filter(isPublished)
+ .filter((item) => item.data.pinned === true || item.data.pinned === "true")
+ .sort((a, b) => b.date - a.date);
+ });
+
+ // 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);
+
+ // ISO week helpers
+ const getISOWeek = (date) => {
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+ return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
+ };
+ const getISOYear = (date) => {
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+ return d.getUTCFullYear();
+ };
+
+ // Group by ISO week
+ const weekMap = new Map();
+
+ for (const post of allPosts) {
+ const d = new Date(post.date);
+ 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 jan4 = new Date(Date.UTC(year, 0, 4));
+ const dayOfWeek = jan4.getUTCDay() || 7;
+ const monday = new Date(jan4);
+ monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7);
+ const sunday = new Date(monday);
+ sunday.setUTCDate(monday.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);
+ }
+
+ // Post type detection (matches blog.njk logic)
+ 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";
+ };
+
+ // Build byType for each week and convert to array
+ 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;
+ });
+
+ // Generate OpenGraph images for posts without photos
+ // Runs on every build (including watcher rebuilds) — manifest caching makes it fast
+ // for incremental: only new posts without an OG image get generated (~200ms each)
+ eleventyConfig.on("eleventy.before", () => {
+ const contentDir = resolve(__dirname, "content");
+ const cacheDir = resolve(__dirname, ".cache");
+ const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
+ try {
+ execFileSync(process.execPath, [
+ "--max-old-space-size=768",
+ resolve(__dirname, "lib", "og-cli.js"),
+ contentDir,
+ cacheDir,
+ siteName,
+ ], {
+ stdio: "inherit",
+ env: { ...process.env, NODE_OPTIONS: "" },
+ });
+
+ // Sync new OG images to output directory.
+ // During incremental builds, .cache/og is in watchIgnores so Eleventy's
+ // passthrough copy won't pick up newly generated images. Copy them manually.
+ const ogCacheDir = resolve(cacheDir, "og");
+ const ogOutputDir = resolve(__dirname, "_site", "og");
+ if (existsSync(ogCacheDir) && existsSync(resolve(__dirname, "_site"))) {
+ mkdirSync(ogOutputDir, { recursive: true });
+ let synced = 0;
+ for (const file of readdirSync(ogCacheDir)) {
+ if (file.endsWith(".png") && !existsSync(resolve(ogOutputDir, file))) {
+ copyFileSync(resolve(ogCacheDir, file), resolve(ogOutputDir, file));
+ synced++;
+ }
+ }
+ if (synced > 0) {
+ console.log(`[og] Synced ${synced} new image(s) to output`);
+ }
+ }
+ } catch (err) {
+ console.error("[og] Image generation failed:", err.message);
+ }
+ });
+
+ // Pre-fetch unfurl metadata for all interaction URLs in content files.
+ // Populates the disk cache BEFORE templates render, so the synchronous
+ // unfurlCard filter (used in nested includes like recent-posts) has data.
+ eleventyConfig.on("eleventy.before", async () => {
+ const contentDir = resolve(__dirname, "content");
+ if (!existsSync(contentDir)) return;
+
+ const urls = new Set();
+ const interactionProps = [
+ "likeOf", "like_of", "bookmarkOf", "bookmark_of",
+ "repostOf", "repost_of", "inReplyTo", "in_reply_to",
+ ];
+
+ const walk = (dir) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = resolve(dir, entry.name);
+ if (entry.isDirectory()) { walk(full); continue; }
+ if (!entry.name.endsWith(".md")) continue;
+ try {
+ const { data } = matter(readFileSync(full, "utf-8"));
+ for (const prop of interactionProps) {
+ if (data[prop]) urls.add(data[prop]);
+ }
+ } catch { /* skip unparseable files */ }
+ }
+ };
+ walk(contentDir);
+
+ if (urls.size === 0) return;
+ console.log(`[unfurl] Pre-fetching ${urls.size} interaction URLs...`);
+ await Promise.all([...urls].map((url) => prefetchUrl(url)));
+ console.log(`[unfurl] Pre-fetch complete.`);
+ });
+
+ // Post-build hook: pagefind indexing + WebSub notification
+ // Pagefind runs once on the first build (initial or watcher's first full build), then never again.
+ // WebSub runs on every non-incremental build.
+ // Note: --incremental CLI flag sets incremental=true even for the watcher's first full build,
+ // so we cannot use the incremental flag to guard pagefind. Use a one-shot flag instead.
+ let pagefindDone = false;
+ eleventyConfig.on("eleventy.after", async ({ dir, directories, runMode, incremental }) => {
+ // Markdown for Agents — generate index.md alongside index.html for articles
+ const mdEnabled = (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true";
+ if (mdEnabled && !incremental) {
+ const outputDir = directories?.output || dir.output;
+ const contentDir = resolve(__dirname, "content/articles");
+ const aiTrain = process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes";
+ const search = process.env.MARKDOWN_AGENTS_SEARCH || "yes";
+ const aiInput = process.env.MARKDOWN_AGENTS_AI_INPUT || "yes";
+ const authorName = process.env.AUTHOR_NAME || "Blog Author";
+ let mdCount = 0;
+ try {
+ const files = readdirSync(contentDir).filter(f => f.endsWith(".md"));
+ for (const file of files) {
+ const src = readFileSync(resolve(contentDir, file), "utf-8");
+ const { data: fm, content: body } = matter(src);
+ if (!fm || fm.draft) continue;
+ // Derive the output path from the article's permalink or url
+ const articleUrl = fm.permalink || fm.url;
+ if (!articleUrl || !articleUrl.startsWith("/articles/")) continue;
+ const mdDir = resolve(outputDir, articleUrl.replace(/^\//, "").replace(/\/$/, ""));
+ const mdPath = resolve(mdDir, "index.md");
+ const trimmedBody = body.trim();
+ const tokens = Math.ceil(trimmedBody.length / 4);
+ const title = (fm.title || "").replace(/"/g, '\\"');
+ const date = fm.date ? new Date(fm.date).toISOString() : fm.published || "";
+ let frontLines = [
+ "---",
+ `title: "${title}"`,
+ `date: ${date}`,
+ `author: ${authorName}`,
+ `url: ${siteUrl}${articleUrl}`,
+ ];
+ if (fm.category && Array.isArray(fm.category) && fm.category.length > 0) {
+ frontLines.push("categories:");
+ for (const cat of fm.category) {
+ frontLines.push(` - ${cat}`);
+ }
+ }
+ if (fm.description) {
+ frontLines.push(`description: "${fm.description.replace(/"/g, '\\"')}"`);
+ }
+ frontLines.push(`tokens: ${tokens}`);
+ frontLines.push(`content_signal: ai-train=${aiTrain}, search=${search}, ai-input=${aiInput}`);
+ frontLines.push("---");
+ mkdirSync(mdDir, { recursive: true });
+ writeFileSync(mdPath, frontLines.join("\n") + "\n\n# " + (fm.title || "") + "\n\n" + trimmedBody + "\n");
+ mdCount++;
+ }
+ console.log(`[markdown-agents] Generated ${mdCount} article .md files`);
+ } catch (err) {
+ console.error("[markdown-agents] Error generating .md files:", err.message);
+ }
+ }
+
+ // Pagefind indexing — run exactly once per process lifetime
+ if (!pagefindDone) {
+ pagefindDone = true;
+ const outputDir = directories?.output || dir.output;
+ try {
+ console.log(`[pagefind] Indexing ${outputDir} (${runMode})...`);
+ execFileSync("npx", ["pagefind", "--site", outputDir, "--output-subdir", "pagefind", "--glob", "**/*.html"], {
+ stdio: "inherit",
+ timeout: 120000,
+ });
+ console.log("[pagefind] Indexing complete");
+ } catch (err) {
+ console.error("[pagefind] Indexing failed:", err.message);
+ }
+
+ }
+
+ // Syndication webhook — trigger after incremental rebuilds (new posts are now live)
+ // Cuts syndication latency from ~2 min (poller) to ~5 sec (immediate trigger)
+ if (incremental) {
+ const syndicateUrl = process.env.SYNDICATE_WEBHOOK_URL;
+ if (syndicateUrl) {
+ try {
+ const secretFile = process.env.SYNDICATE_SECRET_FILE || "/app/data/config/.secret";
+ const secret = readFileSync(secretFile, "utf-8").trim();
+
+ // Build a minimal HS256 JWT using built-in crypto (no jsonwebtoken dependency)
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
+ const now = Math.floor(Date.now() / 1000);
+ const payload = Buffer.from(JSON.stringify({
+ me: siteUrl,
+ scope: "update",
+ iat: now,
+ exp: now + 300, // 5 minutes
+ })).toString("base64url");
+ const signature = createHmac("sha256", secret)
+ .update(`${header}.${payload}`)
+ .digest("base64url");
+ const token = `${header}.${payload}.${signature}`;
+
+ const res = await fetch(`${syndicateUrl}?token=${token}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ signal: AbortSignal.timeout(30000),
+ });
+ console.log(`[syndicate-hook] Triggered syndication: ${res.status}`);
+ } catch (err) {
+ console.error(`[syndicate-hook] Failed:`, err.message);
+ }
+ }
+ }
+
+ // 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);
+ }
+ }
+ });
+
+ return {
+ dir: {
+ input: ".",
+ output: "_site",
+ includes: "_includes",
+ data: "_data",
+ },
+ markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content
+ htmlTemplateEngine: "njk",
+ };
+}
diff --git a/theme/featured.njk b/theme/featured.njk
new file mode 100644
index 0000000..5e66927
--- /dev/null
+++ b/theme/featured.njk
@@ -0,0 +1,186 @@
+---
+layout: layouts/base.njk
+title: Featured
+withSidebar: true
+pagination:
+ data: collections.featuredPosts
+ size: 20
+ alias: paginatedFeatured
+ generatePageOnEmptyData: true
+permalink: "featured/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
+---
+
+
+
+ Curated posts pinned as featured.
+ ({{ collections.featuredPosts.length }} total)
+
+
+ {% if paginatedFeatured.length > 0 %}
+
+ {% for post in paginatedFeatured %}
+ {# Detect post type from frontmatter properties #}
+ {% 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 %}
+
+ {# Determine border color by post type #}
+ {% if likedUrl %}
+ {% set borderClass = "border-l-red-400 dark:border-l-red-500" %}
+ {% elif bookmarkedUrl %}
+ {% set borderClass = "border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif repostedUrl %}
+ {% set borderClass = "border-l-green-400 dark:border-l-green-500" %}
+ {% elif replyToUrl %}
+ {% set borderClass = "border-l-sky-400 dark:border-l-sky-500" %}
+ {% elif hasPhotos %}
+ {% set borderClass = "border-l-purple-400 dark:border-l-purple-500" %}
+ {% else %}
+ {% set borderClass = "border-l-surface-300 dark:border-l-surface-600" %}
+ {% endif %}
+
+
+
+ {% if likedUrl %}
+ {# ── Like ── #}
+
+ {{ likedUrl }}
+ {% if post.templateContent %}
+ {{ post.templateContent | safe }}
+ {% endif %}
+
+ {% elif bookmarkedUrl %}
+ {# ── Bookmark ── #}
+
+ {% if post.data.title %}
+
+ {% endif %}
+ {{ bookmarkedUrl }}
+ {% if post.templateContent %}
+ {{ post.templateContent | safe }}
+ {% endif %}
+
+ {% elif repostedUrl %}
+ {# ── Repost ── #}
+
+ {{ repostedUrl }}
+ {% if post.templateContent %}
+ {{ post.templateContent | safe }}
+ {% endif %}
+
+ {% elif replyToUrl %}
+ {# ── Reply ── #}
+
+ {{ replyToUrl }}
+ {% if post.templateContent %}
+ {{ post.templateContent | safe }}
+ {% endif %}
+
+ {% elif post.data.title %}
+ {# ── Article/Page ── #}
+
+
+ {{ post.date | dateDisplay }}
+ {% if post.data.postType %}
+ {{ post.data.postType }}
+ {% endif %}
+
+ {% if post.templateContent %}
+ {{ post.templateContent | striptags | truncate(250) }}
+ {% endif %}
+ Read more →
+
+ {% else %}
+ {# ── Note ── #}
+
+ {% if post.templateContent %}
+ {{ post.templateContent | safe }}
+ {% endif %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {# Pagination controls #}
+ {% if pagination.pages.length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+ {% set postType = "featured" %}
+ {% include "components/empty-collection.njk" %}
+ {% endif %}
+
diff --git a/theme/feed-json.njk b/theme/feed-json.njk
new file mode 100644
index 0000000..aa9a6e3
--- /dev/null
+++ b/theme/feed-json.njk
@@ -0,0 +1,66 @@
+---
+permalink: /feed.json
+eleventyExcludeFromCollections: true
+eleventyImport:
+ collections:
+ - feed
+---
+{
+ "version": "https://jsonfeed.org/version/1.1",
+ "title": "{{ site.name }}",
+ "home_page_url": "{{ site.url }}/",
+ "feed_url": "{{ site.url }}/feed.json",
+ "hubs": [
+ {
+ "type": "WebSub",
+ "url": "https://websubhub.com/hub"
+ }
+ ],
+ "description": "{{ site.description }}",
+ "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 collections.feed %}
+ {%- set absolutePostUrl = site.url + post.url %}
+ {%- set postImage = post.data.photo %}
+ {%- if postImage %}
+ {# If photo is an array, use first element (check if first element looks like a URL) #}
+ {%- 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 %}
+ ]
+}
diff --git a/theme/feed.njk b/theme/feed.njk
new file mode 100644
index 0000000..0fdfab4
--- /dev/null
+++ b/theme/feed.njk
@@ -0,0 +1,44 @@
+---
+permalink: /feed.xml
+eleventyExcludeFromCollections: true
+eleventyImport:
+ collections:
+ - feed
+---
+
+
+
+ {{ site.name }}
+ {{ site.url }}/
+ {{ site.description }}
+ {{ site.locale | default('en') }}
+
+
+ {{ collections.feed | getNewestCollectionItemDate | dateToRfc822 }}
+ {%- for post in collections.feed %}
+ {%- set absolutePostUrl = site.url + post.url %}
+ {%- set postImage = post.data.photo %}
+ {%- if postImage %}
+ {# If photo is an array, use first element (check if first element looks like a URL) #}
+ {%- 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 %}
+
+
diff --git a/theme/funkwhale.njk b/theme/funkwhale.njk
new file mode 100644
index 0000000..8694d8f
--- /dev/null
+++ b/theme/funkwhale.njk
@@ -0,0 +1,269 @@
+---
+layout: layouts/base.njk
+title: Funkwhale Listening Activity
+permalink: /funkwhale/
+withSidebar: true
+---
+
+
+
+ {# Now Playing / Recently Played Hero #}
+ {% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status %}
+
+
+
+ {% if funkwhaleActivity.nowPlaying.coverUrl %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}
+
+
+
+
+
+
+ Now Playing
+
+ {% else %}
+
+
+ Recently Played
+
+ {% endif %}
+
+
+
+ {% if funkwhaleActivity.nowPlaying.trackUrl %}
+
+ {{ funkwhaleActivity.nowPlaying.track }}
+
+ {% else %}
+ {{ funkwhaleActivity.nowPlaying.track }}
+ {% endif %}
+
+
{{ funkwhaleActivity.nowPlaying.artist }}
+ {% if funkwhaleActivity.nowPlaying.album %}
+
{{ funkwhaleActivity.nowPlaying.album }}
+ {% endif %}
+
{{ funkwhaleActivity.nowPlaying.relativeTime }}
+
+
+
+
+ {% endif %}
+
+ {# Stats Section with Tabs #}
+ {% if funkwhaleActivity.stats %}
+
+
+
+
+
+ Listening Statistics
+
+
+ {# Tab buttons #}
+
+
+ All Time
+
+
+ This Month
+
+
+ This Week
+
+
+ Trends
+
+
+
+ {# All Time Tab #}
+
+ {% set summary = funkwhaleActivity.stats.summary.all %}
+ {% set topArtists = funkwhaleActivity.stats.topArtists.all %}
+ {% set topAlbums = funkwhaleActivity.stats.topAlbums.all %}
+ {% include "components/funkwhale-stats-content.njk" %}
+
+
+ {# This Month Tab #}
+
+ {% set summary = funkwhaleActivity.stats.summary.month %}
+ {% set topArtists = funkwhaleActivity.stats.topArtists.month %}
+ {% set topAlbums = funkwhaleActivity.stats.topAlbums.month %}
+ {% include "components/funkwhale-stats-content.njk" %}
+
+
+ {# This Week Tab #}
+
+ {% set summary = funkwhaleActivity.stats.summary.week %}
+ {% set topArtists = funkwhaleActivity.stats.topArtists.week %}
+ {% set topAlbums = funkwhaleActivity.stats.topAlbums.week %}
+ {% include "components/funkwhale-stats-content.njk" %}
+
+
+ {# Trends Tab #}
+
+ {% if funkwhaleActivity.stats.trends and funkwhaleActivity.stats.trends.length %}
+
+
Daily Listening (Last 30 Days)
+
+ {% set maxCount = 1 %}
+ {% for day in funkwhaleActivity.stats.trends %}
+ {% if day.count > maxCount %}
+ {% set maxCount = day.count %}
+ {% endif %}
+ {% endfor %}
+ {% for day in funkwhaleActivity.stats.trends %}
+
+ {% endfor %}
+
+
+ {{ funkwhaleActivity.stats.trends[0].date }}
+ {{ funkwhaleActivity.stats.trends[funkwhaleActivity.stats.trends.length - 1].date }}
+
+
+ {% else %}
+
No trend data available yet.
+ {% endif %}
+
+
+ {% endif %}
+
+ {# Recent Listenings #}
+
+
+
+
+
+ Recent Listens
+
+
+ {% if funkwhaleActivity.listenings.length %}
+
+ {% for listening in funkwhaleActivity.listenings | head(15) %}
+
+ {% if listening.coverUrl %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% if listening.trackUrl %}
+
+ {{ listening.track }}
+
+ {% else %}
+ {{ listening.track }}
+ {% endif %}
+
+
{{ listening.artist }}
+
+
+
+ {{ listening.relativeTime }}
+ {% if listening.duration %}
+ {{ listening.duration }}
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+ No recent listening history available.
+ {% endif %}
+
+
+ {# Favorites #}
+ {% if funkwhaleActivity.favorites.length %}
+
+
+
+
+
+ Favorite Tracks
+
+
+
+ {% for favorite in funkwhaleActivity.favorites | head(10) %}
+
+ {% if favorite.coverUrl %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% if favorite.trackUrl %}
+
+ {{ favorite.track }}
+
+ {% else %}
+ {{ favorite.track }}
+ {% endif %}
+
+
{{ favorite.artist }}
+ {% if favorite.album %}
+
{{ favorite.album }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
diff --git a/theme/github.njk b/theme/github.njk
new file mode 100644
index 0000000..48cdd64
--- /dev/null
+++ b/theme/github.njk
@@ -0,0 +1,270 @@
+---
+layout: layouts/base.njk
+title: GitHub Activity
+permalink: /github/
+withSidebar: true
+---
+
+
+
+ {# Featured Projects Section #}
+ {% if githubActivity.featured.length %}
+
+
+
+
+
+ Featured Projects
+
+
+
+ {% for repo in githubActivity.featured %}
+
+
+
+ {% if repo.isPrivate %}
+
Private
+ {% endif %}
+
+
+ {% if repo.description %}
+ {{ repo.description }}
+ {% endif %}
+
+
+ {% if repo.language %}
+
+
+ {{ repo.language }}
+
+ {% endif %}
+
+
+
+
+ {{ repo.stars }}
+
+ {% if repo.forks > 0 %}
+
+
+
+
+ {{ repo.forks }}
+
+ {% endif %}
+
+
+ {% if repo.commits and repo.commits.length %}
+
+
+ Recent commits ({{ repo.commits.length }})
+
+
+ {% for commit in repo.commits %}
+
+
+ {{ commit.sha }}
+
+ {{ commit.message }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {# Recent Commits Section #}
+
+
+
+
+
+ Recent Commits
+
+
+ {% if githubActivity.commits.length %}
+
+ {% for commit in githubActivity.commits %}
+
+
+ {{ commit.sha }}
+
+
+
+ {% endfor %}
+
+ {% else %}
+ No recent commits found.
+ {% endif %}
+
+
+ {# Contributions Section (PRs & Issues) #}
+ {% if githubActivity.contributions.length %}
+
+
+
+
+
+ Pull Requests & Issues
+
+
+
+ {% for item in githubActivity.contributions %}
+
+ {% if item.type == "pr" %}
+
PR
+ {% else %}
+
Issue
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {# My Repositories Section #}
+
+
+
+
+
+ My Repositories
+
+
+ {% if githubRepos.length %}
+
+ {% for repo in githubRepos | head(6) %}
+
+
+
+ {% if repo.description %}
+ {{ repo.description | truncate(100) }}
+ {% endif %}
+
+
+ {% if repo.language %}
+
+
+ {{ repo.language }}
+
+ {% endif %}
+
+
+
+
+ {{ repo.stargazers_count }}
+
+
+
+
+
+ {{ repo.forks_count }}
+
+
+
+ {% endfor %}
+
+
+
+ View all repositories →
+
+ {% else %}
+ No repositories found.
+ {% endif %}
+
+
+ {# Starred Repos Section #}
+
+
+
+
+
+ Starred Repositories
+
+
+ {% if githubActivity.stars.length %}
+
+ {% for repo in githubActivity.stars | head(10) %}
+
+
+
+ {% if repo.description %}
+ {{ repo.description }}
+ {% endif %}
+
+
+ {% for topic in repo.topics %}
+
+ {{ topic }}
+
+ {% endfor %}
+
+
+
+ {% if repo.language %}
+
+
+ {{ repo.language }}
+
+ {% endif %}
+
+
+
+
+ {{ repo.stars }}
+
+
+
+ {% endfor %}
+
+
+
+ View all {{ githubStarred.totalCount | default(githubActivity.stars | length) }} starred repos →
+
+ {% else %}
+ No starred repositories found.
+ {% endif %}
+
+
diff --git a/theme/graph.njk b/theme/graph.njk
new file mode 100644
index 0000000..e6ef5e6
--- /dev/null
+++ b/theme/graph.njk
@@ -0,0 +1,18 @@
+---
+layout: layouts/base.njk
+title: Posting Activity
+permalink: /graph/
+withSidebar: true
+---
+
+Posting Activity
+
+
+ A contribution-style graph showing posting frequency across all years.
+
+
+{% if collections.posts and collections.posts.length %}
+ {% postGraph collections.posts, { limit: 0 } %}
+{% else %}
+ No posts found.
+{% endif %}
diff --git a/theme/images/default-avatar.svg b/theme/images/default-avatar.svg
new file mode 100644
index 0000000..3b15f50
--- /dev/null
+++ b/theme/images/default-avatar.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/theme/images/favicon.svg b/theme/images/favicon.svg
new file mode 100644
index 0000000..c15703d
--- /dev/null
+++ b/theme/images/favicon.svg
@@ -0,0 +1,4 @@
+
+
+ i
+
diff --git a/theme/images/og-default.png b/theme/images/og-default.png
new file mode 100644
index 0000000000000000000000000000000000000000..bfaee94a7da84174fe53902a1912582cfe5f531e
GIT binary patch
literal 39412
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
literal 0
HcmV?d00001
diff --git a/theme/images/rick.jpg b/theme/images/rick.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..f976d6d16964900a192ce37d14e2148b3de14ee6
GIT binary patch
literal 152493
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
+
+ ++ Comments + +
+ +Sign in with your website to comment:
+ +Loading comments...
+ + + +No comments yet. Be the first to share your thoughts!
+ +