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 #} +
+
+ + +
+ {# Widget content — inner .widget provides padding, inner title hidden by CSS #} + {% if widget.type == "author-card-compact" %} + {% include "components/widgets/author-card-compact.njk" %} + {% elif widget.type == "author-card" %} + {% include "components/widgets/author-card.njk" %} + {% elif widget.type == "toc" %} + {% include "components/widgets/toc.njk" %} + {% elif widget.type == "post-categories" %} + {% include "components/widgets/post-categories.njk" %} + {% elif widget.type == "recent-posts" %} + {% include "components/widgets/recent-posts-blog.njk" %} + {% elif widget.type == "webmentions" %} + {% include "components/widgets/webmentions.njk" %} + {% elif widget.type == "share" %} + {% include "components/widgets/share.njk" %} + {% elif widget.type == "subscribe" %} + {% include "components/widgets/subscribe.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 == "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 == "recent-comments" %} + {% include "components/widgets/recent-comments.njk" %} + {% elif widget.type == "search" %} + {% include "components/widgets/search.njk" %} + {% elif widget.type == "fediverse-follow" %} + {% include "components/widgets/fediverse-follow.njk" %} + {% elif widget.type == "custom-html" %} + {% set wConfig = widget.config or {} %} + +
+ {% if wConfig.content %} +
+ {{ wConfig.content | safe }} +
+ {% endif %} +
+
+ {% else %} + + {% endif %} +
+
+
+ + {% 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" %} +
+
+ +
+ {% include "components/widgets/author-card-compact.njk" %} +
+
+
+ + {# Table of Contents #} + {% set widgetKey = "post-fb-toc" %} +
+
+ +
+ {% include "components/widgets/toc.njk" %} +
+
+
+ + {# Post Categories #} + {% set widgetKey = "post-fb-post-categories" %} +
+
+ +
+ {% include "components/widgets/post-categories.njk" %} +
+
+
+ + {# Recent Posts #} + {% set widgetKey = "post-fb-recent-posts" %} +
+
+ +
+ {% include "components/widgets/recent-posts-blog.njk" %} +
+
+
+ + {# Webmentions #} + {% set widgetKey = "post-fb-webmentions" %} +
+
+ +
+ {% include "components/widgets/webmentions.njk" %} +
+
+
+ + {# Share #} + {% set widgetKey = "post-fb-share" %} +
+
+ +
+ {% include "components/widgets/share.njk" %} +
+
+
+ + {# Subscribe #} + {% set widgetKey = "post-fb-subscribe" %} +
+
+ +
+ {% include "components/widgets/subscribe.njk" %} +
+
+
+ + {# Recent Comments #} + {% set widgetKey = "post-fb-recent-comments" %} +
+
+ +
+ {% include "components/widgets/recent-comments.njk" %} +
+
+
+{% 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 %} + + +
+ +
+ +

+ Comments + +

+ +
+ +
+ {# Status messages #} +
+ +
+ + {# Sign-in form (shown when not authenticated) #} +
+

Sign in with your website to comment:

+
+
+ + +
+ +
+
+ + {# Comment form (shown when authenticated) #} +
+
+ Signed in as + + +
+ +
+ +
+ + +
+
+
+ + {# Comment list #} +
+ + + + + +
+
+
+
+
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 }} +
+

+ {{ 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 %} +
+ {% for link in socialLinks %} + + {{ socialIcon(link.icon, "w-5 h-5") }} + {{ link.name }} + + {% endfor %} +
+ {% 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 #} +
+
+
+ {% for section in cvPageConfig.sections %} + {% include "components/homepage-section.njk" %} + {% endfor %} +
+
+ +
+ +{% elif layout == "full-width-hero" %} + + {# Full width hero (already rendered above), then two-column below #} + {% if hasSidebar %} +
+
+
+ {% for section in cvPageConfig.sections %} + {% include "components/homepage-section.njk" %} + {% endfor %} +
+
+ +
+ {% 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: +

+{% 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 {} %} + +
+ {% if wConfig.title %} +

{{ wConfig.title }}

+ {% endif %} + {% if wConfig.content %} +
+ {{ wConfig.content | safe }} +
+ {% endif %} +
+
+ {% 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 #} + 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 %} + + {% 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 #} +
+
+
+ {% for section in homepageConfig.sections %} + {% if section.type != "hero" %} + {% include "components/homepage-section.njk" %} + {% endif %} + {% endfor %} +
+
+ +
+ +{% elif layout == "full-width-hero" %} + + {# Full width hero (already rendered above), then two-column below #} + {% if hasSidebar %} +
+
+
+ {% for section in homepageConfig.sections %} + {% if section.type != "hero" %} + {% include "components/homepage-section.njk" %} + {% endif %} + {% endfor %} +
+
+ +
+ {% 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 #} +
+
+ + +
+ {# Widget content — inner .widget provides padding, inner title hidden by CSS #} + {% if widget.type == "author-card" %} + {% include "components/widgets/author-card.njk" %} + {% elif widget.type == "social-activity" %} + {% include "components/widgets/social-activity.njk" %} + {% elif widget.type == "github-repos" %} + {% include "components/widgets/github-repos.njk" %} + {% elif widget.type == "funkwhale" %} + {% include "components/widgets/funkwhale.njk" %} + {% elif widget.type == "recent-posts" %} + {% include "components/widgets/recent-posts.njk" %} + {% elif widget.type == "blogroll" %} + {% include "components/widgets/blogroll.njk" %} + {% elif widget.type == "feedland" %} + {% include "components/widgets/feedland.njk" %} + {% elif widget.type == "categories" %} + {% include "components/widgets/categories.njk" %} + {% elif widget.type == "search" %} + {% include "components/widgets/search.njk" %} + {% elif widget.type == "webmentions" %} + {% include "components/widgets/webmentions.njk" %} + {% elif widget.type == "recent-comments" %} + {% include "components/widgets/recent-comments.njk" %} + {% elif widget.type == "fediverse-follow" %} + {% include "components/widgets/fediverse-follow.njk" %} + {% elif widget.type == "custom-html" %} + {% set wConfig = widget.config or {} %} + +
+ {% if wConfig.content %} +
+ {{ wConfig.content | safe }} +
+ {% endif %} +
+
+ {% else %} + + {% endif %} +
+
+
+ + {% 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 %} + +{% 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 %} + +{% 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 #} + + + {# 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 #} + + + {# 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 #} + + + {# 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 #} + + + {# 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 + +
+ {{ likedUrl | unfurlCard | safe }} + + {{ likedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif bookmarkedUrl %} + {# ── Bookmark card ── #} +
+
+ +
+
+
+ Bookmarked + +
+ {% if post.data.title %} +

+ {{ post.data.title }} +

+ {% endif %} + {{ bookmarkedUrl | unfurlCard | safe }} + + {{ bookmarkedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif repostedUrl %} + {# ── Repost card ── #} +
+
+ +
+
+
+ Reposted + +
+ {{ repostedUrl | unfurlCard | safe }} + + {{ repostedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif replyToUrl %} + {# ── Reply card ── #} +
+
+ +
+
+
+ In reply to + +
+ {{ replyToUrl | unfurlCard | safe }} + + {{ replyToUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif hasPhotos %} + {# ── Photo card ── #} +
+
+ +
+
+
+ Photo + +
+ + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif post.data.title %} + {# ── Article/Page card ── #} +

+ + {{ post.data.title }} + +

+ {% if showSummary and post.templateContent %} +

+ {{ post.templateContent | striptags | truncate(250) }} +

+ {% endif %} +
+ + {% if post.data.postType %} + + {{ post.data.postType }} + + {% endif %} +
+ + {% else %} + {# ── Note card ── #} +
+ + + + {% 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 %} + {{ authorName }} + {% 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 %} +
+ {% for link in socialLinks %} + + {{ socialIcon(link.icon, "w-5 h-5") }} + {{ link.name }} + + {% endfor %} +
+ {% 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 + +
+ {{ likedUrl | unfurlCard | safe }} + + {{ likedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif bookmarkedUrl %} + {# ── Bookmark card ── #} +
+
+ +
+
+
+ Bookmarked + +
+ {% if post.data.title %} +

+ {{ post.data.title }} +

+ {% endif %} + {{ bookmarkedUrl | unfurlCard | safe }} + + {{ bookmarkedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif repostedUrl %} + {# ── Repost card ── #} +
+
+ +
+
+
+ Reposted + +
+ {{ repostedUrl | unfurlCard | safe }} + + {{ repostedUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif replyToUrl %} + {# ── Reply card ── #} +
+
+ +
+
+
+ In reply to + +
+ {{ replyToUrl | unfurlCard | safe }} + + {{ replyToUrl }} + + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif hasPhotos %} + {# ── Photo card ── #} +
+
+ +
+
+
+ Photo + +
+ + {% if post.templateContent %} +
+ {{ post.templateContent | safe }} +
+ {% endif %} + Permalink +
+
+ + {% elif post.data.title %} + {# ── Article card ── #} +

+ + {{ post.data.title }} + +

+ {% if showSummary and post.templateContent %} +

+ {{ post.templateContent | striptags | truncate(250) }} +

+ {% endif %} +
+ + {% if post.data.postType %} + + {{ post.data.postType }} + + {% endif %} +
+ + {% else %} + {# ── Note card ── #} +
+ + + + {% 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 #} +
+
+ + +
+ {# Widget content — inner .widget provides padding, inner title hidden by CSS #} + {% if widget.type == "author-card" %} + {% include "components/widgets/author-card.njk" %} + {% elif widget.type == "author-card-compact" %} + {% include "components/widgets/author-card-compact.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" %} + {% if blogrollStatus and blogrollStatus.source == "indiekit" %} + {% include "components/widgets/blogroll.njk" %} + {% endif %} + {% elif widget.type == "feedland" %} + {% include "components/widgets/feedland.njk" %} + {% elif widget.type == "categories" %} + {% include "components/widgets/categories.njk" %} + {% elif widget.type == "subscribe" %} + {% include "components/widgets/subscribe.njk" %} + {% elif widget.type == "recent-comments" %} + {% include "components/widgets/recent-comments.njk" %} + {% elif widget.type == "search" %} + {% include "components/widgets/search.njk" %} + {% elif widget.type == "webmentions" %} + {% include "components/widgets/webmentions.njk" %} + {% elif widget.type == "fediverse-follow" %} + {% include "components/widgets/fediverse-follow.njk" %} + {% elif widget.type == "custom-html" %} + {% set wConfig = widget.config or {} %} + +
+ {% if wConfig.content %} +
+ {{ wConfig.content | safe }} +
+ {% endif %} +
+
+ {% else %} + + {% endif %} +
+
+
+ + {% 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" %} +
+
+ +
+ {% include "components/widgets/author-card.njk" %} +
+
+
+ + {# Social Activity — Bluesky/Mastodon feeds #} + {% set widgetKey = "listing-fb-social-activity" %} +
+
+ +
+ {% include "components/widgets/social-activity.njk" %} +
+
+
+ + {# GitHub Repos #} + {% set widgetKey = "listing-fb-github-repos" %} +
+
+ +
+ {% include "components/widgets/github-repos.njk" %} +
+
+
+ + {# Funkwhale — Now Playing / Listening Stats #} + {% set widgetKey = "listing-fb-funkwhale" %} +
+
+ +
+ {% include "components/widgets/funkwhale.njk" %} +
+
+
+ + {# Recent Posts #} + {% set widgetKey = "listing-fb-recent-posts" %} +
+
+ +
+ {% include "components/widgets/recent-posts.njk" %} +
+
+
+ + {# Blogroll — only when backend is available #} + {% if blogrollStatus and blogrollStatus.source == "indiekit" %} + {% set widgetKey = "listing-fb-blogroll" %} +
+
+ +
+ {% include "components/widgets/blogroll.njk" %} +
+
+
+ {% endif %} + + {# FeedLand — only when backend is available #} + {% if blogrollStatus and blogrollStatus.source == "indiekit" %} + {% set widgetKey = "listing-fb-feedland" %} +
+
+ +
+ {% include "components/widgets/feedland.njk" %} +
+
+
+ {% endif %} + + {# Recent Comments #} + {% set widgetKey = "listing-fb-recent-comments" %} +
+
+ +
+ {% include "components/widgets/recent-comments.njk" %} +
+
+
+ + {# Categories/Tags #} + {% set widgetKey = "listing-fb-categories" %} +
+
+ +
+ {% include "components/widgets/categories.njk" %} +
+
+
+{% 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 %} + + {{ like.author.name }} + + {% endfor %} +
+
+
+ {% endif %} + + {# Reposts #} + {% set reposts = mentions | webmentionsByType('reposts') %} + {% if reposts.length %} +
+

+ {{ reposts.length }} Repost{% if reposts.length != 1 %}s{% endif %} +

+ +
+ {% for repost in reposts %} + + {{ repost.author.name }} + + {% endfor %} +
+
+
+ {% endif %} + + {# Bookmarks #} + {% set bookmarks = mentions | webmentionsByType('bookmarks') %} + {% if bookmarks.length %} +
+

+ {{ bookmarks.length }} Bookmark{% if bookmarks.length != 1 %}s{% endif %} +

+ +
+ {% for bookmark in bookmarks %} + + {{ bookmark.author.name }} + + {% endfor %} +
+
+
+ {% endif %} + + {# Replies #} + {% set replies = mentions | webmentionsByType('replies') %} + {% if replies.length %} +
+

+ {{ replies.length }} Repl{% if replies.length != 1 %}ies{% else %}y{% endif %} +

+ +
+ {% 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) #} + +
+
+ {# Hidden u-photo for reliable microformat parsing #} + + +
+ + {{ site.author.name }} + +

{{ site.author.title }}

+ {% if site.author.locality %} +

{{ site.author.locality }}{% if site.author.country %}, {{ site.author.country }}{% endif %}

+ {% endif %} +
+
+ {# Hidden but present for microformat completeness #} + + {% if site.author.email %}{% endif %} + {% if site.author.org %}{% endif %} +
+
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 #} + +
+

+ + + + Blogroll +

+ + {# Source tabs - only shown when multiple sources exist #} +
+ +
+ +
    + +
+ +
+ No blogs loaded yet. +
+ + + View all blogs + + +
+ + +
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 %} + +
+

Categories

+
+ {% for category in categories %} + + {{ category }} + + {% endfor %} +
+
+
+{% 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 %} + +
+

Follow Me

+

Follow me from your fediverse instance.

+ + + Follow on the Fediverse + + {% set modalTitle = "Follow on the Fediverse" %} + {% set modalDescription = "Choose your instance to follow this account." %} + {% include "components/fediverse-modal.njk" %} +
+
+{% 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 #} + + + + + + +
+
+ {# Title + menu #} +
+
+ +
+ + +
+ + {# Sort links #} +
+ Title + When +
+ + {# Feed list — pure divs, no table #} + + + {# Footer #} + +
+
+ + +
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 %} + +
+

+ + + + Listening +

+ + {# Now Playing — show if either source is actively playing #} + {% set fwNow = funkwhaleActivity.nowPlaying if funkwhaleActivity and funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status == 'now-playing' else null %} + {% set lfmNow = lastfmActivity.nowPlaying if lastfmActivity and lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status == 'now-playing' else null %} + + {% if fwNow or lfmNow %} + {% set np = fwNow or lfmNow %} + {% set npSource = "Funkwhale" if fwNow else "Last.fm" %} + {% set npColor = "purple" if fwNow else "red" %} +
+
+ + + + + + Now Playing + ({{ npSource }}) +
+
+ {% if np.coverUrl %} + + {% endif %} +
+

+ {% if np.trackUrl %} + {{ np.track }} + {% else %} + {{ np.track }} + {% endif %} +

+

{{ np.artist }}

+
+
+
+ {% endif %} + + {# Recent tracks — 2 from each source #} +
    + {% if funkwhaleActivity and funkwhaleActivity.listenings.length %} + {% for listening in funkwhaleActivity.listenings | head(2) %} +
  • + {% if listening.coverUrl %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    + {% if listening.trackUrl %} + {{ listening.track }} + {% else %} + {{ listening.track }} + {% endif %} +

    +

    {{ listening.artist }} + Funkwhale +

    +
    +
  • + {% endfor %} + {% endif %} + + {% if lastfmActivity and lastfmActivity.scrobbles.length %} + {% for scrobble in lastfmActivity.scrobbles | head(2) %} +
  • + {% if scrobble.coverUrl %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    + {% if scrobble.trackUrl %} + {{ scrobble.track }} + {% else %} + {{ scrobble.track }} + {% endif %} + {% if scrobble.loved %}{% endif %} +

    +

    {{ scrobble.artist }} + Last.fm +

    +
    +
  • + {% endfor %} + {% endif %} +
+ + + View full listening history + + +
+
+{% 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 #} + +
+

+ + GitHub +

+ + {# Tab buttons — order: Commits, Repos, Featured, PRs #} +
+ + + + +
+ + {# Tab content — fixed height container to prevent layout shift #} +
+ + {# Loading state #} +
+ Loading... +
+ + {# Commits Tab #} +
+
    + +
+
No recent commits.
+
+ + {# Repos Tab #} +
+
    + +
+
No repositories found.
+
+ + {# Featured Tab #} +
+
    + +
+
No featured projects.
+
+ + {# PRs Tab #} +
+
    + +
+
No recent PRs or issues.
+
+ +
+ + {# Footer link #} + {% if site.feeds.github %} + + View on GitHub + + + {% endif %} +
+ + +
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 %} + +
+

Categories

+
+ {% if category is string %} + + {{ category }} + + {% else %} + {% for cat in category %} + + {{ cat }} + + {% endfor %} + {% endif %} +
+
+
+{% 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 %} + +
+

More Posts

+
+ {% if _prevPost %} + + {% endif %} + {% if _nextPost %} +
+ Next + {% 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 _likedUrl %} + + Liked {{ _likedUrl | replace("https://", "") | truncate(35) }} + {% elif _bookmarkedUrl %} + + {{ _nextPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }} + {% elif _repostedUrl %} + + Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }} + {% elif _replyToUrl %} + + Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }} + {% else %} + {{ _nextPost.data.title or _nextPost.data.name or (_nextPost.templateContent | striptags | truncate(50)) or "Note" }} + {% endif %} + +
+ {% endif %} +
+
+
+{% 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 %} + +
+

Recent Comments

+
    + {% for comment in recentComments %} +
  • +
    + {% if comment.author and comment.author.photo %} + {{ comment.author.name }} + {% endif %} +
    + {{ comment.author.name or "Anonymous" }} +

    {{ comment.content.text | truncate(80) }}

    + {% if comment["comment-target"] %} + View post + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+
+{% 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 %} + +
+

Recent Posts

+ + + View all 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 %} + +
+

Recent Posts

+ + + View all posts + +
+
+{% 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 %} + +
+

Share

+
+ + + + + + + + {% set modalTitle = "Share on Mastodon / Fediverse" %} + {% set modalDescription = "Choose your instance to share this post." %} + {% include "components/fediverse-modal.njk" %} + +
+
+
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) %} + +
+

Social Activity

+ + {# Tab buttons #} +
+ {% if blueskyFeed and blueskyFeed.length %} + + {% endif %} + {% if mastodonFeed and mastodonFeed.length %} + + {% endif %} +
+ + {# Bluesky Tab Content #} + {% if blueskyFeed and blueskyFeed.length %} +
+ + + View on Bluesky + + +
+ {% endif %} + + {# Mastodon Tab Content #} + {% if mastodonFeed and mastodonFeed.length %} +
+ + + View on Mastodon + + +
+ {% endif %} +
+
+{% 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 %} + +
+

Contents

+ +
+
+{% 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) #} + +
+

+ + + + Webmentions +

+ + {# Tab buttons #} +
+ + +
+ + {# === Inbound tab (client-side fetched) === #} +
+ {# Loading #} +
+
+
+ + {# Mentions list #} +
+ +
+ + {# Empty #} +

No webmentions received yet.

+ + {# Error #} +

+ + {# Link to full interactions page #} + +
+ + {# === Outbound tab (from Eleventy collections) === #} +
+ + +
+
+ + +
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 %} +
+
+ {{ content | safe }} +
+ +
+ {% elif withBlogSidebar %} +
+
+ {{ content | safe }} +
+ +
+ {% else %} + {{ content | safe }} + {% endif %} +
+ + + + {# Island architecture - lazy hydration for widgets #} + + {# Relative date display - progressively enhances