mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 08:44:56 +02:00
Add a reusable fullwidth layout (layouts/fullwidth.njk) for rich HTML content that needs the full container width without sidebar or prose constraints. Add the interactive architecture explorer as a static asset served via passthrough copy at /interactive/architecture.html. - layouts/fullwidth.njk: site header + footer only, no sidebar - interactive/architecture.html: tabbed architecture guide - eleventy.config.js: passthrough copy for interactive/ directory
1163 lines
46 KiB
HTML
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 {% if value %} 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>
|