Files
indiekit-blog/theme/interactive/architecture.html
2026-03-07 17:27:39 +01:00

1163 lines
46 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Indiekit Architecture Guide</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-inner: #1c2128;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-dim: #8b949e;
--text-muted: #484f58;
--blue: #58a6ff;
--green: #3fb950;
--orange: #f0883e;
--red: #f85149;
--purple: #d2a8ff;
--pink: #db61a2;
--yellow: #d29922;
--cyan: #79c0ff;
--teal: #7ee787;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
min-height: 100vh;
}
/* Navigation */
nav {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 0 24px;
display: flex;
align-items: center;
gap: 0;
}
.nav-brand {
font-size: 15px;
font-weight: 700;
color: var(--text);
padding: 12px 16px 12px 0;
border-right: 1px solid var(--border);
margin-right: 4px;
white-space: nowrap;
}
.nav-tab {
padding: 12px 16px;
font-size: 13px;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all .15s;
white-space: nowrap;
}
.nav-tab:hover { color: var(--text); }
.nav-tab.active {
color: var(--text);
border-bottom-color: var(--orange);
font-weight: 500;
}
/* Page container */
.page {
display: none;
max-width: 1100px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.page.active { display: block; }
/* Typography */
.page-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text);
}
.page-subtitle {
font-size: 15px;
color: var(--text-dim);
margin-bottom: 40px;
line-height: 1.5;
max-width: 720px;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin: 48px 0 16px;
color: var(--text);
}
.section-title:first-of-type { margin-top: 0; }
.section-desc {
font-size: 14px;
color: var(--text-dim);
margin-bottom: 24px;
line-height: 1.5;
max-width: 720px;
}
/* Diagram boxes */
.diagram {
position: relative;
padding: 20px 0;
}
.box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
border-left: 3px solid var(--border);
}
.box .box-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .6px;
color: var(--text-muted);
margin-bottom: 6px;
}
.box .box-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.box .box-desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.4;
}
.box .box-detail {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
font-family: "SFMono-Regular", Consolas, monospace;
}
/* Border colors by category */
.box.core { border-left-color: var(--blue); }
.box.endpoint { border-left-color: var(--green); }
.box.social { border-left-color: var(--red); }
.box.aggregator { border-left-color: var(--orange); }
.box.syndicator { border-left-color: var(--pink); }
.box.deploy { border-left-color: var(--text-dim); }
.box.theme { border-left-color: var(--teal); }
.box.storage { border-left-color: var(--yellow); }
.box.site { border-left-color: var(--purple); }
.box.posttype { border-left-color: var(--cyan); }
/* Flow layout: vertical pipeline */
.flow-vertical {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.flow-vertical .box {
width: 100%;
max-width: 560px;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 0;
color: var(--text-muted);
font-size: 12px;
gap: 2px;
}
.flow-arrow .arrow-line {
width: 2px;
height: 16px;
background: var(--border);
}
.flow-arrow .arrow-label {
font-size: 11px;
color: var(--text-muted);
font-family: "SFMono-Regular", Consolas, monospace;
padding: 2px 8px;
}
.flow-arrow .arrow-head {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--border);
}
/* Flow split: horizontal branches from center */
.flow-split {
display: flex;
justify-content: center;
gap: 20px;
margin: 0;
width: 100%;
}
.flow-split .branch {
flex: 1;
max-width: 340px;
}
.flow-split .branch .box { width: 100%; }
/* Grid layout for plugin lists */
.plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
margin-bottom: 32px;
}
/* Compact plugin card */
.plugin-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
border-left: 3px solid var(--border);
}
.plugin-card .pc-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 3px;
}
.plugin-card .pc-pkg {
font-size: 11px;
color: var(--text-muted);
font-family: "SFMono-Regular", Consolas, monospace;
margin-bottom: 4px;
}
.plugin-card .pc-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
}
.plugin-card .pc-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
margin-top: 6px;
border: 1px solid;
}
.badge-fork { color: var(--orange); border-color: var(--orange); }
.badge-custom { color: var(--green); border-color: var(--green); }
.badge-sync { color: var(--cyan); border-color: var(--cyan); }
.plugin-card.endpoint { border-left-color: var(--green); }
.plugin-card.social { border-left-color: var(--red); }
.plugin-card.aggregator { border-left-color: var(--orange); }
.plugin-card.syndicator { border-left-color: var(--pink); }
.plugin-card.site { border-left-color: var(--purple); }
.plugin-card.posttype { border-left-color: var(--cyan); }
/* Container diagram for deployment */
.container-diagram {
background: var(--bg-inner);
border: 2px solid var(--border);
border-radius: 12px;
padding: 24px;
position: relative;
}
.container-diagram .cd-label {
position: absolute;
top: -11px;
left: 20px;
background: var(--bg-inner);
padding: 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .5px;
}
.container-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Process table */
.process-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 12px;
}
.process-table th {
text-align: left;
padding: 8px 12px;
background: var(--bg-inner);
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid var(--border);
}
.process-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
color: var(--text);
}
.process-table td:nth-child(2) {
color: var(--text-dim);
}
.process-table td:nth-child(3) {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
color: var(--text-muted);
}
/* Convention list */
.convention-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.convention-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 18px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.convention-item .ci-key {
font-size: 12px;
font-weight: 600;
color: var(--blue);
min-width: 100px;
flex-shrink: 0;
}
.convention-item .ci-val {
font-size: 13px;
color: var(--text-dim);
line-height: 1.4;
}
.convention-item code {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
background: var(--bg-inner);
padding: 1px 5px;
border-radius: 3px;
color: var(--text);
}
/* Horizontal flow for syndication */
.flow-horizontal {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
padding: 8px 0;
}
.flow-horizontal .box {
min-width: 200px;
flex-shrink: 0;
}
.h-arrow {
display: flex;
align-items: center;
padding: 0 4px;
flex-shrink: 0;
}
.h-arrow .ha-line {
width: 24px;
height: 2px;
background: var(--border);
}
.h-arrow .ha-head {
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 6px solid var(--border);
}
.h-arrow .ha-label {
font-size: 10px;
color: var(--text-muted);
position: absolute;
margin-top: -18px;
white-space: nowrap;
font-family: "SFMono-Regular", Consolas, monospace;
}
/* Targets list inside box */
.target-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.target-tag {
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-dim);
}
/* Data sources grid */
.data-sources {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.data-source {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
}
.data-source .ds-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.data-source .ds-list {
font-size: 11px;
color: var(--text-muted);
font-family: "SFMono-Regular", Consolas, monospace;
line-height: 1.6;
}
/* File tree */
.file-tree {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px 20px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.8;
color: var(--text-dim);
}
.file-tree .ft-dir { color: var(--blue); }
.file-tree .ft-file { color: var(--text-dim); }
.file-tree .ft-comment { color: var(--text-muted); }
.file-tree .ft-symlink { color: var(--green); }
/* MongoDB collections list */
.mongo-collections {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.mongo-coll {
font-size: 11px;
font-family: "SFMono-Regular", Consolas, monospace;
padding: 3px 8px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border-light);
color: var(--text-muted);
}
/* Summary stat boxes */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 40px;
}
.stat-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-box .stat-num {
font-size: 32px;
font-weight: 700;
color: var(--text);
}
.stat-box .stat-label {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
/* Responsive */
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.plugin-grid { grid-template-columns: 1fr; }
.data-sources { grid-template-columns: 1fr; }
.flow-split { flex-direction: column; align-items: center; }
.flow-horizontal { flex-direction: column; }
.h-arrow { transform: rotate(90deg); }
nav { overflow-x: auto; }
}
/* Category heading with dot */
.cat-heading {
display: flex;
align-items: center;
gap: 10px;
margin: 32px 0 16px;
}
.cat-heading .cat-dot {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.cat-heading h3 {
font-size: 16px;
font-weight: 600;
}
.cat-heading .cat-count {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
</style>
</head>
<body>
<nav id="nav">
<span class="nav-brand">Indiekit</span>
</nav>
<div id="pages"></div>
<script>
// ── PAGE DATA ─────────────────────────────────────────────
// Each page is defined as structured data, rendered with safe DOM methods.
var pages = [
{
id: 'overview',
tab: 'Overview',
title: 'Indiekit System Architecture',
subtitle: 'A 30+ plugin personal web platform built on Node.js, Express, and MongoDB. It publishes content, federates with the fediverse, syndicates to social networks, aggregates external activity, and deploys as a static site via Eleventy.',
render: renderOverview,
},
{
id: 'content',
tab: 'Content Pipeline',
title: 'Content Pipeline',
subtitle: 'How a post goes from creation to a published static page. The Micropub protocol receives content, a preset formats it, a store writes it, Eleventy builds it, and nginx serves it.',
render: renderContentPipeline,
},
{
id: 'plugins',
tab: 'Plugins',
title: 'Plugin Ecosystem',
subtitle: 'All 30+ @rmdes/* plugins organized by function. Each plugin follows the Indiekit plugin API: ESM modules with standardized class exports, no build step required.',
render: renderPlugins,
},
{
id: 'federation',
tab: 'Federation & Syndication',
title: 'Federation & Syndication',
subtitle: 'How content reaches other platforms. ActivityPub for the fediverse, syndicators for Bluesky/Mastodon/LinkedIn, webmentions for the IndieWeb.',
render: renderFederation,
},
{
id: 'aggregation',
tab: 'Aggregation',
title: 'Content Aggregation',
subtitle: 'How external activity flows into the site. Background processes poll external services, store data in MongoDB, and the Eleventy theme fetches it via plugin APIs at build time.',
render: renderAggregation,
},
{
id: 'deployment',
tab: 'Deployment',
title: 'Deployment Architecture',
subtitle: 'How the entire system runs inside a single Cloudron container at rmendes.net. Three processes (nginx, Indiekit, Eleventy) coordinated with atomic release swaps.',
render: renderDeployment,
},
];
// ── DOM HELPERS ───────────────────────────────────────────
function el(tag, attrs, children) {
var e = document.createElement(tag);
if (attrs) {
for (var k in attrs) {
if (k === 'className') e.className = attrs[k];
else if (k === 'textContent') e.textContent = attrs[k];
else e.setAttribute(k, attrs[k]);
}
}
if (children) {
if (!Array.isArray(children)) children = [children];
for (var i = 0; i < children.length; i++) {
if (!children[i]) continue;
if (typeof children[i] === 'string') e.appendChild(document.createTextNode(children[i]));
else e.appendChild(children[i]);
}
}
return e;
}
function box(cls, label, title, desc, detail) {
var items = [];
if (label) items.push(el('div', { className: 'box-label', textContent: label }));
if (title) items.push(el('div', { className: 'box-title', textContent: title }));
if (desc) items.push(el('div', { className: 'box-desc', textContent: desc }));
if (detail) items.push(el('div', { className: 'box-detail', textContent: detail }));
return el('div', { className: 'box ' + cls }, items);
}
function arrow(label) {
var items = [el('div', { className: 'arrow-line' })];
if (label) items.push(el('div', { className: 'arrow-label', textContent: label }));
items.push(el('div', { className: 'arrow-line' }));
items.push(el('div', { className: 'arrow-head' }));
return el('div', { className: 'flow-arrow' }, items);
}
function hArrow() {
return el('div', { className: 'h-arrow' }, [
el('div', { className: 'ha-line' }),
el('div', { className: 'ha-head' }),
]);
}
function pluginCard(cls, name, pkg, desc, badges) {
var items = [];
items.push(el('div', { className: 'pc-name', textContent: name }));
if (pkg) items.push(el('div', { className: 'pc-pkg', textContent: pkg }));
items.push(el('div', { className: 'pc-desc', textContent: desc }));
if (badges) {
for (var i = 0; i < badges.length; i++) {
items.push(el('span', { className: 'pc-badge badge-' + badges[i][0], textContent: badges[i][1] }));
}
}
return el('div', { className: 'plugin-card ' + cls }, items);
}
function sectionTitle(text) { return el('h2', { className: 'section-title', textContent: text }); }
function sectionDesc(text) { return el('p', { className: 'section-desc', textContent: text }); }
// ── PAGE: OVERVIEW ────────────────────────────────────────
function renderOverview(page) {
// Stats
var stats = el('div', { className: 'stats-row' }, [
statBox('30+', 'Plugins'),
statBox('4', 'Syndicators'),
statBox('6', 'Aggregators'),
statBox('11', 'Background Jobs'),
]);
page.appendChild(stats);
page.appendChild(sectionTitle('What Indiekit Does'));
page.appendChild(sectionDesc('Indiekit is a Node.js server that turns your content into a personal website. You write posts through the Micropub protocol, and Indiekit handles everything else: formatting, publishing, syndicating to social networks, and federating with the fediverse.'));
// The five plugin types
page.appendChild(sectionTitle('Five Plugin Types'));
page.appendChild(sectionDesc('Every piece of functionality is a plugin. The core server provides five extension points:'));
var apiGrid = el('div', { className: 'plugin-grid' }, [
box('core', 'addEndpoint()', 'Endpoints', 'HTTP routes that handle requests. Content creation, admin UI, social readers, content aggregation, and site management.', '18 endpoint plugins'),
box('syndicator', 'addSyndicator()', 'Syndicators', 'Cross-post content to external platforms. Each syndicator knows how to format and deliver posts to its target.', '4 syndicator plugins'),
box('posttype', 'addPostType()', 'Post Types', 'Define content types with their own properties, permalink patterns, and template associations.', '1 custom post type'),
box('posttype', 'addPreset()', 'Presets', 'Static site generator integration. Converts internal JF2 data to the format your SSG expects (e.g., YAML frontmatter for Eleventy).', '1 preset plugin'),
box('storage', 'addStore()', 'Stores', 'Storage backend for content files. Default: write .md files to the filesystem. Could also be GitHub, GitLab, etc.', '@indiekit/store-file-system'),
]);
page.appendChild(apiGrid);
// High-level data flow
page.appendChild(sectionTitle('High-Level Data Flow'));
page.appendChild(sectionDesc('The system has two main directions: outbound (publishing and syndicating your content) and inbound (aggregating external activity and receiving mentions).'));
var outbound = el('div', { className: 'flow-vertical' }, [
box('core', 'OUTBOUND', 'You write a post', 'Using a Micropub client (Quill, Indigenous) or the admin UI at /posts.'),
arrow('Micropub API'),
box('endpoint', null, 'Indiekit processes it', 'Auth check, content formatting via preset, file storage, MongoDB metadata.'),
arrow('file watcher'),
box('deploy', null, 'Eleventy builds the site', 'Static HTML generated, atomic release swap, Pagefind indexing.'),
arrow('syndication trigger'),
box('syndicator', null, 'Syndicators distribute it', 'Bluesky, Mastodon, LinkedIn, IndieNews. Plus ActivityPub to the fediverse.'),
]);
page.appendChild(outbound);
page.appendChild(el('div', { style: 'height: 32px' }));
var inbound = el('div', { className: 'flow-vertical' }, [
box('aggregator', 'INBOUND', 'External services have activity', 'GitHub commits, Last.fm scrobbles, Funkwhale plays, YouTube videos, RSS feeds, podcasts.'),
arrow('background sync'),
box('storage', null, 'Stored in MongoDB', 'Each aggregator plugin runs on a schedule, fetches new data, and stores it in its own collections.'),
arrow('Eleventy _data/*.js'),
box('theme', null, 'Theme fetches via plugin APIs', 'At build time, data files call plugin API endpoints to get the latest aggregated content.'),
]);
page.appendChild(inbound);
}
function statBox(num, label) {
return el('div', { className: 'stat-box' }, [
el('div', { className: 'stat-num', textContent: num }),
el('div', { className: 'stat-label', textContent: label }),
]);
}
// ── PAGE: CONTENT PIPELINE ────────────────────────────────
function renderContentPipeline(page) {
page.appendChild(sectionTitle('The Publishing Pipeline'));
page.appendChild(sectionDesc('Every post follows the same path, from Micropub request to served static page. Here is each step:'));
var pipeline = el('div', { className: 'flow-vertical' }, [
box('core', 'STEP 1', 'Micropub Client', 'User writes content in Quill, Indigenous, or the admin UI at /posts. Sends POST /micropub with JF2 content.', 'POST /micropub { type: "entry", content: "Hello world" }'),
arrow('HTTP POST'),
box('endpoint', 'STEP 2', 'endpoint-auth', 'Validates IndieAuth bearer token. Checks scopes (create, update, delete). JWT-based for background processes.', '/auth/token verification'),
arrow('authenticated request'),
box('endpoint', 'STEP 3', 'endpoint-micropub', 'Processes the Micropub request. Determines post type, applies template, preserves mp-syndicate-to targets. Fork adds type-based post discovery.', 'create / update / delete operations'),
arrow('JF2 content'),
box('posttype', 'STEP 4', 'preset-eleventy', 'Converts JF2 to YAML frontmatter + Markdown body. Generates permalink for the post type (/articles/2024/slug, /notes/abc123). Fork ensures ALL post types get permalinks.', 'JF2 \u2192 YAML+MD'),
arrow('formatted .md file'),
box('storage', 'STEP 5', 'store-file-system + MongoDB', 'Writes the .md file to /app/data/content/{type}/. Stores post metadata in MongoDB posts collection for admin queries.'),
arrow('file watcher detects change'),
box('deploy', 'STEP 6', 'Eleventy', 'File watcher triggers a build. Eleventy processes all content with the theme templates. Full build, then atomic release swap: new build directory symlinked, zero-downtime.', 'build \u2192 /app/data/releases/TIMESTAMP/ \u2192 symlink /app/data/site'),
arrow('static HTML ready'),
box('deploy', 'STEP 7', 'nginx', 'Serves the static site on :3000. Static files from /app/data/site (symlink), media from /app/data/content/media/. Proxies /admin and plugin routes to Indiekit :8080.', 'rmendes.net \u2192 nginx :3000'),
]);
page.appendChild(pipeline);
page.appendChild(sectionTitle('After Publishing'));
page.appendChild(sectionDesc('Once the post is created, two things happen in the background:'));
var afterSplit = el('div', { className: 'flow-split' }, [
el('div', { className: 'branch' }, [
box('syndicator', 'EVERY 2 MIN', 'Syndication Poller', 'endpoint-syndicate checks for posts with pending mp-syndicate-to targets. Triggers each syndicator with 2s delay between them.'),
el('div', { style: 'height: 12px' }),
box('syndicator', null, 'Syndicators fire', 'Bluesky (AT Protocol), Mastodon (API), LinkedIn (REST), IndieNews (webmention). Each formats and delivers the post natively.'),
]),
el('div', { className: 'branch' }, [
box('endpoint', 'EVERY 5 MIN', 'Webmention Sender', 'Scans post content for URLs. Sends webmentions to any linked page that advertises a webmention endpoint.'),
el('div', { style: 'height: 12px' }),
box('social', null, 'ActivityPub', 'If enabled, converts JF2 to ActivityStreams and delivers to follower inboxes via Fedify 2.0.'),
]),
]);
page.appendChild(afterSplit);
}
// ── PAGE: PLUGINS ─────────────────────────────────────────
function renderPlugins(page) {
var categories = [
{
name: 'Core IndieWeb Endpoints', dot: 'var(--green)', count: 6,
desc: 'The essential IndieWeb building blocks: authentication, content creation, post management, syndication triggers, and webmentions.',
plugins: [
['endpoint', 'endpoint-auth', '@rmdes/indiekit-endpoint-auth', 'IndieAuth with JWT, PKCE, OAuth. Guards all admin routes.', [['fork', 'fork']]],
['endpoint', 'endpoint-micropub', '@rmdes/indiekit-endpoint-micropub', 'Content creation via Micropub. Create/update/delete posts.', [['fork', 'fork']]],
['endpoint', 'endpoint-posts', '@rmdes/indiekit-endpoint-posts', 'Admin post management UI at /posts.', [['fork', 'fork']]],
['endpoint', 'endpoint-syndicate', '@rmdes/indiekit-endpoint-syndicate', 'Syndication trigger. Polls every 2 min, batch mode with 2s delay.', [['fork', 'fork']]],
['endpoint', 'webmention.io proxy', '@rmdes/indiekit-endpoint-webmentions-proxy', 'Proxies webmention.io API with server-side auth. Polls every 15 min.', [['custom', 'custom']]],
['endpoint', 'webmention-sender', '@rmdes/indiekit-endpoint-webmention-sender', 'Sends outbound webmentions to linked URLs. Runs every 5 min.', [['custom', 'custom']]],
]
},
{
name: 'Social & Federation', dot: 'var(--red)', count: 3,
desc: 'Connect to the broader social web: full fediverse federation, social feed reading, and blog aggregation.',
plugins: [
['social', 'ActivityPub', '@rmdes/indiekit-endpoint-activitypub', 'Full fediverse federation via Fedify 2.0. Outbound posts, inbound interactions, timeline reader, composer. 13 MongoDB collections.', [['custom', 'custom']]],
['social', 'Microsub', '@rmdes/indiekit-endpoint-microsub', 'Social reader + feed aggregator. Channels, subscriptions, adaptive polling (1min to 17hr tiers). Compose via Micropub.', [['custom', 'custom']]],
['social', 'Blogroll', '@rmdes/indiekit-endpoint-blogroll', 'Blog aggregation from OPML, Microsub, and FeedLand. Admin UI for managing blogs. Syncs hourly.', [['custom', 'custom']]],
]
},
{
name: 'Content Aggregation', dot: 'var(--orange)', count: 6,
desc: 'Pull in activity from external platforms. Each runs on a background schedule, stores data in MongoDB, and exposes an API for the theme.',
plugins: [
['aggregator', 'RSS', '@rmdes/indiekit-endpoint-rss', 'RSS/Atom/JSON feed aggregator. Fetches every 15 min.', [['custom', 'custom'], ['sync', 'every 15m']]],
['aggregator', 'Podroll', '@rmdes/indiekit-endpoint-podroll', 'Podcast aggregation from FreshRSS. OPML sidebar.', [['custom', 'custom'], ['sync', 'every 15m']]],
['aggregator', 'GitHub', '@rmdes/indiekit-endpoint-github', 'GitHub activity: commits, stars, contributions, featured repos.', [['custom', 'custom']]],
['aggregator', 'Funkwhale', '@rmdes/indiekit-endpoint-funkwhale', 'Funkwhale listening activity: history, favorites, statistics.', [['custom', 'custom'], ['sync', 'every 5m']]],
['aggregator', 'Last.fm', '@rmdes/indiekit-endpoint-lastfm', 'Last.fm scrobbles: listening history, loved tracks, statistics.', [['custom', 'custom'], ['sync', 'every 5m']]],
['aggregator', 'YouTube', '@rmdes/indiekit-endpoint-youtube', 'YouTube channel display: latest videos, live streaming status.', [['custom', 'custom']]],
]
},
{
name: 'Site Management', dot: 'var(--purple)', count: 3,
desc: 'Tools for managing the site itself: homepage layout, CV, and OAuth token handling.',
plugins: [
['site', 'Homepage Builder', '@rmdes/indiekit-endpoint-homepage', 'Drag-drop homepage sections. Discovers content from CV, GitHub, Funkwhale, Last.fm, Blogroll, Podroll, YouTube, Microsub.', [['custom', 'custom']]],
['site', 'CV', '@rmdes/indiekit-endpoint-cv', 'CV/resume management with 5 sections. Data in MongoDB cvData collection.', [['custom', 'custom']]],
['site', 'LinkedIn OAuth', '@rmdes/indiekit-endpoint-linkedin', 'LinkedIn OAuth token management. Provides access tokens to the LinkedIn syndicator.', [['custom', 'custom']]],
]
},
{
name: 'Syndicators', dot: 'var(--pink)', count: 4,
desc: 'Cross-post content to external platforms. Triggered by endpoint-syndicate after a Micropub post.',
plugins: [
['syndicator', 'Bluesky', '@rmdes/indiekit-syndicator-bluesky', 'AT Protocol syndication. Native likes/reposts, OG card embeds.', [['custom', 'custom']]],
['syndicator', 'Mastodon', '@rmdes/indiekit-syndicator-mastodon', 'Mastodon API syndication. Native favorites/reblogs.', [['custom', 'custom']]],
['syndicator', 'LinkedIn', '@rmdes/indiekit-syndicator-linkedin', 'LinkedIn REST API. Posts articles and notes. Requires token from endpoint-linkedin.', [['custom', 'custom']]],
['syndicator', 'IndieNews', '@rmdes/indiekit-syndicator-indienews', 'Webmention-based syndication to news.indieweb.org. No API key needed.', [['custom', 'custom']]],
]
},
{
name: 'Post Types & Presets', dot: 'var(--cyan)', count: 2,
desc: 'Content type definitions and static site generator integrations.',
plugins: [
['posttype', 'post-type-page', '@rmdes/indiekit-post-type-page', 'Page post type. Creates root-level slash pages: /about, /now, /uses.', [['custom', 'custom']]],
['posttype', 'preset-eleventy', '@rmdes/indiekit-preset-eleventy', 'Eleventy-compatible JF2-to-YAML frontmatter + permalink generation for ALL post types.', [['fork', 'fork']]],
]
},
];
for (var c = 0; c < categories.length; c++) {
var cat = categories[c];
var heading = el('div', { className: 'cat-heading' }, [
el('div', { className: 'cat-dot', style: 'background:' + cat.dot }),
el('h3', { textContent: cat.name }),
el('span', { className: 'cat-count', textContent: cat.count + ' plugins' }),
]);
page.appendChild(heading);
page.appendChild(sectionDesc(cat.desc));
var grid = el('div', { className: 'plugin-grid' });
for (var p = 0; p < cat.plugins.length; p++) {
var pl = cat.plugins[p];
grid.appendChild(pluginCard(pl[0], pl[1], pl[2], pl[3], pl[4]));
}
page.appendChild(grid);
}
}
// ── PAGE: FEDERATION & SYNDICATION ────────────────────────
function renderFederation(page) {
page.appendChild(sectionTitle('Three Ways Out'));
page.appendChild(sectionDesc('When you publish a post, it can reach three different networks: the fediverse (ActivityPub), social platforms (syndicators), and the IndieWeb (webmentions).'));
// Syndication flow
page.appendChild(sectionTitle('Syndication: Cross-Posting to Platforms'));
var syndFlow = el('div', { className: 'flow-vertical' }, [
box('endpoint', 'TRIGGER', 'endpoint-syndicate', 'Polls every 2 minutes. Finds posts with pending mp-syndicate-to targets. Processes them in batch mode with 2-second delay between each syndicator.'),
arrow('for each target'),
el('div', { className: 'flow-split' }, [
el('div', { className: 'branch' }, [
pluginCard('syndicator', 'Bluesky', null, 'AT Protocol. Creates posts with native rich text facets, OG card embeds. Supports external likes and reposts.'),
]),
el('div', { className: 'branch' }, [
pluginCard('syndicator', 'Mastodon', null, 'Mastodon API. Creates toots with native favorites and reblogs. Supports external interactions.'),
]),
]),
el('div', { style: 'height: 8px' }),
el('div', { className: 'flow-split' }, [
el('div', { className: 'branch' }, [
pluginCard('syndicator', 'LinkedIn', null, 'REST API. Posts articles and notes. Requires OAuth token from endpoint-linkedin.'),
]),
el('div', { className: 'branch' }, [
pluginCard('syndicator', 'IndieNews', null, 'Webmention-based. Submits to news.indieweb.org. No API key needed \u2014 uses standard webmention protocol.'),
]),
]),
]);
page.appendChild(syndFlow);
// ActivityPub
page.appendChild(sectionTitle('ActivityPub: Fediverse Federation'));
page.appendChild(sectionDesc('Full bidirectional federation using Fedify 2.0. Your site becomes a fediverse actor that can be followed from Mastodon, Pleroma, Misskey, etc.'));
var apFlow = el('div', { className: 'flow-split' }, [
el('div', { className: 'branch' }, [
box('social', 'OUTBOUND', 'Publishing to the Fediverse', 'Post created \u2192 JF2 converted to ActivityStreams \u2192 ctx.sendActivity() delivers to all follower inboxes.'),
el('div', { style: 'height: 12px' }),
box('social', 'READER', 'Timeline & Compose', 'Follow accounts \u2192 their posts arrive in ap_timeline \u2192 reader UI with compose, like, boost, follow.'),
]),
el('div', { className: 'branch' }, [
box('social', 'INBOUND', 'Receiving from the Fediverse', 'Remote servers POST to inbox \u2192 Fedify routes to handlers \u2192 Follow, Like, Announce, Create, Delete \u2192 MongoDB.'),
el('div', { style: 'height: 12px' }),
box('social', 'BRIDGE', 'Express \u2194 Fedify', 'Custom bridge (not @fedify/express due to mount path issue). Reconstructs req.originalUrl and POST bodies.'),
]),
]);
page.appendChild(apFlow);
page.appendChild(el('div', { style: 'height: 16px' }));
page.appendChild(sectionDesc('ActivityPub endpoints:'));
var apEndpoints = el('div', { className: 'plugin-grid' }, [
box('social', 'PUBLIC', 'Discovery & Federation', '/.well-known/webfinger (actor discovery)\n/.well-known/nodeinfo (server info)\n/activitypub/users/* (actor, inbox, outbox)\n/activitypub/inbox (shared inbox)\nContent negotiation at / (AS2 JSON)'),
box('social', 'ADMIN', 'Management UI', '/activitypub/ (dashboard)\n/activitypub/admin/reader (timeline, compose)\n/activitypub/admin/profile (actor editor)\n/activitypub/admin/followers, following\n/activitypub/admin/migrate (Mastodon import)\n/activitypub/admin/reader/moderation'),
]);
page.appendChild(apEndpoints);
// Webmentions
page.appendChild(sectionTitle('Webmentions: IndieWeb Interactions'));
var wmFlow = el('div', { className: 'flow-split' }, [
el('div', { className: 'branch' }, [
box('endpoint', 'OUTBOUND', 'webmention-sender', 'Every 5 minutes, scans recent posts for URLs. Discovers webmention endpoints on linked pages and sends notifications.'),
]),
el('div', { className: 'branch' }, [
box('endpoint', 'INBOUND', 'webmention.io proxy', 'Polls webmention.io every 15 min. Stores received mentions in MongoDB. Admin UI for moderation and blocklist.'),
]),
]);
page.appendChild(wmFlow);
}
// ── PAGE: AGGREGATION ─────────────────────────────────────
function renderAggregation(page) {
page.appendChild(sectionTitle('External Data Sources'));
page.appendChild(sectionDesc('Six aggregator plugins pull activity from external platforms. Each runs on its own background schedule.'));
var procTable = el('table', { className: 'process-table' }, [
el('thead', null, [
el('tr', null, [
el('th', { textContent: 'Plugin' }),
el('th', { textContent: 'Source' }),
el('th', { textContent: 'Interval' }),
el('th', { textContent: 'MongoDB Collections' }),
]),
]),
]);
var tbody = el('tbody');
var rows = [
['endpoint-rss', 'RSS / Atom / JSON feeds', '15 min', 'rssFeeds, rssItems'],
['endpoint-podroll', 'Podcasts via FreshRSS', '15 min', 'podrollEpisodes, podrollSources'],
['endpoint-github', 'GitHub API', 'on demand', 'cached in memory'],
['endpoint-funkwhale', 'Funkwhale API', '5 min', 'listenings'],
['endpoint-lastfm', 'Last.fm API', '5 min', 'scrobbles'],
['endpoint-youtube', 'YouTube Data API', 'on demand', 'cached in memory'],
['endpoint-blogroll', 'OPML / Microsub / FeedLand', '1 hour', 'blogrollBlogs, blogrollItems, blogrollSources'],
['endpoint-microsub', 'RSS/Atom feeds', 'adaptive 1m\u201317h', 'microsub_channels, microsub_feeds, microsub_items'],
['webmention.io proxy', 'webmention.io API', '15 min', 'webmentions, webmentionBlocklist'],
];
for (var r = 0; r < rows.length; r++) {
var tr = el('tr');
for (var c = 0; c < rows[r].length; c++) {
tr.appendChild(el('td', { textContent: rows[r][c] }));
}
tbody.appendChild(tr);
}
procTable.appendChild(tbody);
page.appendChild(procTable);
// Data flow to theme
page.appendChild(sectionTitle('How Aggregated Data Reaches the Site'));
page.appendChild(sectionDesc('The Eleventy theme has _data/*.js files that fetch from plugin API endpoints at build time. This is how external activity appears on the frontend.'));
var sources = el('div', { className: 'data-sources' }, [
dataSource('Plugin APIs', '/blogrollapi/*\n/funkwhale/api/*\n/lastfm/api/*\n/podrollapi/*\n/github/api/*\n/cv/data.json\n/homepage/api/*'),
dataSource('External APIs', 'YouTube Data API\nGitHub REST API\nBluesky AT Proto\nMastodon API'),
dataSource('Static Config', 'site.js (env vars)\nenabledPostTypes.js\nhomepageConfig.js\ncv.js'),
]);
page.appendChild(sources);
page.appendChild(arrow('fetched at build time'));
var templates = box('theme', 'TEMPLATES', 'Eleventy Theme', 'Nunjucks templates render the data into pages. Layouts: base, home, post, page. Sections: hero, recent-posts, social-activity, cv-*, custom-html. Widgets for each aggregated source.');
templates.style.maxWidth = '560px';
templates.style.margin = '0 auto';
page.appendChild(templates);
// Homepage builder
page.appendChild(sectionTitle('The Homepage Builder'));
page.appendChild(sectionDesc('endpoint-homepage provides a drag-and-drop UI for arranging homepage sections. It discovers available sections from other plugins:'));
var hpGrid = el('div', { className: 'plugin-grid' }, [
box('site', null, 'CV', '5 sections: experience, education, skills, projects, certifications'),
box('aggregator', null, 'GitHub', 'Featured repos, contribution graph'),
box('aggregator', null, 'Funkwhale', 'Recent listening activity'),
box('aggregator', null, 'Last.fm', 'Top tracks, recent scrobbles'),
box('social', null, 'Blogroll', 'Featured blogs'),
box('aggregator', null, 'Podroll', 'Recent podcast episodes'),
box('aggregator', null, 'YouTube', 'Latest videos'),
box('social', null, 'Microsub', 'Social reader feed'),
]);
page.appendChild(hpGrid);
page.appendChild(sectionDesc('Changes to homepage.json trigger an Eleventy rebuild, so the homepage updates immediately.'));
}
function dataSource(title, list) {
return el('div', { className: 'data-source' }, [
el('div', { className: 'ds-title', textContent: title }),
el('div', { className: 'ds-list', textContent: list }),
]);
}
// ── PAGE: DEPLOYMENT ──────────────────────────────────────
function renderDeployment(page) {
page.appendChild(sectionTitle('Cloudron Container'));
page.appendChild(sectionDesc('Everything runs in a single Cloudron container. Three long-running processes, shared filesystem, Cloudron-managed MongoDB.'));
// Container diagram
var container = el('div', { className: 'container-diagram' }, [
el('div', { className: 'cd-label', textContent: 'Cloudron Container \u2014 rmendes.net' }),
el('div', { className: 'container-stack' }, [
box('deploy', 'ENTRY POINT', 'nginx :3000', 'Serves static files from /app/data/site (symlink to current release). Media from /app/data/content/media/. Proxies /admin, /micropub, /auth, and all plugin routes to :8080. Legacy /content/ redirects to clean URLs. Security headers: CSP, X-Frame-Options, etc.'),
box('core', 'APPLICATION', 'Indiekit :8080', '30+ @rmdes/* plugins loaded via indiekit.config.js. @rmdes/preset-eleventy for content formatting. @indiekit/store-file-system for content storage. ActivityPub federation (Fedify 2.0). All 11 background sync processes.'),
box('deploy', 'SITE BUILDER', 'Eleventy (file watcher)', 'Watches /app/data/content/ for changes. Full build \u2192 atomic release swap (zero-downtime). Incremental rebuilds for file changes. Pagefind search indexing, WebSub notification. Theme from indiekit-eleventy-theme (git submodule).'),
]),
]);
page.appendChild(container);
// Filesystem
page.appendChild(sectionTitle('Filesystem Layout'));
var tree = el('div', { className: 'file-tree' });
var lines = [
['/app/data/', 'ft-dir', 'writable, backed up by Cloudron'],
['\u251C\u2500\u2500 config/', 'ft-dir', 'indiekit.config.js, env.sh, .secret'],
['\u251C\u2500\u2500 content/', 'ft-dir', 'user posts organized by type'],
['\u2502 \u251C\u2500\u2500 articles/', 'ft-dir', ''],
['\u2502 \u251C\u2500\u2500 notes/', 'ft-dir', ''],
['\u2502 \u251C\u2500\u2500 photos/', 'ft-dir', ''],
['\u2502 \u251C\u2500\u2500 likes/', 'ft-dir', ''],
['\u2502 \u251C\u2500\u2500 pages/', 'ft-dir', ''],
['\u2502 \u2514\u2500\u2500 media/', 'ft-dir', 'uploaded images'],
['\u251C\u2500\u2500 releases/', 'ft-dir', 'timestamped Eleventy builds'],
['\u251C\u2500\u2500 site \u2192', 'ft-symlink', 'symlink to current release (atomic swap)'],
['\u2514\u2500\u2500 cache/', 'ft-dir', 'Eleventy build cache'],
];
for (var l = 0; l < lines.length; l++) {
var line = el('div');
var path = el('span', { className: lines[l][1], textContent: lines[l][0] });
line.appendChild(path);
if (lines[l][2]) {
line.appendChild(document.createTextNode(' '));
line.appendChild(el('span', { className: 'ft-comment', textContent: '# ' + lines[l][2] }));
}
tree.appendChild(line);
}
page.appendChild(tree);
// MongoDB
page.appendChild(sectionTitle('MongoDB Collections'));
page.appendChild(sectionDesc('Cloudron manages MongoDB. The database stores all state \u2014 post metadata, aggregated content, ActivityPub data, configuration.'));
var collections = [
'posts', 'blogrollBlogs', 'blogrollItems', 'blogrollSources',
'microsub_channels', 'microsub_feeds', 'microsub_items',
'webmentions', 'webmentionBlocklist', 'rssFeeds', 'rssItems',
'listenings', 'scrobbles', 'cvData', 'homepageConfig',
'linkedin_tokens', 'podrollEpisodes', 'podrollSources',
'ap_followers', 'ap_following', 'ap_activities', 'ap_keys',
'ap_kv', 'ap_profile', 'ap_featured', 'ap_featured_tags',
'ap_timeline', 'ap_notifications', 'ap_muted', 'ap_blocked',
'ap_interactions',
];
var collContainer = el('div', { className: 'mongo-collections' });
for (var mc = 0; mc < collections.length; mc++) {
collContainer.appendChild(el('span', { className: 'mongo-coll', textContent: collections[mc] }));
}
page.appendChild(collContainer);
// Background processes
page.appendChild(sectionTitle('Background Processes'));
page.appendChild(sectionDesc('11 scheduled jobs run inside the Indiekit process:'));
var bgTable = el('table', { className: 'process-table' }, [
el('thead', null, [
el('tr', null, [
el('th', { textContent: 'Process' }),
el('th', { textContent: 'What It Does' }),
el('th', { textContent: 'Interval' }),
]),
]),
]);
var bgBody = el('tbody');
var bgRows = [
['Syndication poller', 'POST /syndicate for pending posts', '2 min'],
['Webmention sender', 'Send outbound webmentions', '5 min'],
['Blogroll sync', 'Fetch OPML/feeds', '1 hour'],
['Microsub scheduler', 'Poll subscribed feeds', 'adaptive 1m\u201317h'],
['RSS sync', 'Fetch RSS/Atom feeds', '15 min'],
['Podroll sync', 'Fetch FreshRSS podcasts', '15 min'],
['Funkwhale sync', 'Fetch listening history', '5 min'],
['Last.fm sync', 'Fetch scrobbles', '5 min'],
['ActivityPub queue', 'Process Fedify delivery queue', 'continuous'],
['Timeline cleanup', 'Prune AP timeline', '24 hours'],
['Webmention.io sync', 'Poll webmention.io', '15 min'],
];
for (var br = 0; br < bgRows.length; br++) {
var btr = el('tr');
for (var bc = 0; bc < bgRows[br].length; bc++) {
btr.appendChild(el('td', { textContent: bgRows[br][bc] }));
}
bgBody.appendChild(btr);
}
bgTable.appendChild(bgBody);
page.appendChild(bgTable);
// Conventions
page.appendChild(sectionTitle('Key Conventions'));
var conventions = el('div', { className: 'convention-list' });
var convItems = [
['Dates', 'Always ISO 8601 strings via new Date().toISOString(). Never store Date objects \u2014 the Nunjucks | date filter crashes on them.'],
['Modules', 'ESM everywhere. All plugins use "type": "module" in package.json.'],
['Templates', 'Always guard date filters with {% raw %}{% if value %}{% endraw %} to protect against null/undefined.'],
['Auth', 'IndieAuth for admin routes, JWT for background processes, HTTP Signatures for ActivityPub.'],
['Storage', 'MongoDB for state and metadata. Filesystem for content (.md files). Two sources of truth by design.'],
['Publishing', 'Bump version \u2192 commit \u2192 npm publish (manual, OTP required) \u2192 update Dockerfile \u2192 cloudron build.'],
];
for (var cv = 0; cv < convItems.length; cv++) {
conventions.appendChild(el('div', { className: 'convention-item' }, [
el('div', { className: 'ci-key', textContent: convItems[cv][0] }),
el('div', { className: 'ci-val', textContent: convItems[cv][1] }),
]));
}
page.appendChild(conventions);
}
// ── NAVIGATION & INIT ─────────────────────────────────────
var navEl = document.getElementById('nav');
var pagesEl = document.getElementById('pages');
function switchPage(id) {
var tabs = navEl.querySelectorAll('.nav-tab');
for (var t = 0; t < tabs.length; t++) {
tabs[t].classList.toggle('active', tabs[t].getAttribute('data-page') === id);
}
var pageEls = pagesEl.querySelectorAll('.page');
for (var p = 0; p < pageEls.length; p++) {
pageEls[p].classList.toggle('active', pageEls[p].id === 'page-' + id);
}
window.scrollTo(0, 0);
}
// Build navigation and pages
for (var i = 0; i < pages.length; i++) {
(function(pg, idx) {
var tab = el('div', {
className: 'nav-tab' + (idx === 0 ? ' active' : ''),
textContent: pg.tab,
'data-page': pg.id,
});
tab.addEventListener('click', function() { switchPage(pg.id); });
navEl.appendChild(tab);
var pageEl = el('div', {
className: 'page' + (idx === 0 ? ' active' : ''),
id: 'page-' + pg.id,
}, [
el('h1', { className: 'page-title', textContent: pg.title }),
el('p', { className: 'page-subtitle', textContent: pg.subtitle }),
]);
pg.render(pageEl);
pagesEl.appendChild(pageEl);
})(pages[i], i);
}
</script>
</body>
</html>