@geekity/cms 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2217 -214
- package/admin/components/fields.njk +165 -0
- package/admin/layouts/base.njk +1 -1
- package/admin/layouts/settings-page.njk +26 -0
- package/admin/layouts/shell.njk +17 -3
- package/admin/pages/account/forgot.njk +47 -0
- package/admin/pages/account/login.njk +26 -0
- package/admin/pages/account/reset.njk +38 -0
- package/admin/pages/account/setup.njk +28 -0
- package/admin/pages/appearance/themes.njk +69 -0
- package/admin/pages/comments/all.njk +133 -0
- package/admin/{layouts/dashboard.njk → pages/dashboard/home.njk} +8 -15
- package/admin/{layouts/document-conflict.njk → pages/documents/conflict.njk} +1 -0
- package/admin/{layouts/document-editor.njk → pages/documents/editor.njk} +75 -59
- package/admin/{layouts/document-list.njk → pages/documents/list.njk} +19 -2
- package/admin/pages/documents/taxonomy.njk +95 -0
- package/admin/pages/federation/followers.njk +238 -0
- package/admin/pages/federation/settings.njk +77 -0
- package/admin/pages/media/library.njk +160 -0
- package/admin/pages/messages/all.njk +110 -0
- package/admin/pages/navigation/menus.njk +129 -0
- package/admin/{layouts → pages}/placeholder.njk +5 -1
- package/admin/pages/settings/discussion.njk +99 -0
- package/admin/pages/settings/email.njk +203 -0
- package/admin/pages/settings/general.njk +29 -0
- package/admin/pages/settings/permalinks.njk +50 -0
- package/admin/pages/settings/reading.njk +64 -0
- package/admin/pages/tools/content-index.njk +89 -0
- package/admin/pages/users/edit.njk +239 -0
- package/admin/pages/users/list.njk +51 -0
- package/admin/pages/users/new.njk +41 -0
- package/admin/static/admin.css +558 -3
- package/admin/static/copy.js +52 -0
- package/admin/static/editor.js +16 -16
- package/admin/static/slug.js +61 -0
- package/dist/access-log.d.ts +44 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/access-log.js +71 -0
- package/dist/access-log.js.map +1 -0
- package/dist/admin/accounts.d.ts +413 -0
- package/dist/admin/accounts.d.ts.map +1 -0
- package/dist/admin/accounts.js +680 -0
- package/dist/admin/accounts.js.map +1 -0
- package/dist/admin/appearance.d.ts +80 -0
- package/dist/admin/appearance.d.ts.map +1 -0
- package/dist/admin/appearance.js +136 -0
- package/dist/admin/appearance.js.map +1 -0
- package/dist/admin/comments.d.ts +90 -0
- package/dist/admin/comments.d.ts.map +1 -0
- package/dist/admin/comments.js +244 -0
- package/dist/admin/comments.js.map +1 -0
- package/dist/admin/credentials.d.ts +26 -2
- package/dist/admin/credentials.d.ts.map +1 -1
- package/dist/admin/credentials.js +35 -2
- package/dist/admin/credentials.js.map +1 -1
- package/dist/admin/documents.d.ts +117 -10
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +328 -41
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/federation.d.ts +161 -50
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +235 -93
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/formatting.d.ts +20 -0
- package/dist/admin/formatting.d.ts.map +1 -0
- package/dist/admin/formatting.js +72 -0
- package/dist/admin/formatting.js.map +1 -0
- package/dist/admin/headers.d.ts +65 -0
- package/dist/admin/headers.d.ts.map +1 -0
- package/dist/admin/headers.js +104 -0
- package/dist/admin/headers.js.map +1 -0
- package/dist/admin/index.d.ts +38 -12
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +25 -7
- package/dist/admin/index.js.map +1 -1
- package/dist/admin/media.d.ts +168 -0
- package/dist/admin/media.d.ts.map +1 -0
- package/dist/admin/media.js +295 -0
- package/dist/admin/media.js.map +1 -0
- package/dist/admin/menu.d.ts +79 -0
- package/dist/admin/menu.d.ts.map +1 -0
- package/dist/admin/menu.js +143 -0
- package/dist/admin/menu.js.map +1 -0
- package/dist/admin/messages.d.ts +80 -0
- package/dist/admin/messages.d.ts.map +1 -0
- package/dist/admin/messages.js +152 -0
- package/dist/admin/messages.js.map +1 -0
- package/dist/admin/navigation.d.ts +155 -0
- package/dist/admin/navigation.d.ts.map +1 -0
- package/dist/admin/navigation.js +292 -0
- package/dist/admin/navigation.js.map +1 -0
- package/dist/admin/preview.d.ts.map +1 -1
- package/dist/admin/preview.js +31 -7
- package/dist/admin/preview.js.map +1 -1
- package/dist/admin/recovery.d.ts +92 -0
- package/dist/admin/recovery.d.ts.map +1 -0
- package/dist/admin/recovery.js +229 -0
- package/dist/admin/recovery.js.map +1 -0
- package/dist/admin/routes.d.ts +0 -20
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +164 -105
- package/dist/admin/routes.js.map +1 -1
- package/dist/admin/session.d.ts +17 -3
- package/dist/admin/session.d.ts.map +1 -1
- package/dist/admin/session.js +19 -5
- package/dist/admin/session.js.map +1 -1
- package/dist/admin/settings-discussion.d.ts +37 -0
- package/dist/admin/settings-discussion.d.ts.map +1 -0
- package/dist/admin/settings-discussion.js +117 -0
- package/dist/admin/settings-discussion.js.map +1 -0
- package/dist/admin/settings-email.d.ts +54 -0
- package/dist/admin/settings-email.d.ts.map +1 -0
- package/dist/admin/settings-email.js +189 -0
- package/dist/admin/settings-email.js.map +1 -0
- package/dist/admin/settings-federation.d.ts +48 -0
- package/dist/admin/settings-federation.d.ts.map +1 -0
- package/dist/admin/settings-federation.js +123 -0
- package/dist/admin/settings-federation.js.map +1 -0
- package/dist/admin/settings-general.d.ts +12 -0
- package/dist/admin/settings-general.d.ts.map +1 -0
- package/dist/admin/settings-general.js +48 -0
- package/dist/admin/settings-general.js.map +1 -0
- package/dist/admin/settings-page.d.ts +119 -0
- package/dist/admin/settings-page.d.ts.map +1 -0
- package/dist/admin/settings-page.js +132 -0
- package/dist/admin/settings-page.js.map +1 -0
- package/dist/admin/settings-pages.d.ts +30 -0
- package/dist/admin/settings-pages.d.ts.map +1 -0
- package/dist/admin/settings-pages.js +43 -0
- package/dist/admin/settings-pages.js.map +1 -0
- package/dist/admin/settings-permalinks.d.ts +16 -0
- package/dist/admin/settings-permalinks.d.ts.map +1 -0
- package/dist/admin/settings-permalinks.js +32 -0
- package/dist/admin/settings-permalinks.js.map +1 -0
- package/dist/admin/settings-reading.d.ts +34 -0
- package/dist/admin/settings-reading.d.ts.map +1 -0
- package/dist/admin/settings-reading.js +49 -0
- package/dist/admin/settings-reading.js.map +1 -0
- package/dist/admin/settings.d.ts +339 -96
- package/dist/admin/settings.d.ts.map +1 -1
- package/dist/admin/settings.js +600 -308
- package/dist/admin/settings.js.map +1 -1
- package/dist/admin/store.d.ts +606 -152
- package/dist/admin/store.d.ts.map +1 -1
- package/dist/admin/store.js +863 -268
- package/dist/admin/store.js.map +1 -1
- package/dist/admin/taxonomy.d.ts +114 -0
- package/dist/admin/taxonomy.d.ts.map +1 -0
- package/dist/admin/taxonomy.js +289 -0
- package/dist/admin/taxonomy.js.map +1 -0
- package/dist/admin/templates.d.ts +46 -14
- package/dist/admin/templates.d.ts.map +1 -1
- package/dist/admin/templates.js +46 -14
- package/dist/admin/templates.js.map +1 -1
- package/dist/admin/throttle.d.ts +84 -0
- package/dist/admin/throttle.d.ts.map +1 -0
- package/dist/admin/throttle.js +143 -0
- package/dist/admin/throttle.js.map +1 -0
- package/dist/admin/tools.d.ts +111 -0
- package/dist/admin/tools.d.ts.map +1 -0
- package/dist/admin/tools.js +182 -0
- package/dist/admin/tools.js.map +1 -0
- package/dist/admin/uploads.d.ts +28 -2
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +43 -4
- package/dist/admin/uploads.js.map +1 -1
- package/dist/admin/users.d.ts +100 -13
- package/dist/admin/users.d.ts.map +1 -1
- package/dist/admin/users.js +412 -39
- package/dist/admin/users.js.map +1 -1
- package/dist/cache.d.ts +141 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +207 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +14 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +429 -21
- package/dist/cli.js.map +1 -1
- package/dist/comments/akismet.d.ts +130 -0
- package/dist/comments/akismet.d.ts.map +1 -0
- package/dist/comments/akismet.js +274 -0
- package/dist/comments/akismet.js.map +1 -0
- package/dist/comments/conversation.d.ts +29 -0
- package/dist/comments/conversation.d.ts.map +1 -0
- package/dist/comments/conversation.js +60 -0
- package/dist/comments/conversation.js.map +1 -0
- package/dist/comments/form.d.ts +123 -0
- package/dist/comments/form.d.ts.map +1 -0
- package/dist/comments/form.js +79 -0
- package/dist/comments/form.js.map +1 -0
- package/dist/comments/index.d.ts +16 -0
- package/dist/comments/index.d.ts.map +1 -0
- package/dist/comments/index.js +10 -0
- package/dist/comments/index.js.map +1 -0
- package/dist/comments/markdown.d.ts +8 -0
- package/dist/comments/markdown.d.ts.map +1 -0
- package/dist/comments/markdown.js +85 -0
- package/dist/comments/markdown.js.map +1 -0
- package/dist/comments/moderate.d.ts +71 -0
- package/dist/comments/moderate.d.ts.map +1 -0
- package/dist/comments/moderate.js +88 -0
- package/dist/comments/moderate.js.map +1 -0
- package/dist/comments/policy.d.ts +57 -0
- package/dist/comments/policy.d.ts.map +1 -0
- package/dist/comments/policy.js +80 -0
- package/dist/comments/policy.js.map +1 -0
- package/dist/comments/records.d.ts +266 -0
- package/dist/comments/records.d.ts.map +1 -0
- package/dist/comments/records.js +487 -0
- package/dist/comments/records.js.map +1 -0
- package/dist/comments/routes.d.ts +60 -0
- package/dist/comments/routes.d.ts.map +1 -0
- package/dist/comments/routes.js +212 -0
- package/dist/comments/routes.js.map +1 -0
- package/dist/comments/submission.d.ts +297 -0
- package/dist/comments/submission.d.ts.map +1 -0
- package/dist/comments/submission.js +280 -0
- package/dist/comments/submission.js.map +1 -0
- package/dist/comments/viewer.d.ts +21 -0
- package/dist/comments/viewer.d.ts.map +1 -0
- package/dist/comments/viewer.js +45 -0
- package/dist/comments/viewer.js.map +1 -0
- package/dist/config.d.ts +225 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +121 -6
- package/dist/config.js.map +1 -1
- package/dist/contact/delivery.d.ts +60 -0
- package/dist/contact/delivery.d.ts.map +1 -0
- package/dist/contact/delivery.js +87 -0
- package/dist/contact/delivery.js.map +1 -0
- package/dist/contact/form.d.ts +130 -0
- package/dist/contact/form.d.ts.map +1 -0
- package/dist/contact/form.js +163 -0
- package/dist/contact/form.js.map +1 -0
- package/dist/contact/index.d.ts +17 -0
- package/dist/contact/index.d.ts.map +1 -0
- package/dist/contact/index.js +13 -0
- package/dist/contact/index.js.map +1 -0
- package/dist/contact/records.d.ts +124 -0
- package/dist/contact/records.d.ts.map +1 -0
- package/dist/contact/records.js +218 -0
- package/dist/contact/records.js.map +1 -0
- package/dist/contact/routes.d.ts +17 -0
- package/dist/contact/routes.d.ts.map +1 -0
- package/dist/contact/routes.js +127 -0
- package/dist/contact/routes.js.map +1 -0
- package/dist/contact/submission.d.ts +103 -0
- package/dist/contact/submission.d.ts.map +1 -0
- package/dist/contact/submission.js +166 -0
- package/dist/contact/submission.js.map +1 -0
- package/dist/content/document.d.ts +7 -2
- package/dist/content/document.d.ts.map +1 -1
- package/dist/content/document.js +1 -0
- package/dist/content/document.js.map +1 -1
- package/dist/content/index.d.ts +7 -2
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -1
- package/dist/content/index.js.map +1 -1
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +24 -2
- package/dist/content/markdown.js.map +1 -1
- package/dist/content/parser.d.ts.map +1 -1
- package/dist/content/parser.js +11 -4
- package/dist/content/parser.js.map +1 -1
- package/dist/content/save.d.ts +5 -0
- package/dist/content/save.d.ts.map +1 -1
- package/dist/content/save.js +44 -1
- package/dist/content/save.js.map +1 -1
- package/dist/content/schedule.d.ts +125 -0
- package/dist/content/schedule.d.ts.map +1 -0
- package/dist/content/schedule.js +207 -0
- package/dist/content/schedule.js.map +1 -0
- package/dist/content/search.d.ts +64 -0
- package/dist/content/search.d.ts.map +1 -0
- package/dist/content/search.js +136 -0
- package/dist/content/search.js.map +1 -0
- package/dist/content/store.d.ts +220 -7
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +392 -67
- package/dist/content/store.js.map +1 -1
- package/dist/content/sync.d.ts +7 -1
- package/dist/content/sync.d.ts.map +1 -1
- package/dist/content/sync.js +18 -10
- package/dist/content/sync.js.map +1 -1
- package/dist/content/time.d.ts +56 -0
- package/dist/content/time.d.ts.map +1 -0
- package/dist/content/time.js +141 -0
- package/dist/content/time.js.map +1 -0
- package/dist/content/writer.d.ts.map +1 -1
- package/dist/content/writer.js +3 -0
- package/dist/content/writer.js.map +1 -1
- package/dist/env.d.ts +70 -5
- package/dist/env.d.ts.map +1 -1
- package/dist/federation/actor.d.ts +95 -41
- package/dist/federation/actor.d.ts.map +1 -1
- package/dist/federation/actor.js +180 -53
- package/dist/federation/actor.js.map +1 -1
- package/dist/federation/article.d.ts +44 -17
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +98 -46
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/delivery.d.ts +63 -15
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +154 -80
- package/dist/federation/delivery.js.map +1 -1
- package/dist/federation/federation.d.ts +22 -17
- package/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js +98 -77
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/followers.d.ts +3 -2
- package/dist/federation/followers.d.ts.map +1 -1
- package/dist/federation/followers.js +6 -5
- package/dist/federation/followers.js.map +1 -1
- package/dist/federation/import-wordpress.d.ts +128 -0
- package/dist/federation/import-wordpress.d.ts.map +1 -0
- package/dist/federation/import-wordpress.js +426 -0
- package/dist/federation/import-wordpress.js.map +1 -0
- package/dist/federation/inbox.d.ts +36 -13
- package/dist/federation/inbox.d.ts.map +1 -1
- package/dist/federation/inbox.js +120 -49
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/index.d.ts +22 -10
- package/dist/federation/index.d.ts.map +1 -1
- package/dist/federation/index.js +13 -8
- package/dist/federation/index.js.map +1 -1
- package/dist/federation/keys.d.ts +98 -20
- package/dist/federation/keys.d.ts.map +1 -1
- package/dist/federation/keys.js +269 -39
- package/dist/federation/keys.js.map +1 -1
- package/dist/federation/mount.d.ts +46 -10
- package/dist/federation/mount.d.ts.map +1 -1
- package/dist/federation/mount.js +300 -28
- package/dist/federation/mount.js.map +1 -1
- package/dist/federation/paths.d.ts +23 -35
- package/dist/federation/paths.d.ts.map +1 -1
- package/dist/federation/paths.js +26 -39
- package/dist/federation/paths.js.map +1 -1
- package/dist/federation/records.d.ts +223 -0
- package/dist/federation/records.d.ts.map +1 -0
- package/dist/federation/records.js +506 -0
- package/dist/federation/records.js.map +1 -0
- package/dist/federation/relays.d.ts +114 -0
- package/dist/federation/relays.d.ts.map +1 -0
- package/dist/federation/relays.js +290 -0
- package/dist/federation/relays.js.map +1 -0
- package/dist/federation/replies.d.ts +80 -0
- package/dist/federation/replies.d.ts.map +1 -0
- package/dist/federation/replies.js +166 -0
- package/dist/federation/replies.js.map +1 -0
- package/dist/federation/wordpress.d.ts +131 -0
- package/dist/federation/wordpress.d.ts.map +1 -0
- package/dist/federation/wordpress.js +341 -0
- package/dist/federation/wordpress.js.map +1 -0
- package/dist/files/atomic.d.ts +103 -0
- package/dist/files/atomic.d.ts.map +1 -0
- package/dist/files/atomic.js +150 -0
- package/dist/files/atomic.js.map +1 -0
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +2 -0
- package/dist/files/index.js.map +1 -0
- package/dist/forms/protection.d.ts +85 -0
- package/dist/forms/protection.d.ts.map +1 -0
- package/dist/forms/protection.js +116 -0
- package/dist/forms/protection.js.map +1 -0
- package/dist/images/icons.d.ts +59 -0
- package/dist/images/icons.d.ts.map +1 -0
- package/dist/images/icons.js +131 -0
- package/dist/images/icons.js.map +1 -0
- package/dist/images/index.d.ts +7 -0
- package/dist/images/index.d.ts.map +1 -0
- package/dist/images/index.js +4 -0
- package/dist/images/index.js.map +1 -0
- package/dist/images/markup.d.ts +47 -0
- package/dist/images/markup.d.ts.map +1 -0
- package/dist/images/markup.js +175 -0
- package/dist/images/markup.js.map +1 -0
- package/dist/images/paths.d.ts +34 -0
- package/dist/images/paths.d.ts.map +1 -0
- package/dist/images/paths.js +41 -0
- package/dist/images/paths.js.map +1 -0
- package/dist/images/variants.d.ts +104 -0
- package/dist/images/variants.d.ts.map +1 -0
- package/dist/images/variants.js +320 -0
- package/dist/images/variants.js.map +1 -0
- package/dist/index.d.ts +123 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -25
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +18 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +34 -3
- package/dist/init.js.map +1 -1
- package/dist/mail/brevo.d.ts +35 -0
- package/dist/mail/brevo.d.ts.map +1 -0
- package/dist/mail/brevo.js +85 -0
- package/dist/mail/brevo.js.map +1 -0
- package/dist/mail/credentials.d.ts +62 -0
- package/dist/mail/credentials.d.ts.map +1 -0
- package/dist/mail/credentials.js +86 -0
- package/dist/mail/credentials.js.map +1 -0
- package/dist/mail/index.d.ts +15 -0
- package/dist/mail/index.d.ts.map +1 -0
- package/dist/mail/index.js +8 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/memory.d.ts +35 -0
- package/dist/mail/memory.d.ts.map +1 -0
- package/dist/mail/memory.js +29 -0
- package/dist/mail/memory.js.map +1 -0
- package/dist/mail/provider.d.ts +80 -0
- package/dist/mail/provider.d.ts.map +1 -0
- package/dist/mail/provider.js +23 -0
- package/dist/mail/provider.js.map +1 -0
- package/dist/mail/service.d.ts +156 -0
- package/dist/mail/service.d.ts.map +1 -0
- package/dist/mail/service.js +265 -0
- package/dist/mail/service.js.map +1 -0
- package/dist/mail/smtp.d.ts +39 -0
- package/dist/mail/smtp.d.ts.map +1 -0
- package/dist/mail/smtp.js +58 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/templates.d.ts +91 -0
- package/dist/mail/templates.d.ts.map +1 -0
- package/dist/mail/templates.js +69 -0
- package/dist/mail/templates.js.map +1 -0
- package/dist/notifications/comments.d.ts +66 -0
- package/dist/notifications/comments.d.ts.map +1 -0
- package/dist/notifications/comments.js +151 -0
- package/dist/notifications/comments.js.map +1 -0
- package/dist/notifications/digest.d.ts +135 -0
- package/dist/notifications/digest.d.ts.map +1 -0
- package/dist/notifications/digest.js +287 -0
- package/dist/notifications/digest.js.map +1 -0
- package/dist/notifications/index.d.ts +13 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +8 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/links.d.ts +60 -0
- package/dist/notifications/links.d.ts.map +1 -0
- package/dist/notifications/links.js +63 -0
- package/dist/notifications/links.js.map +1 -0
- package/dist/notifications/optouts.d.ts +48 -0
- package/dist/notifications/optouts.d.ts.map +1 -0
- package/dist/notifications/optouts.js +93 -0
- package/dist/notifications/optouts.js.map +1 -0
- package/dist/notifications/preferences.d.ts +158 -0
- package/dist/notifications/preferences.d.ts.map +1 -0
- package/dist/notifications/preferences.js +195 -0
- package/dist/notifications/preferences.js.map +1 -0
- package/dist/notifications/routes.d.ts +28 -0
- package/dist/notifications/routes.d.ts.map +1 -0
- package/dist/notifications/routes.js +184 -0
- package/dist/notifications/routes.js.map +1 -0
- package/dist/notifications/tokens.d.ts +60 -0
- package/dist/notifications/tokens.d.ts.map +1 -0
- package/dist/notifications/tokens.js +125 -0
- package/dist/notifications/tokens.js.map +1 -0
- package/dist/notify.d.ts +84 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +149 -0
- package/dist/notify.js.map +1 -0
- package/dist/web/archive.d.ts +49 -0
- package/dist/web/archive.d.ts.map +1 -0
- package/dist/web/archive.js +59 -0
- package/dist/web/archive.js.map +1 -0
- package/dist/web/assets.d.ts +6 -5
- package/dist/web/assets.d.ts.map +1 -1
- package/dist/web/assets.js +5 -7
- package/dist/web/assets.js.map +1 -1
- package/dist/web/authors.d.ts +143 -0
- package/dist/web/authors.d.ts.map +1 -0
- package/dist/web/authors.js +178 -0
- package/dist/web/authors.js.map +1 -0
- package/dist/web/comments.d.ts +60 -0
- package/dist/web/comments.d.ts.map +1 -0
- package/dist/web/comments.js +207 -0
- package/dist/web/comments.js.map +1 -0
- package/dist/web/context.d.ts +162 -28
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +123 -26
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts +209 -0
- package/dist/web/conversation.d.ts.map +1 -0
- package/dist/web/conversation.js +557 -0
- package/dist/web/conversation.js.map +1 -0
- package/dist/web/documents.d.ts +36 -6
- package/dist/web/documents.d.ts.map +1 -1
- package/dist/web/documents.js +52 -10
- package/dist/web/documents.js.map +1 -1
- package/dist/web/feed-atom.d.ts +22 -0
- package/dist/web/feed-atom.d.ts.map +1 -0
- package/dist/web/feed-atom.js +83 -0
- package/dist/web/feed-atom.js.map +1 -0
- package/dist/web/feed-item.d.ts +145 -0
- package/dist/web/feed-item.d.ts.map +1 -0
- package/dist/web/feed-item.js +120 -0
- package/dist/web/feed-item.js.map +1 -0
- package/dist/web/feed-json.d.ts +79 -0
- package/dist/web/feed-json.d.ts.map +1 -0
- package/dist/web/feed-json.js +53 -0
- package/dist/web/feed-json.js.map +1 -0
- package/dist/web/feed-rss.d.ts +26 -0
- package/dist/web/feed-rss.d.ts.map +1 -0
- package/dist/web/feed-rss.js +203 -0
- package/dist/web/feed-rss.js.map +1 -0
- package/dist/web/feed-source.d.ts +244 -0
- package/dist/web/feed-source.d.ts.map +1 -0
- package/dist/web/feed-source.js +201 -0
- package/dist/web/feed-source.js.map +1 -0
- package/dist/web/feed-xml.d.ts +63 -0
- package/dist/web/feed-xml.d.ts.map +1 -0
- package/dist/web/feed-xml.js +116 -0
- package/dist/web/feed-xml.js.map +1 -0
- package/dist/web/feeds.d.ts +31 -96
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +94 -198
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/health.d.ts +31 -0
- package/dist/web/health.d.ts.map +1 -0
- package/dist/web/health.js +44 -0
- package/dist/web/health.js.map +1 -0
- package/dist/web/index.d.ts +30 -10
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +18 -7
- package/dist/web/index.js.map +1 -1
- package/dist/web/navigation.d.ts +195 -0
- package/dist/web/navigation.d.ts.map +1 -0
- package/dist/web/navigation.js +300 -0
- package/dist/web/navigation.js.map +1 -0
- package/dist/web/negotiate.d.ts +31 -0
- package/dist/web/negotiate.d.ts.map +1 -1
- package/dist/web/negotiate.js +31 -11
- package/dist/web/negotiate.js.map +1 -1
- package/dist/web/recent.d.ts +43 -0
- package/dist/web/recent.d.ts.map +1 -0
- package/dist/web/recent.js +37 -0
- package/dist/web/recent.js.map +1 -0
- package/dist/web/render.d.ts +203 -9
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +341 -17
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts +25 -8
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +854 -93
- package/dist/web/routes.js.map +1 -1
- package/dist/web/sanitize.d.ts +19 -0
- package/dist/web/sanitize.d.ts.map +1 -0
- package/dist/web/sanitize.js +272 -0
- package/dist/web/sanitize.js.map +1 -0
- package/dist/web/search.d.ts +79 -0
- package/dist/web/search.d.ts.map +1 -0
- package/dist/web/search.js +94 -0
- package/dist/web/search.js.map +1 -0
- package/dist/web/sitemap.d.ts +120 -0
- package/dist/web/sitemap.d.ts.map +1 -0
- package/dist/web/sitemap.js +199 -0
- package/dist/web/sitemap.js.map +1 -0
- package/dist/web/taxonomy.d.ts +152 -0
- package/dist/web/taxonomy.d.ts.map +1 -0
- package/dist/web/taxonomy.js +237 -0
- package/dist/web/taxonomy.js.map +1 -0
- package/dist/web/templates.d.ts +54 -16
- package/dist/web/templates.d.ts.map +1 -1
- package/dist/web/templates.js +109 -28
- package/dist/web/templates.js.map +1 -1
- package/dist/web/themes.d.ts +226 -0
- package/dist/web/themes.d.ts.map +1 -0
- package/dist/web/themes.js +314 -0
- package/dist/web/themes.js.map +1 -0
- package/dist/webmention/discovery.d.ts +51 -0
- package/dist/webmention/discovery.d.ts.map +1 -0
- package/dist/webmention/discovery.js +176 -0
- package/dist/webmention/discovery.js.map +1 -0
- package/dist/webmention/html.d.ts +71 -0
- package/dist/webmention/html.d.ts.map +1 -0
- package/dist/webmention/html.js +285 -0
- package/dist/webmention/html.js.map +1 -0
- package/dist/webmention/index.d.ts +12 -0
- package/dist/webmention/index.d.ts.map +1 -0
- package/dist/webmention/index.js +8 -0
- package/dist/webmention/index.js.map +1 -0
- package/dist/webmention/links.d.ts +18 -0
- package/dist/webmention/links.d.ts.map +1 -0
- package/dist/webmention/links.js +64 -0
- package/dist/webmention/links.js.map +1 -0
- package/dist/webmention/microformats.d.ts +93 -0
- package/dist/webmention/microformats.d.ts.map +1 -0
- package/dist/webmention/microformats.js +376 -0
- package/dist/webmention/microformats.js.map +1 -0
- package/dist/webmention/receive.d.ts +141 -0
- package/dist/webmention/receive.d.ts.map +1 -0
- package/dist/webmention/receive.js +264 -0
- package/dist/webmention/receive.js.map +1 -0
- package/dist/webmention/routes.d.ts +37 -0
- package/dist/webmention/routes.d.ts.map +1 -0
- package/dist/webmention/routes.js +75 -0
- package/dist/webmention/routes.js.map +1 -0
- package/dist/webmention/service.d.ts +94 -0
- package/dist/webmention/service.d.ts.map +1 -0
- package/dist/webmention/service.js +207 -0
- package/dist/webmention/service.js.map +1 -0
- package/package.json +11 -6
- package/templates/site/content/_data/site.json +8 -0
- package/templates/site/content/pages/about.md +7 -0
- package/templates/site/content/posts/2026-01-01-hello-world.md +5 -3
- package/templates/site/geekity.config.ts +11 -1
- package/templates/site/tsconfig.json +1 -1
- package/themes/default/README.md +1216 -50
- package/themes/default/layouts/404.njk +2 -3
- package/themes/default/layouts/author.njk +36 -0
- package/themes/default/layouts/base.njk +161 -31
- package/themes/default/layouts/category.njk +27 -0
- package/themes/default/layouts/front-page.njk +56 -0
- package/themes/default/layouts/home.njk +17 -1
- package/themes/default/layouts/page.njk +57 -5
- package/themes/default/layouts/post.njk +78 -19
- package/themes/default/layouts/search.njk +57 -0
- package/themes/default/layouts/tag.njk +3 -6
- package/themes/default/mail/comment-digest.html.njk +37 -0
- package/themes/default/mail/comment-digest.subject.njk +3 -0
- package/themes/default/mail/comment-digest.txt.njk +40 -0
- package/themes/default/mail/comment-pending.html.njk +27 -0
- package/themes/default/mail/comment-pending.subject.njk +2 -0
- package/themes/default/mail/comment-pending.txt.njk +24 -0
- package/themes/default/mail/comment-reply.html.njk +21 -0
- package/themes/default/mail/comment-reply.subject.njk +2 -0
- package/themes/default/mail/comment-reply.txt.njk +22 -0
- package/themes/default/mail/contact-message.html.njk +24 -0
- package/themes/default/mail/contact-message.subject.njk +5 -0
- package/themes/default/mail/contact-message.txt.njk +20 -0
- package/themes/default/mail/password-changed.html.njk +18 -0
- package/themes/default/mail/password-changed.subject.njk +2 -0
- package/themes/default/mail/password-changed.txt.njk +17 -0
- package/themes/default/mail/password-reset.html.njk +22 -0
- package/themes/default/mail/password-reset.subject.njk +2 -0
- package/themes/default/mail/password-reset.txt.njk +18 -0
- package/themes/default/mail/test.html.njk +14 -0
- package/themes/default/mail/test.subject.njk +2 -0
- package/themes/default/mail/test.txt.njk +12 -0
- package/themes/default/partials/archive.njk +27 -0
- package/themes/default/partials/bio.njk +71 -0
- package/themes/default/partials/comment-form.njk +137 -0
- package/themes/default/partials/contact-form.njk +88 -0
- package/themes/default/partials/conversation.njk +139 -0
- package/themes/default/partials/feeds.njk +26 -0
- package/themes/default/partials/jsonld.njk +120 -0
- package/themes/default/partials/menu.njk +32 -0
- package/themes/default/partials/pagination.njk +12 -6
- package/themes/default/partials/post-list.njk +51 -18
- package/themes/default/partials/search-form.njk +22 -0
- package/themes/default/partials/tags.njk +26 -5
- package/themes/default/static/highlight.js +4 -0
- package/themes/default/static/style.css +1142 -194
- package/themes/default/theme.json +9 -0
- package/admin/layouts/federation.njk +0 -145
- package/admin/layouts/login.njk +0 -23
- package/admin/layouts/settings.njk +0 -116
- package/admin/layouts/setup.njk +0 -27
- package/admin/layouts/users.njk +0 -91
- /package/admin/{layouts/_flash.njk → components/flash.njk} +0 -0
package/README.md
CHANGED
|
@@ -33,13 +33,20 @@ tsconfig.json so the site type checks against the package
|
|
|
33
33
|
content/
|
|
34
34
|
posts/ Markdown posts, plus posts.json for Eleventy
|
|
35
35
|
pages/ Markdown pages, plus pages.json
|
|
36
|
-
_data/site.json title, tagline, author, page and feed sizes
|
|
36
|
+
_data/site.json the settings: title, tagline, author, page and feed sizes
|
|
37
|
+
_data/federation/ once the site federates: followers.json and the inbox log
|
|
38
|
+
_data/comments/ once somebody comments: one JSON file per post
|
|
37
39
|
.gitignore node_modules, data and .env
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
`data/` and `
|
|
41
|
-
first boot and is safe to delete; `
|
|
42
|
-
|
|
42
|
+
`data/` and `themes/` are not written. The index under `data/` is created on
|
|
43
|
+
first boot and is safe to delete; `themes/` is optional and only exists once
|
|
44
|
+
you write a theme of your own — one folder, `themes/<name>/`, with a
|
|
45
|
+
`theme.json` in it, chosen on **Appearance > Themes** in the admin. Until then
|
|
46
|
+
every page comes from the theme inside the package. [Theme
|
|
47
|
+
overrides](#theme-overrides) has the whole of it, and the comment beside
|
|
48
|
+
`themesDir` in the generated `geekity.config.ts` says it where you will see
|
|
49
|
+
it.
|
|
43
50
|
|
|
44
51
|
The generated `package.json` pins `@geekity/cms` to the version of the CLI that
|
|
45
52
|
wrote it, so a site is never scaffolded against a version it has not been tested
|
|
@@ -72,18 +79,21 @@ changelog.
|
|
|
72
79
|
site (or `npx geekity`, or a `package.json` script, which is how the generated
|
|
73
80
|
`sync` script calls it).
|
|
74
81
|
|
|
75
|
-
| Command
|
|
76
|
-
|
|
|
77
|
-
| `geekity serve`
|
|
78
|
-
| `geekity init <dir>`
|
|
79
|
-
| `geekity sync`
|
|
80
|
-
| `geekity
|
|
81
|
-
| `geekity
|
|
82
|
-
| `geekity
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
| Command | What it does |
|
|
83
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `geekity serve` | Boot from the config file and listen. The default when no command is given. |
|
|
85
|
+
| `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
|
|
86
|
+
| `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
|
|
87
|
+
| `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
|
|
88
|
+
| `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
|
|
89
|
+
| `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
|
|
90
|
+
| `geekity --help`, `-h` | The same table, on the terminal. |
|
|
91
|
+
| `geekity --version` | The installed version. |
|
|
92
|
+
|
|
93
|
+
`serve`, `sync`, `rebuild`, `user add` and `import wordpress-actor` take
|
|
94
|
+
`--config <file>`; without it they look for `geekity.config.ts`, then
|
|
95
|
+
`geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
|
|
96
|
+
on defaults if there is none.
|
|
87
97
|
|
|
88
98
|
`sync` prints what the scan did and forces watching off whatever the config
|
|
89
99
|
says, because a one-shot scan that then sat in a watcher would never exit:
|
|
@@ -97,13 +107,49 @@ A file that will not parse is logged, left out of the index and counted in
|
|
|
97
107
|
`failed`; the command then exits `1` so a deploy step notices. Everything else
|
|
98
108
|
in the directory is still indexed.
|
|
99
109
|
|
|
110
|
+
### Starting the database again
|
|
111
|
+
|
|
112
|
+
`geekity rebuild` deletes `data/geekity.db` (with its `-wal` and `-shm`) and
|
|
113
|
+
builds a new one exactly as a boot does — the same migrations, the same scan of
|
|
114
|
+
`content/`, the same read of `content/_data/federation/` — then says what it
|
|
115
|
+
indexed:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
$ geekity rebuild
|
|
119
|
+
Rebuilt /srv/blog/data/geekity.db from the files.
|
|
120
|
+
Scanned 214: 214 created, 0 updated, 0 removed, 0 unchanged, 0 failed
|
|
121
|
+
Indexed 37 followers and 1,204 inbox activities.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Nothing in the database is anything but a reading of `content/` and `data/`, so
|
|
125
|
+
this is a command with no undo and nothing to lose; [what a rebuild does cost
|
|
126
|
+
is listed below](#two-directories-content-and-data). It is also the way past a
|
|
127
|
+
database this version refuses to open — a damaged one, or one written by a newer
|
|
128
|
+
`@geekity/cms`.
|
|
129
|
+
|
|
130
|
+
It refuses while the site is running, because deleting the file under a live
|
|
131
|
+
server would leave it writing to a database nothing can find. Stop the site
|
|
132
|
+
first. A file that will not parse is reported and exits `1`, as with `sync`.
|
|
133
|
+
|
|
134
|
+
That is why it is not the usual repair. **Tools > Content index** in the admin
|
|
135
|
+
empties the index and reads every file again in place, on the live connections:
|
|
136
|
+
no downtime, nobody signed out, and the delivery log, the relay handshakes and
|
|
137
|
+
the scheduler's watermark all kept. It is behind a confirm step, because the
|
|
138
|
+
site answers 404 for its documents between the emptying and the end of the
|
|
139
|
+
scan, and it federates nothing — a change a scan makes carries `origin: 'scan'`,
|
|
140
|
+
which delivery, the webmentions and the feed pings ignore. Use the command for
|
|
141
|
+
the one case the screen cannot be the door for: a database this version refuses
|
|
142
|
+
to open, where there is no site running to press a button in.
|
|
143
|
+
|
|
100
144
|
### Creating an admin from the command line
|
|
101
145
|
|
|
102
|
-
`geekity user add <username>` writes a user straight into
|
|
146
|
+
`geekity user add <username>` writes a user straight into `data/users.json`,
|
|
103
147
|
which is how a site that cannot reach `/admin/setup` from a browser — a
|
|
104
|
-
headless deploy, a server behind a bastion — gets its first login. It
|
|
105
|
-
|
|
106
|
-
|
|
148
|
+
headless deploy, a server behind a bastion — gets its first login. It is also
|
|
149
|
+
the way back into a site that has locked everybody out and cannot send email.
|
|
150
|
+
It enforces exactly the rules the setup form does: a username of 1 to 64
|
|
151
|
+
letters, digits, dots, dashes or underscores, and a password of at least 8
|
|
152
|
+
characters.
|
|
107
153
|
|
|
108
154
|
```sh
|
|
109
155
|
$ geekity user add ada
|
|
@@ -123,12 +169,22 @@ printf '%s\n' "$ADMIN_PASSWORD" | geekity user add ada
|
|
|
123
169
|
three: a password on the command line is visible in the process list and lands
|
|
124
170
|
in shell history.
|
|
125
171
|
|
|
172
|
+
`--email <address>` puts an address on the new user, which is optional and is
|
|
173
|
+
what [password recovery](#forgotten-passwords) works through:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
$ geekity user add ada --email ada@example.com
|
|
177
|
+
Password for ada:
|
|
178
|
+
Created admin user ada (ada@example.com). Sign in at /admin/login.
|
|
179
|
+
```
|
|
180
|
+
|
|
126
181
|
A name that is already taken is refused and nothing is written; so are an
|
|
127
|
-
illegal username
|
|
182
|
+
illegal username, a short password and an address that is not one. Every
|
|
183
|
+
refusal prints why and exits `1`.
|
|
128
184
|
|
|
129
185
|
Once somebody can sign in, the [`/admin/users` screen](#the-admin) is the
|
|
130
|
-
easier door: it adds users, generates passwords
|
|
131
|
-
|
|
186
|
+
easier door: it adds users, generates passwords and deletes them, and each
|
|
187
|
+
user's own screen under it is where the rest of an account is edited.
|
|
132
188
|
|
|
133
189
|
### How a TypeScript config is loaded
|
|
134
190
|
|
|
@@ -189,39 +245,204 @@ export default defineConfig({
|
|
|
189
245
|
port: 3000,
|
|
190
246
|
contentDir: 'content',
|
|
191
247
|
dataDir: 'data',
|
|
192
|
-
|
|
248
|
+
themesDir: 'themes',
|
|
193
249
|
baseUrl: 'http://localhost:3000',
|
|
194
250
|
watch: true,
|
|
195
251
|
sessionLifetime: 60 * 60 * 24 * 14,
|
|
252
|
+
loginAttempts: 5,
|
|
253
|
+
loginLockout: 15 * 60,
|
|
254
|
+
trustProxy: false,
|
|
196
255
|
});
|
|
197
256
|
```
|
|
198
257
|
|
|
199
258
|
Every field is optional. Relative directories resolve against the working
|
|
200
259
|
directory; absolute ones are used as given.
|
|
201
260
|
|
|
202
|
-
| Field | Default
|
|
203
|
-
| ------------------ |
|
|
204
|
-
| `port` | `3000`
|
|
205
|
-
| `contentDir` | `<cwd>/content`
|
|
206
|
-
| `dataDir` | `<cwd>/data`
|
|
207
|
-
| `
|
|
208
|
-
| `baseUrl` | `http://localhost:<port>`
|
|
209
|
-
| `watch` | `true`
|
|
210
|
-
| `sessionLifetime` | `1209600` (14 days)
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
261
|
+
| Field | Default | Environment override | Meaning |
|
|
262
|
+
| ------------------ | ------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
263
|
+
| `port` | `3000` | `GEEKITY_PORT`, then `PORT` | Port the HTTP server listens on. `0` picks a free one. |
|
|
264
|
+
| `contentDir` | `<cwd>/content` | `GEEKITY_CONTENT_DIR` | Markdown content. |
|
|
265
|
+
| `dataDir` | `<cwd>/data` | `GEEKITY_DATA_DIR` | Derived state — the SQLite index, the image variants — and the two things in it that are not derived and must be backed up: `users.json` and, under `keys/`, each user's actor key pairs. |
|
|
266
|
+
| `themesDir` | `<cwd>/themes` | `GEEKITY_THEMES_DIR` | The site's themes, one directory per theme, each with a `theme.json`. Which one is in use is the `theme` setting in `site.json`, not a path. Need not exist. |
|
|
267
|
+
| `baseUrl` | `http://localhost:<port>` | `GEEKITY_BASE_URL` | Public origin for canonical URLs, feeds and ActivityPub ids. A trailing slash is stripped. |
|
|
268
|
+
| `watch` | `true` | `GEEKITY_WATCH` | Watch `contentDir` while serving and keep the index in step. |
|
|
269
|
+
| `sessionLifetime` | `1209600` (14 days) | `GEEKITY_SESSION_LIFETIME` | How long an admin login lasts, in seconds. |
|
|
270
|
+
| `loginAttempts` | `5` | `GEEKITY_LOGIN_ATTEMPTS` | Failed sign-ins a username or an address may make before it is locked out. |
|
|
271
|
+
| `loginLockout` | `900` (15 minutes) | `GEEKITY_LOGIN_LOCKOUT` | How long the first lockout lasts, in seconds. See [Login hardening](#login-hardening). |
|
|
272
|
+
| `trustProxy` | `false` | `GEEKITY_TRUST_PROXY` | Believe `X-Forwarded-For` when deciding which address a sign-in came from. |
|
|
273
|
+
| `accessLog` | `false`; `true` under `geekity serve` | `GEEKITY_ACCESS_LOG` | One line per request on stdout: method, path with query, status, duration. See [The access log](#the-access-log). |
|
|
274
|
+
| `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
|
|
275
|
+
| `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
|
|
276
|
+
| `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
|
|
277
|
+
| `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
|
|
278
|
+
| `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
|
|
279
|
+
| `commentChecker` | Akismet | — | A spam checker of the site's own, which wins over the key in `data/akismet.json`. See [Akismet](#akismet). |
|
|
280
|
+
| `mail` | `{}` | — | Mail provider, retries, backoff and logger. See [Email](#email). |
|
|
214
281
|
|
|
215
282
|
Precedence is environment variable, then config file, then default, so a host
|
|
216
283
|
can override anything without editing the site. A boolean environment variable
|
|
217
284
|
takes `true`, `1`, `yes` and `on`, or their opposites; anything else is an error
|
|
218
285
|
rather than a silent `false`.
|
|
219
286
|
|
|
287
|
+
## The access log
|
|
288
|
+
|
|
289
|
+
`geekity serve` writes one line per request to stdout. `createCms` does not:
|
|
290
|
+
a CMS mounted inside somebody else's server has no business writing to its
|
|
291
|
+
stdout uninvited, so an embedder asks for it with `accessLog: true` — in the
|
|
292
|
+
config, no environment variable needed:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const cms = createCms({ accessLog: true });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The line is the method, the path with its query string, the status and how
|
|
299
|
+
long the request took, in that order, separated by single spaces:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
GET / 200 4.2ms
|
|
303
|
+
GET /.well-known/webfinger?resource=acct:ada@blog.example 200 1.9ms
|
|
304
|
+
GET /nothing-here 404 2.1ms
|
|
305
|
+
POST /admin/login 303 41.3ms
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Every route is on it — `/healthz`, the federation endpoints, the admin and the
|
|
309
|
+
public site — and a request whose handler threw gets its `500` line. Nothing
|
|
310
|
+
but the request line is ever read: no body is touched, so a password posted to
|
|
311
|
+
the login form cannot reach the log, and no cookie or `Authorization` header
|
|
312
|
+
is looked at either.
|
|
313
|
+
|
|
314
|
+
With `accessLogAddress: true` the client address goes on the end, after the
|
|
315
|
+
duration. It is off by default because an address is personal data. Which
|
|
316
|
+
address is right is `trustProxy`'s answer — the leftmost `X-Forwarded-For`
|
|
317
|
+
entry behind a proxy that sets it, the socket's own address otherwise — the
|
|
318
|
+
same one the login throttle counts a failed sign-in against.
|
|
319
|
+
|
|
320
|
+
`accessLogWriter` takes the lines somewhere else, one call per line, without
|
|
321
|
+
the newline:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
const lines: string[] = [];
|
|
325
|
+
const cms = createCms({
|
|
326
|
+
accessLog: true,
|
|
327
|
+
accessLogWriter: (line) => lines.push(line),
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Two directories: `content/` and `data/`
|
|
332
|
+
|
|
333
|
+
Everything a site cannot afford to lose is a file, and every one of those files
|
|
334
|
+
is in one of two directories. Nothing else needs backing up, and nothing else
|
|
335
|
+
needs carrying across a deploy.
|
|
336
|
+
|
|
337
|
+
`content/` is what the site publishes. It belongs in git, an Eleventy build of
|
|
338
|
+
the same directory reads all of it, and everything in it is meant to be public:
|
|
339
|
+
|
|
340
|
+
| Path | What it holds |
|
|
341
|
+
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
342
|
+
| `content/posts/`, `content/pages/` | The Markdown documents, `_trash/` included. |
|
|
343
|
+
| `content/uploads/` | Uploaded files exactly as they arrived. |
|
|
344
|
+
| `content/_data/site.json` | Every site setting. |
|
|
345
|
+
| `content/_data/federation/followers.json` | Who follows the site. |
|
|
346
|
+
| `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` | Every activity the inbox was handed, one per line. |
|
|
347
|
+
| `content/_data/comments/{slug}.json` | The comments left on that post, whatever a moderator has done with them. |
|
|
348
|
+
|
|
349
|
+
`data/` is private. It is gitignored, and it is the half to copy somewhere safe:
|
|
350
|
+
|
|
351
|
+
| Path | What it holds |
|
|
352
|
+
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
|
|
354
|
+
| `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
|
|
355
|
+
| `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
|
|
356
|
+
| `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
|
|
357
|
+
| `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
|
|
358
|
+
| `data/notification-secret` | What signs the one-click links in a notification. Mode `0600`. Losing it kills every link already in an inbox and nothing else. |
|
|
359
|
+
| `data/comment-optouts.json` | The addresses that have unsubscribed from reply notices. Mode `0600`. |
|
|
360
|
+
| `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
|
|
361
|
+
| `data/wordpress-activitypub.json` | When each WordPress compatibility path was last asked for. Losing it resets the answer the switch is watched by. |
|
|
362
|
+
|
|
363
|
+
Two things under `data/` may be deleted whenever the site is stopped, and
|
|
364
|
+
nothing else in either directory may:
|
|
365
|
+
|
|
366
|
+
| Path | What it is |
|
|
367
|
+
| ----------------- | ---------------------------------------------------------------------------------------- |
|
|
368
|
+
| `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
|
|
369
|
+
| `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
|
|
370
|
+
|
|
371
|
+
The next boot builds the database out of the files with no manual step, and a
|
|
372
|
+
request for a variant that is not there derives it and serves it.
|
|
373
|
+
|
|
374
|
+
### What is in the database, and what a rebuild costs
|
|
375
|
+
|
|
376
|
+
No table holds anything that is not either read back from the files or
|
|
377
|
+
something a site is told it may lose:
|
|
378
|
+
|
|
379
|
+
| Table | Where it comes back from |
|
|
380
|
+
| --------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
381
|
+
| `documents`, `document_tags`, `document_categories` | The boot scan of `content/`. |
|
|
382
|
+
| `followers`, `ap_inbox` | `content/_data/federation/`, emptied and read back on every boot. |
|
|
383
|
+
| `comments` | `content/_data/comments/`, emptied and read back on every boot. |
|
|
384
|
+
| `sessions` | Nothing. Everybody signed in is signed out. |
|
|
385
|
+
| `ap_deliveries` | Nothing. The federation screen shows its posts with "Nothing recorded." |
|
|
386
|
+
| `ap_relays` | The relay list in `site.json`: boot sends each of them a fresh `Follow`. |
|
|
387
|
+
| `password_resets`, `spent_tokens` | Nothing. Every reset link and every one-click moderation link stops working. |
|
|
388
|
+
| `cms_state` | Nothing. One key, the scheduler's watermark. |
|
|
389
|
+
| `migrations`, `admin_migrations` | The package. They record which schema versions have run. |
|
|
390
|
+
|
|
391
|
+
So four things are actually lost:
|
|
392
|
+
|
|
393
|
+
- **Logins.** Everybody signed in has to sign in again. The accounts themselves
|
|
394
|
+
are in `data/users.json` and are untouched.
|
|
395
|
+
- **Whether a one-click link had been used.** The links themselves are signed
|
|
396
|
+
rather than stored, so they go on working; a rebuild only forgets which of
|
|
397
|
+
them had already been spent. Every action they perform is idempotent, so the
|
|
398
|
+
worst that costs is a moderation link that works a second time. A password
|
|
399
|
+
reset link, which _is_ stored, stops working altogether.
|
|
400
|
+
- **Relay handshakes.** A relay named in the settings that the database has
|
|
401
|
+
never heard of is followed on boot, so a rebuild sends every listed relay a
|
|
402
|
+
new `Follow` and the reason one gave for rejecting the last is gone.
|
|
403
|
+
- **A scheduled post that came due while the site was down.** The scheduler
|
|
404
|
+
treats an absent watermark as "start from here", so that a rebuilt database
|
|
405
|
+
cannot re-announce the archive to every follower. Such a post is public on the
|
|
406
|
+
next boot but no `Create` is delivered for it; resend it from
|
|
407
|
+
`/admin/federation` if it should have gone out.
|
|
408
|
+
|
|
409
|
+
The one thing the file-first design gives up is narrower than any of those: a
|
|
410
|
+
post whose **file is gone entirely** can no longer be withdrawn from followers'
|
|
411
|
+
timelines, because the permalink a `Delete` names was in the file. Trashing a
|
|
412
|
+
post in the admin keeps the file under `_trash/`, so the ordinary way of
|
|
413
|
+
unpublishing still sends the `Delete`.
|
|
414
|
+
|
|
415
|
+
### A database this version will not open
|
|
416
|
+
|
|
417
|
+
Two cases refuse the boot rather than being cleaned up behind your back, and
|
|
418
|
+
both name the file and say what to do about it:
|
|
419
|
+
|
|
420
|
+
- **Written by a newer `@geekity/cms`**, meaning its migration ledger records a
|
|
421
|
+
schema version this package does not ship. Upgrading the package back is
|
|
422
|
+
usually what was meant; `geekity rebuild` throws it away if it was not.
|
|
423
|
+
- **Damaged**, meaning SQLite will not open it. `geekity rebuild` is the fix.
|
|
424
|
+
|
|
425
|
+
A database _older_ than the oldest migration the package still ships is the one
|
|
426
|
+
case thrown away and rebuilt without asking: there is by definition no path
|
|
427
|
+
forward from it, and nothing in it is anything but a reading of the files.
|
|
428
|
+
|
|
220
429
|
## Federation
|
|
221
430
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
431
|
+
**Every user is an ActivityPub actor**, served by [Fedify] at their author URL:
|
|
432
|
+
`{baseUrl}/author/{username}/` is the archive in a browser and the `Person` to
|
|
433
|
+
a peer asking for `application/activity+json`, exactly as a post's permalink is
|
|
434
|
+
its page and its object. The collections are that URL's children —
|
|
435
|
+
`inbox/`, `outbox/`, `followers/` and `following/` — and the instance-wide
|
|
436
|
+
shared inbox is `/inbox/`. `author` and `inbox` are reserved top-level paths
|
|
437
|
+
for that reason: an actor id a settings field could move would be a different
|
|
438
|
+
account to everybody holding it.
|
|
439
|
+
|
|
440
|
+
There is no site actor. A post is announced by the actor of the user its
|
|
441
|
+
`author` names, its `outbox` is that user's archive as activities, and its
|
|
442
|
+
followers are that user's own.
|
|
443
|
+
|
|
444
|
+
Fedify's stores have defaults that suit a single process, and `federation` is
|
|
445
|
+
where a site that has outgrown them says so:
|
|
225
446
|
|
|
226
447
|
| Field | Default | Meaning |
|
|
227
448
|
| --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -229,9 +450,46 @@ outgrown them says so:
|
|
|
229
450
|
| `queue` | `InProcessMessageQueue` | The delivery and inbox queue. `null` means no queue: activities are handled and delivered inside the request that carried them, with no retry. |
|
|
230
451
|
| `allowPrivateAddress` | `false` | Whether Fedify may fetch private and loopback addresses. Leave it off: turning it on removes an SSRF guard. It exists for tests. |
|
|
231
452
|
|
|
232
|
-
Followers
|
|
233
|
-
|
|
234
|
-
|
|
453
|
+
Followers and the log of what the inbox was told are the CMS's own, and they
|
|
454
|
+
are files the site publishes (decision-9):
|
|
455
|
+
`content/_data/federation/{username}/followers.json` holds one object per
|
|
456
|
+
follower of that user — actor id, inbox, shared inbox, handle, name, icon,
|
|
457
|
+
profile URL and follow time, oldest follow first — and
|
|
458
|
+
`content/_data/federation/inbox/{yyyy}-{mm}.jsonl` holds one compact JSON-LD
|
|
459
|
+
activity per line, with the `receivedAt` the log stamped it with and the
|
|
460
|
+
`recipient` it was addressed to in front of it. The log stays one chronological
|
|
461
|
+
record because it is a record of what this server was told; each line says
|
|
462
|
+
whose it was. Both are in git beside the posts, and an Eleventy build of the
|
|
463
|
+
same content directory sees them as `federation.{username}.followers` and
|
|
464
|
+
`federation.inbox`. A `Follow` appends to the first, an `Undo(Follow)` or an
|
|
465
|
+
actor's own `Delete` takes the entry out, and a like, a boost or a reply
|
|
466
|
+
appends a line to the second; each write updates the SQLite index in the same
|
|
467
|
+
step, behind a lock on the file, so the two can never disagree. The `followers` and `ap_inbox` tables are that
|
|
468
|
+
index and nothing more: every boot empties them and reads them back from the
|
|
469
|
+
files, which is what makes editing `followers.json` by hand and restarting a
|
|
470
|
+
supported thing to do, and what makes deleting the database cost a site
|
|
471
|
+
nothing. `cms.admin.replaceFollowers` and `replaceInboxActivities` are what the
|
|
472
|
+
rebuild calls; `rebuildFederationIndexes({ admin, contentDir })` is the rebuild
|
|
473
|
+
itself. The delivery outcomes stay in SQLite as a cache of what happened; the
|
|
474
|
+
activities themselves are not stored at all, because they are rebuilt from the
|
|
475
|
+
posts' files whenever they are wanted again.
|
|
476
|
+
|
|
477
|
+
Each user's key pairs are the CMS's own too, but they are the one
|
|
478
|
+
thing a site can never regenerate, so they live in files: one JWK per algorithm
|
|
479
|
+
per user under `dataDir/keys` (`ada.rsassa-pkcs1-v1_5.jwk` and
|
|
480
|
+
`ada.ed25519.jwk`), written `0600` in a `0700` directory, each holding the
|
|
481
|
+
private key alone because the public half is derived from it. They are
|
|
482
|
+
generated on the first request that needs them and read back on every one after
|
|
483
|
+
that. The actor publishes the RSA key as `publicKey` at `{actor}#main-key`,
|
|
484
|
+
which is what a peer verifies an HTTP Signature with, and both as
|
|
485
|
+
`assertionMethod` multikeys at `{actor}#multikey-0` and `#multikey-1`.
|
|
486
|
+
|
|
487
|
+
A key file that is there and will not import stops the boot, with a message
|
|
488
|
+
naming it. That is deliberate: replacing it would give the site a new identity
|
|
489
|
+
and every follower's cached public key would stop verifying, and Fedify's own
|
|
490
|
+
answer — an actor document with no `publicKey` at all — is worse still, because
|
|
491
|
+
peers cache it. Restore the file from a backup, or delete it to ask for a new
|
|
492
|
+
key on purpose.
|
|
235
493
|
|
|
236
494
|
### Delivery
|
|
237
495
|
|
|
@@ -249,60 +507,317 @@ Nothing is delivered for a full scan, the boot scan included: a rebuilt index
|
|
|
249
507
|
reports the whole archive as new, and announcing it again is not what deleting
|
|
250
508
|
`data/geekity.db` should mean.
|
|
251
509
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
an
|
|
256
|
-
|
|
510
|
+
A post's ActivityStreams object id is its permalink, absolute on the site's
|
|
511
|
+
base URL. One URL answers both audiences — a browser gets the page, a peer
|
|
512
|
+
asking for `application/activity+json` gets the `Article` — so a shared link, a
|
|
513
|
+
feed item, an object id and a reply's `inReplyTo` all name the same thing.
|
|
514
|
+
Nothing else has to be minted or kept in step.
|
|
515
|
+
|
|
516
|
+
The first activity about a post writes one key, `activitypub.published`, into
|
|
517
|
+
its front matter: the record that the post has been announced and when, which
|
|
518
|
+
is what decides `Create` against `Update` and what a resend reads. Restoring a
|
|
519
|
+
trashed post reuses it too.
|
|
520
|
+
|
|
521
|
+
Because that URL is a promise, the editor keeps it: **a published post's slug
|
|
522
|
+
and permalink cannot be changed**. Both fields still move freely on a draft,
|
|
523
|
+
and a site that really means to move a published post can edit the file,
|
|
524
|
+
knowing that its followers will be handed a second object.
|
|
525
|
+
|
|
526
|
+
A post whose file already names an `activitypub.id` keeps it as its object id
|
|
527
|
+
for the life of the post — that is how a post migrated from WordPress keeps the
|
|
528
|
+
`https://example.com/?p=813` its followers, its replies and its RSS subscribers
|
|
529
|
+
already hold. The CMS serves the `Article` at that URL on an ActivityStreams
|
|
530
|
+
request, redirects a browser from it to the permalink, and names it in every
|
|
531
|
+
`Update` and `Delete`. The CMS never writes one itself.
|
|
257
532
|
|
|
258
533
|
One POST serves a whole instance — the shared inbox is preferred — but the
|
|
259
|
-
outcome is recorded per
|
|
260
|
-
and send the
|
|
534
|
+
outcome is recorded per recipient, so an admin can see which one did not get it
|
|
535
|
+
and send the post again:
|
|
261
536
|
|
|
262
537
|
```ts
|
|
263
|
-
const report = await cms.delivery.
|
|
264
|
-
report?.deliveries; // one row per follower: inbox, status, error, time
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
`
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
the
|
|
292
|
-
|
|
293
|
-
federation
|
|
538
|
+
const report = await cms.delivery.resend(slug);
|
|
539
|
+
report?.deliveries; // one row per follower and relay: inbox, status, error, time
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
`resend` is "send this post as it now reads", not "send that activity again":
|
|
543
|
+
the activity is built from the file at the moment it is called, so a follower
|
|
544
|
+
whose server was down ends up with the post as it stands. Which activity that
|
|
545
|
+
is follows the same table as a save — a published post nobody has been told
|
|
546
|
+
about is a `Create` and is stamped, a published post they hold is an `Update`
|
|
547
|
+
under a fresh id, a draft or a trashed one is a `Delete` of a `Tombstone` — and
|
|
548
|
+
`undefined` comes back when there is no post by that name and nothing to
|
|
549
|
+
withdraw.
|
|
550
|
+
|
|
551
|
+
Nothing else is stored. What SQLite keeps about anything the site has sent is
|
|
552
|
+
one outcome row per recipient — the activity's id, type, object id and slug,
|
|
553
|
+
the inbox used, how it went, why not, and when — and that is a cache which may
|
|
554
|
+
be empty. `cms.admin.lastDeliveryToObject(objectId)` is the newest outcome
|
|
555
|
+
about one post, and `listDeliveries(activityId)` and
|
|
556
|
+
`countDeliveriesByStatus(activityId)` say how one activity landed with each
|
|
557
|
+
recipient. A status is `sent` (the inbox took it), `queued` (handed to Fedify's
|
|
558
|
+
queue, which retries out of band) or `failed`.
|
|
559
|
+
|
|
560
|
+
The one capability given up is tombstoning a post whose file is gone entirely
|
|
561
|
+
rather than in the trash: there is no file left to build the `Tombstone` from.
|
|
562
|
+
|
|
563
|
+
### Relays
|
|
564
|
+
|
|
565
|
+
A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
|
|
566
|
+
the instances subscribed to it, which is how a site nobody follows yet reaches
|
|
567
|
+
people. `relays` is the setting: one relay inbox per line on
|
|
568
|
+
`/admin/federation/settings`, kept in `site.json` like every other setting, and
|
|
569
|
+
`https://tags.pub/user/_____relay_____/inbox` is one worth knowing about — it
|
|
570
|
+
boosts any public post carrying a hashtag it tracks, which every `Article` this
|
|
571
|
+
CMS builds already carries one of per tag and per category.
|
|
572
|
+
|
|
573
|
+
Adding a line sends that inbox a `Follow` whose object is the ActivityStreams
|
|
574
|
+
Public collection, signed by the site's first account — a relay subscription is
|
|
575
|
+
an instance-wide agreement rather than one person's, and the first account is
|
|
576
|
+
the one actor that can be chosen without asking — and carrying the Linked Data
|
|
577
|
+
signature a Mastodon-style relay verifies. The relay answers `Accept` or
|
|
578
|
+
`Reject` — possibly days later, because a subscription may need a human to
|
|
579
|
+
approve it — and that answer arrives in the site's inbox and moves the
|
|
580
|
+
subscription. Removing the line sends `Undo` of the same `Follow`.
|
|
581
|
+
|
|
582
|
+
Only an accepted relay is delivered to. From there it is one more inbox in the
|
|
583
|
+
fan-out: every `Create`, `Update` and `Delete` for a post, whoever wrote it,
|
|
584
|
+
and the actor `Update` a profile change sends, goes to it as well as to the
|
|
585
|
+
author's followers, its
|
|
586
|
+
outcome recorded in the delivery log against its actor id and its inbox exactly
|
|
587
|
+
as a follower's is — which is why Resend reaches relays too.
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
cms.admin.listRelays(); // inbox, actor, state, reason, follow id, times
|
|
591
|
+
cms.relays.sync(); // reconcile the records with the setting
|
|
592
|
+
await cms.relays.retry(inboxId); // send the follow again
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
The records live in `ap_relays`, keyed by inbox, and are operational state
|
|
596
|
+
rather than a source: the setting is. A relay the settings name that has no
|
|
597
|
+
record — a rebuilt database, a restored content directory — is followed again
|
|
598
|
+
on the next boot.
|
|
599
|
+
|
|
600
|
+
[fepae0c]: https://w3id.org/fep/ae0c
|
|
601
|
+
|
|
602
|
+
### A user's profile
|
|
603
|
+
|
|
604
|
+
An actor's `name`, `summary`, `icon` and `attachment` are the display name, the
|
|
605
|
+
bio, the avatar and the links on their user record, edited on
|
|
606
|
+
`/admin/users/<id>` and stored in `data/users.json` — the same profile the
|
|
607
|
+
author archive is headed with, so a page and an actor cannot say different
|
|
608
|
+
things about somebody. Their `preferredUsername` is their login, and
|
|
609
|
+
`alsoKnownAs` lists every URL they answer to: the actor id, the archive and
|
|
610
|
+
`/@{username}`. The profile holds two more fields the actor does not carry — a
|
|
611
|
+
job title and a location — which are there for a theme to print beside the
|
|
612
|
+
name.
|
|
613
|
+
|
|
614
|
+
The avatar is a path like `/uploads/2026/09/me.png`, stored with the site's
|
|
615
|
+
other uploads. The actor carries it as an absolute URL resolved against the
|
|
616
|
+
base URL in effect, because a peer has no site to resolve a path against; one
|
|
617
|
+
that is already an absolute URL is left alone, which is how a profile picture
|
|
618
|
+
goes on a CDN.
|
|
619
|
+
|
|
620
|
+
Saving a profile delivers an `Update` of that user's actor to their followers,
|
|
621
|
+
so the profile a peer cached is refreshed rather than left showing last year's
|
|
622
|
+
picture. Nothing on the settings screen does: the site is not an actor.
|
|
623
|
+
`cms.delivery.updateActor(user)` sends one from code. Its outcome is recorded
|
|
624
|
+
like every other activity's, with the actor's id as its object and no slug, so
|
|
625
|
+
it is not a row in the federation screen's per-post delivery table, which is
|
|
626
|
+
built from the posts the content index holds.
|
|
627
|
+
|
|
628
|
+
### WebFinger
|
|
629
|
+
|
|
630
|
+
`/.well-known/webfinger` is the CMS's own route rather than Fedify's, because
|
|
631
|
+
Fedify computes its `self` link and its `aliases` from the dispatcher path and
|
|
632
|
+
neither can be added to. It answers for every spelling of the same person — the
|
|
633
|
+
`acct:{username}@{host}` handle, the bare `{username}@{host}`, the author URL,
|
|
634
|
+
`/@{username}` and the stored actor id of somebody who has one — with the actor
|
|
635
|
+
id as `self`, the archive as `profile-page`, and all of the person's URLs as
|
|
636
|
+
`aliases`. A username nobody has is a 404. `/@{username}` itself is a 301 to
|
|
637
|
+
the archive.
|
|
638
|
+
|
|
639
|
+
### A user's stored actor id
|
|
640
|
+
|
|
641
|
+
A user record in `data/users.json` may carry an `actorId`: the ActivityStreams
|
|
642
|
+
id that person was published under somewhere else, such as the
|
|
643
|
+
`https://example.com/?author=2` the WordPress ActivityPub plugin publishes.
|
|
644
|
+
That is identity, not cache — a follower's server keys the account by the URL
|
|
645
|
+
it first saw — so the CMS serves the person under it for the life of the
|
|
646
|
+
account, the way a post keeps its `activitypub.id`:
|
|
647
|
+
|
|
648
|
+
```json
|
|
649
|
+
{ "id": 2, "username": "ada", "actorId": "https://example.com/?author=2" }
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
The actor document's `id` is then that URL and its keys hang off it
|
|
653
|
+
(`…?author=2#main-key`), every activity the user sends names it as `actor` and
|
|
654
|
+
is signed with a key id under it, and the URL itself — query string and all —
|
|
655
|
+
answers with the `Person` for a peer and a 301 to the author archive for a
|
|
656
|
+
browser. WebFinger publishes it as `self` and resolves a lookup by it, and the
|
|
657
|
+
actor lists it in `alsoKnownAs` beside the archive and `/@{username}`. Nothing
|
|
658
|
+
else moves: `url` is still the archive and the collections are still the
|
|
659
|
+
archive's children, because a peer refetches those.
|
|
660
|
+
|
|
661
|
+
The CMS never mints one and no screen writes one. It arrives with the WordPress
|
|
662
|
+
import, or is typed into the file by hand; `/admin/users/<id>` shows it
|
|
663
|
+
read-only beside the account, and a value that is not an absolute URL is
|
|
664
|
+
ignored.
|
|
665
|
+
|
|
666
|
+
### WordPress ActivityPub compatibility
|
|
667
|
+
|
|
668
|
+
A site that moved here from the WordPress ActivityPub plugin has followers
|
|
669
|
+
whose servers still hold the plugin's endpoints — `/wp-json/activitypub/1.0/actors/2/inbox`,
|
|
670
|
+
the shared `/wp-json/activitypub/1.0/inbox`, and the collections beside them.
|
|
671
|
+
Those are cache rather than identity: a follower's server replaces them the
|
|
672
|
+
next time it refetches the actor. So the CMS can carry them for a while, behind
|
|
673
|
+
a switch, and is meant to stop.
|
|
674
|
+
|
|
675
|
+
Turn **WordPress ActivityPub compatibility** on under
|
|
676
|
+
`/admin/federation/settings`. It is off by default and `site.json` says nothing
|
|
677
|
+
about it until it is on. It needs one thing on the user record in
|
|
678
|
+
`data/users.json` besides the stored actor id above: the number WordPress gave
|
|
679
|
+
that person, which is what its paths are built from.
|
|
680
|
+
|
|
681
|
+
```json
|
|
682
|
+
{
|
|
683
|
+
"id": 2,
|
|
684
|
+
"username": "ada",
|
|
685
|
+
"actorId": "https://example.com/?author=2",
|
|
686
|
+
"wordpressActorId": 2
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
`geekity import wordpress-actor` writes both; no screen does. With the switch
|
|
691
|
+
on, those paths are real inbox routes, signature-verified exactly as the
|
|
692
|
+
canonical ones are — an unsigned or badly signed delivery is refused — plus GET
|
|
693
|
+
routes for the actor, its outbox, its followers and its following. What a peer
|
|
694
|
+
reads back is always the canonical document: the same `id`, the same key, and
|
|
695
|
+
the _canonical_ inbox and collections, so a follower refetching the actor at
|
|
696
|
+
the old URL is the follower that learns the new endpoints. A user with no
|
|
697
|
+
`wordpressActorId` is not reachable through any of it.
|
|
698
|
+
|
|
699
|
+
Every one of those paths records the instant it was last asked for, per user,
|
|
700
|
+
in `data/wordpress-activitypub.json` — a file, so a deleted database does not
|
|
701
|
+
forget it — and the settings page lists each path beside the switch with that
|
|
702
|
+
instant, or _Never_. That is what the switch is watched by: once every
|
|
703
|
+
follower's server has refetched the actor, nothing asks any more, and the
|
|
704
|
+
switch can go off. Clearing it takes the paths away on the very next request,
|
|
705
|
+
with no restart.
|
|
706
|
+
|
|
707
|
+
### Moving a site off the WordPress ActivityPub plugin
|
|
708
|
+
|
|
709
|
+
`geekity import wordpress-actor` brings one person across. It writes the three
|
|
710
|
+
things the CMS cannot mint for itself — the RSA key pair the followers have
|
|
711
|
+
cached, the actor id they key the account by, and the followers — and it is a
|
|
712
|
+
command rather than a screen because a stored actor id is identity for the life
|
|
713
|
+
of the account.
|
|
714
|
+
|
|
715
|
+
```sh
|
|
716
|
+
geekity import wordpress-actor ada \
|
|
717
|
+
--actor-id 'https://example.com/?author=2' \
|
|
718
|
+
--wordpress-id 2 \
|
|
719
|
+
--keypair ada.keypair.json
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
It writes `data/keys/ada.rsassa-pkcs1-v1_5.jwk` (and mints the Ed25519 pair
|
|
723
|
+
beside it, which WordPress never had), puts `actorId` and `wordpressActorId` on
|
|
724
|
+
the user in `data/users.json`, and fetches the plugin's public followers
|
|
725
|
+
collection into `content/_data/federation/ada/followers.json`, dereferencing
|
|
726
|
+
each follower for its inbox, shared inbox, handle, name, avatar and profile URL.
|
|
727
|
+
|
|
728
|
+
| Option | What it is |
|
|
729
|
+
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
730
|
+
| `--actor-id <url>` | The id WordPress published, query string and all. The one thing the import cannot work out for itself. |
|
|
731
|
+
| `--wordpress-id <n>` | The WordPress user id, which is the number in the plugin's paths. |
|
|
732
|
+
| `--keypair <file>` | The JSON the plugin's option holds: `{"private_key": …, "public_key": …}`. |
|
|
733
|
+
| `--private-key <file>`, `--public-key <file>` | The two PEMs instead, for a site still on the legacy user meta. The public key is only checked against the private half; it is derived, never stored. |
|
|
734
|
+
| `--followers <url\|file\|none>` | Where the followers come from. Left off, the plugin's own collection on the actor id's origin. |
|
|
735
|
+
| `--force` | Import over a key pair the user already has. |
|
|
736
|
+
|
|
737
|
+
**Exporting the key pair with wp-cli.** The plugin keeps a user's pair in a
|
|
738
|
+
WordPress option named after their login:
|
|
739
|
+
|
|
740
|
+
```sh
|
|
741
|
+
# On the WordPress server. The login, not the user id, is what names the option.
|
|
742
|
+
wp option get activitypub_keypair_for_ada --format=json > ada.keypair.json
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
A site old enough to still be on the plugin's legacy storage has the pair in
|
|
746
|
+
user meta instead, one PEM per key:
|
|
747
|
+
|
|
748
|
+
```sh
|
|
749
|
+
wp user meta get 2 magic_sig_private_key > ada.private.pem
|
|
750
|
+
wp user meta get 2 magic_sig_public_key > ada.public.pem
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Either export is accepted, and either PEM encoding — `-----BEGIN PRIVATE
|
|
754
|
+
KEY-----` (PKCS#8) or `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1). Treat the
|
|
755
|
+
files the way you would treat a password: the private key is the account.
|
|
756
|
+
|
|
757
|
+
**Running it twice changes nothing**, and says so, which is what makes it safe
|
|
758
|
+
to run again after an instance that was down comes back — a follower already in
|
|
759
|
+
the file keeps its place and its follow time. A follower whose server will not
|
|
760
|
+
answer is listed and skipped rather than failing the import. A user who already
|
|
761
|
+
has a _different_ key pair is refused: importing over one would change that
|
|
762
|
+
person's identity, and every follower has cached the public half of the key
|
|
763
|
+
that is there. `--force` is the way past that, and is only right when you are
|
|
764
|
+
certain the pair being imported is the one the followers hold.
|
|
765
|
+
|
|
766
|
+
#### The cutover, end to end
|
|
767
|
+
|
|
768
|
+
1. **Export, while the WordPress site is still up.** The key pair, as above.
|
|
769
|
+
The followers can be saved too — `curl -H 'Accept: application/activity+json'
|
|
770
|
+
'https://example.com/wp-json/activitypub/1.0/actors/2/followers?page=1' >
|
|
771
|
+
followers.json` — which is worth doing if the old site is going away before
|
|
772
|
+
the import runs. Note the actor id and the numeric actor id off
|
|
773
|
+
`https://example.com/?author=2`, and bring the content across.
|
|
774
|
+
2. **Import.** `geekity import wordpress-actor ada --actor-id … --wordpress-id
|
|
775
|
+
… --keypair ada.keypair.json`, pointing `--followers` at the saved file if
|
|
776
|
+
you took one. Check the report: every follower should be added, and any that
|
|
777
|
+
were skipped should be re-run once their servers answer.
|
|
778
|
+
3. **Switch on.** Turn **WordPress ActivityPub compatibility** on under
|
|
779
|
+
`/admin/federation/settings`, then move the DNS. Followers' servers go on
|
|
780
|
+
delivering to the plugin's old inbox paths until they next refetch the
|
|
781
|
+
actor, and the switch is what catches those deliveries.
|
|
782
|
+
4. **Watch.** `/admin/federation` lists the users with their actor ids and
|
|
783
|
+
followers; `/admin/federation/settings` lists each compatibility path with
|
|
784
|
+
the instant it was last asked for. Deliveries should thin out as each
|
|
785
|
+
follower's server refetches the actor and learns the new endpoints.
|
|
786
|
+
5. **Switch off.** Once every path says _Never_ again for long enough — weeks
|
|
787
|
+
rather than days, since a quiet instance refetches rarely — clear the switch.
|
|
788
|
+
The paths go away on the very next request, with nothing restarted, and if
|
|
789
|
+
something was still using them you can turn it back on just as quickly.
|
|
790
|
+
|
|
791
|
+
Retiring the stored actor id itself is a separate, optional step by the Move
|
|
792
|
+
protocol, and is not part of the cutover: not every follower's software honours
|
|
793
|
+
a `Move`, and the ones that do not would simply stop following.
|
|
294
794
|
|
|
295
795
|
### The federation screen
|
|
296
796
|
|
|
297
|
-
`/admin/federation` is all of that with a page in front of it. It shows
|
|
298
|
-
|
|
299
|
-
profile carries,
|
|
300
|
-
follow dates
|
|
301
|
-
linked to the remote object and to the post it was about, and
|
|
302
|
-
that has been federated:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
797
|
+
`/admin/federation` is all of that with a page in front of it. It shows one
|
|
798
|
+
panel per user — their avatar, their `@username@host` handle, the summary their
|
|
799
|
+
profile carries, their actor id, and the followers each of them has with
|
|
800
|
+
avatars and follow dates — the recent likes, boosts and replies out of the
|
|
801
|
+
inbox log, each linked to the remote object and to the post it was about, and
|
|
802
|
+
one row per post that has been federated: who announced it, its last activity,
|
|
803
|
+
when it went, and how many recipients it reached, is queued for, or failed
|
|
804
|
+
for. Each of those rows has a
|
|
805
|
+
Resend button, which is `cms.delivery.resend` behind a form: it builds the post
|
|
806
|
+
again from its file, sends it to every follower and every accepted relay the
|
|
807
|
+
site has now, and says what came of it.
|
|
808
|
+
|
|
809
|
+
Which posts are listed is a question for the files — the posts carrying an
|
|
810
|
+
`activitypub.published`, the trash included, which is the same thing as the
|
|
811
|
+
posts some follower holds a copy of — and how each landed is a question for the outcome
|
|
812
|
+
cache. So a site that has just deleted `data/geekity.db` sees every federated
|
|
813
|
+
post listed with nothing recorded against it, and can press Resend on any of
|
|
814
|
+
them.
|
|
815
|
+
|
|
816
|
+
A Relays panel sits between the followers and the inbox log: one row per
|
|
817
|
+
subscription with its inbox, whether it is waiting, accepted or rejected, when
|
|
818
|
+
it last moved, why it was refused if it was, and the last activity delivered
|
|
819
|
+
there with how that went. A subscription still waiting has a Retry, which sends
|
|
820
|
+
the `Follow` again under a fresh id.
|
|
306
821
|
|
|
307
822
|
### Testing federation against a real Mastodon account
|
|
308
823
|
|
|
@@ -321,8 +836,9 @@ pnpm dlx @fedify/cli tunnel 3000
|
|
|
321
836
|
|
|
322
837
|
Leave that running and boot the site with `baseUrl` set to the address it
|
|
323
838
|
printed, because `baseUrl` is what every ActivityStreams id is minted from — a
|
|
324
|
-
site booted on `http://localhost:3000` serves
|
|
325
|
-
`http://localhost:3000/
|
|
839
|
+
site booted on `http://localhost:3000` serves actors whose ids are
|
|
840
|
+
`http://localhost:3000/author/{username}/`, and no remote instance can
|
|
841
|
+
dereference those:
|
|
326
842
|
|
|
327
843
|
```sh
|
|
328
844
|
GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
|
|
@@ -330,15 +846,15 @@ GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
|
|
|
330
846
|
|
|
331
847
|
Then, from a Mastodon account:
|
|
332
848
|
|
|
333
|
-
1. Search for `@
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
2. Follow it. The `Follow` lands on `/
|
|
337
|
-
`Accept`, and the account appears on `/admin/federation` within
|
|
338
|
-
two.
|
|
849
|
+
1. Search for `@ada@quiet-sun-42.serveo.net` — the handle is a username at the
|
|
850
|
+
tunnel's host. The profile should come back with that user's display name,
|
|
851
|
+
bio and avatar.
|
|
852
|
+
2. Follow it. The `Follow` lands on `/author/ada/inbox/` (or on `/inbox/`), the
|
|
853
|
+
CMS answers `Accept`, and the account appears on `/admin/federation` within
|
|
854
|
+
a second or two.
|
|
339
855
|
3. Publish a post, from the editor or by writing a file into `content/posts/`.
|
|
340
856
|
A `Create(Article)` is delivered, and the post shows up in the follower's
|
|
341
|
-
home timeline; `/admin/federation` records the outcome, with a
|
|
857
|
+
home timeline; `/admin/federation` records the outcome, with a Resend
|
|
342
858
|
button if it did not land.
|
|
343
859
|
4. Edit the post, then set `draft: true` on it, to see the `Update` and the
|
|
344
860
|
`Delete` arrive.
|
|
@@ -351,14 +867,14 @@ that no longer exists, so they cannot be migrated to the real domain. The
|
|
|
351
867
|
admin has no button for removing a follower yet, so run tunnel rehearsals
|
|
352
868
|
against a scratch `dataDir` rather than the one the real site will keep.
|
|
353
869
|
|
|
354
|
-
The
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
870
|
+
The key pairs live in `dataDir/keys`, not in the base URL, so they
|
|
871
|
+
survive across tunnel sessions. That is usually what you want; it also means a
|
|
872
|
+
`data` directory copied from one deployment to another brings the other's
|
|
873
|
+
identity with it.
|
|
358
874
|
|
|
359
875
|
`pnpm fed:smoke`, in this repository, is the automated half of the same idea:
|
|
360
|
-
it boots the CMS on a free port, runs `fedify lookup` against
|
|
361
|
-
post object, and delivers a `Create(Article)` to two real inboxes over
|
|
876
|
+
it boots the CMS on a free port, runs `fedify lookup` against a user's actor
|
|
877
|
+
and a post object, reads WebFinger, and delivers a `Create(Article)` to two real inboxes over
|
|
362
878
|
loopback. It needs no tunnel and no network, and it is a job in CI. See
|
|
363
879
|
`packages/cms/scripts/fed-smoke.ts`, whose header explains why the `Follow`
|
|
364
880
|
half is sent by a peer built in the script rather than by `fedify inbox`.
|
|
@@ -366,6 +882,427 @@ half is sent by a peer built in the script rather than by `fedify inbox`.
|
|
|
366
882
|
[Fedify]: https://fedify.dev/
|
|
367
883
|
[`fedify tunnel`]: https://fedify.dev/cli
|
|
368
884
|
|
|
885
|
+
## Comments
|
|
886
|
+
|
|
887
|
+
A reader can answer a post on the page, and what they leave joins the same
|
|
888
|
+
thread as the fediverse replies rather than sitting in a section of its own.
|
|
889
|
+
|
|
890
|
+
A comment is a file. `content/_data/comments/{slug}.json` holds one post's
|
|
891
|
+
comments — id, source, kind, status, author, the Markdown and the HTML it
|
|
892
|
+
rendered to, when it was submitted, a salted hash of the address, and what it
|
|
893
|
+
answers — so they are in git beside the posts, an Eleventy build of the same
|
|
894
|
+
directory shows them, and the `comments` table is an index emptied and read
|
|
895
|
+
back on every boot.
|
|
896
|
+
|
|
897
|
+
Three settings and one front-matter key decide whether a post is still taking
|
|
898
|
+
them, and they are read in one place so the form and the endpoint can never
|
|
899
|
+
disagree:
|
|
900
|
+
|
|
901
|
+
| Where | What it says |
|
|
902
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
903
|
+
| `comments` in `site.json` | Whether the site takes comments at all. On by default; off is off everywhere. |
|
|
904
|
+
| `commentsCloseAfterDays` in `site.json` | Days after a post's `date` that it closes. `14` by default; `0` never closes. |
|
|
905
|
+
| `comments: true` / `false` in front matter | The post's own answer, which beats both, in both directions. The editor offers it. |
|
|
906
|
+
| The document's type | A page is closed unless it says otherwise. |
|
|
907
|
+
|
|
908
|
+
None of it touches the fediverse: a reply, a like or a boost arrives because a
|
|
909
|
+
remote server sent it, and a closed post shows every one of them.
|
|
910
|
+
|
|
911
|
+
A comment is held for a moderator unless the same **name and email** have had
|
|
912
|
+
one approved before — WordPress's rule. In front of that are a honeypot field,
|
|
913
|
+
a minimum time between the form being rendered and submitted, and a per-address
|
|
914
|
+
rate limit. Comments are rendered from Markdown with raw HTML off, images
|
|
915
|
+
unembedded and every link marked `rel="nofollow ugc"`.
|
|
916
|
+
|
|
917
|
+
A site can hand the whole thing to a service:
|
|
918
|
+
|
|
919
|
+
```ts
|
|
920
|
+
export default defineConfig({
|
|
921
|
+
commentChecker: {
|
|
922
|
+
async check(submission) {
|
|
923
|
+
// submission carries the comment, the post's URL, and the commenter's
|
|
924
|
+
// address, user agent and referrer — everything Akismet asks for.
|
|
925
|
+
return 'spam' | 'discard' | 'ham' | 'unknown';
|
|
926
|
+
},
|
|
927
|
+
async reportSpam(report) {}, // a moderator filed something as spam
|
|
928
|
+
async reportHam(report) {}, // …or let something out of the spam list
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
A checker that throws is treated as having no opinion and logged, so a service
|
|
934
|
+
that is down never stops a site taking comments. `/admin/comments` is the
|
|
935
|
+
moderation queue and the dashboard carries the number waiting.
|
|
936
|
+
|
|
937
|
+
### Being told about one
|
|
938
|
+
|
|
939
|
+
With mail configured, a comment or a webmention entering the queue emails every
|
|
940
|
+
user who has an address and has not switched the notice off on their
|
|
941
|
+
`/admin/users/<id>` screen. The message carries the comment, the post it is on,
|
|
942
|
+
and three links — approve, spam, delete — that work without a login. Nothing is
|
|
943
|
+
sent about a comment Akismet filed as spam or told the site to discard: only
|
|
944
|
+
what is actually waiting for a person.
|
|
945
|
+
|
|
946
|
+
Each link lands on a small page with a button on it, and only the button acts.
|
|
947
|
+
That is not politeness. Mail readers, spam filters and corporate link scanners
|
|
948
|
+
fetch the URLs in a message as a matter of course, and a link that moderated on
|
|
949
|
+
being opened would mean a mail gateway silently deleting this site's comments.
|
|
950
|
+
A link is signed rather than stored — the secret is `data/notification-secret`,
|
|
951
|
+
so a link goes on working across a `geekity rebuild` — is bound to one action
|
|
952
|
+
on one comment, lasts a week, and is spent the first time it is used.
|
|
953
|
+
|
|
954
|
+
Commenters get the other half. The form offers "email me when somebody replies
|
|
955
|
+
to this", but only on a site that can actually send mail; ticking it stores
|
|
956
|
+
`notify: true` beside the address that is already in the comment file, and
|
|
957
|
+
nothing about either is ever rendered — not on the page, not in the JSON or
|
|
958
|
+
Markdown representations, and not in the comments feeds. When a reply to that
|
|
959
|
+
comment is **approved**, one message goes out with the reply in it and an
|
|
960
|
+
unsubscribe link at the bottom. Unsubscribing is site-wide by address: the
|
|
961
|
+
address goes into `data/comment-optouts.json` and this site writes to it about
|
|
962
|
+
replies again. The comments themselves are untouched.
|
|
963
|
+
|
|
964
|
+
### One message an hour instead of one a comment
|
|
965
|
+
|
|
966
|
+
Beside each switch on a user's `/admin/users/<id>` screen is how often that
|
|
967
|
+
notice should arrive: as they arrive, an hourly digest, or a daily one. It is
|
|
968
|
+
per user, and it starts as "as they arrive", so nothing changes for anybody who
|
|
969
|
+
does not touch it.
|
|
970
|
+
|
|
971
|
+
On a digest, a comment entering the queue sends nothing at all. Instead, once a
|
|
972
|
+
window has passed, one message goes out listing everything **still waiting**,
|
|
973
|
+
each item with its own approve, spam and delete links. A digest is built from
|
|
974
|
+
the queue as it stands rather than from a list of things that happened, so an
|
|
975
|
+
item somebody moderated in the meantime is simply not in it, and there is no
|
|
976
|
+
queue to keep true across a restart. A user with nothing waiting is not written
|
|
977
|
+
to at all, and their window does not start until something is: the promise a
|
|
978
|
+
mode makes is at most one message per window, and a quiet site sends none.
|
|
979
|
+
|
|
980
|
+
A very long queue — a spam wave Akismet let through — is listed up to a hundred
|
|
981
|
+
items, with the rest counted in a line pointing at `/admin/comments`.
|
|
982
|
+
|
|
983
|
+
The only thing written down is when each user last had one, in
|
|
984
|
+
`data/notification-digests.json`. It is a file rather than a row because
|
|
985
|
+
decision-9 lets a site delete the database whenever it is stopped, and a
|
|
986
|
+
forgotten timestamp would mean either a window silently skipped or a digest
|
|
987
|
+
sent twice. It is its own file rather than a field in `data/users.json` because
|
|
988
|
+
that file answers "who may sign in", and a timestamp rewritten every hour is
|
|
989
|
+
bookkeeping rather than anything about the person.
|
|
990
|
+
|
|
991
|
+
Reply notices to commenters are not affected: those are one message about one
|
|
992
|
+
reply, and they go when the reply is approved.
|
|
993
|
+
|
|
994
|
+
With no mail configured, none of this happens and nothing breaks: the box is
|
|
995
|
+
not offered, no notice is sent, no digest runs, and the moderation screen is
|
|
996
|
+
the notification it was before.
|
|
997
|
+
|
|
998
|
+
Notices are per user and keyed by event name, so a later one — a new follower,
|
|
999
|
+
a digest of failed deliveries — is one entry in the registry in
|
|
1000
|
+
`src/notifications/preferences.ts` and a checkbox that appears by itself; an
|
|
1001
|
+
entry that says `batched: true` gets the how-often select beside it too. An
|
|
1002
|
+
event a user has said nothing about is at its default, which is why turning one
|
|
1003
|
+
on for a site does not need anybody to visit the users screen.
|
|
1004
|
+
|
|
1005
|
+
### Akismet
|
|
1006
|
+
|
|
1007
|
+
The one checker the package ships. It is off until a site has a key, and the
|
|
1008
|
+
key is a credential rather than a setting: it lives in `data/akismet.json` at
|
|
1009
|
+
mode `0600` beside the password hashes and the users' private keys, never in
|
|
1010
|
+
`content/_data/site.json`, which is public, in git and published with the site.
|
|
1011
|
+
|
|
1012
|
+
Paste it into **Spam checking** on `/admin/settings/discussion`. It is checked with
|
|
1013
|
+
Akismet's `verify-key` before it is stored, and the panel then reads Connected,
|
|
1014
|
+
"Akismet does not recognise this key", "Akismet could not be reached", or Not
|
|
1015
|
+
connected. The key is never printed back — the last four characters are, so it
|
|
1016
|
+
is possible to tell which key is in there. A **Remove key** button turns Akismet
|
|
1017
|
+
off again.
|
|
1018
|
+
|
|
1019
|
+
The file is read on every call, so a key saved on that screen filters the next
|
|
1020
|
+
comment and a removed one stops filtering at once. Neither needs a restart.
|
|
1021
|
+
|
|
1022
|
+
A `commentChecker` in the config wins outright: the Akismet checker is built
|
|
1023
|
+
only when the site named none.
|
|
1024
|
+
|
|
1025
|
+
Every comment and every incoming webmention that gets past the honeypot, the
|
|
1026
|
+
form's age and the rate limit goes to `comment-check` with `blog`, `user_ip`,
|
|
1027
|
+
`user_agent`, `referrer`, `permalink`, `comment_type` (`comment` or
|
|
1028
|
+
`webmention`), `comment_author`, `comment_author_email`, `comment_author_url`,
|
|
1029
|
+
`comment_content`, `comment_date_gmt`, `blog_lang`, `blog_charset` and, for a
|
|
1030
|
+
form submission, `honeypot_field_name`. Fediverse replies are never sent: they
|
|
1031
|
+
do not reach the checker seam at all.
|
|
1032
|
+
|
|
1033
|
+
| Akismet says | What happens |
|
|
1034
|
+
| ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
1035
|
+
| `true` | Filed as spam, where a moderator can still see it and change its mind. |
|
|
1036
|
+
| `true` with `X-akismet-pro-tip: discard` | Dropped without a queue entry. |
|
|
1037
|
+
| `false` | The site's own rules decide: approved for a name and email approved before, pending otherwise. |
|
|
1038
|
+
| `invalid`, a non-200, a timeout, no route | The same as `false`, and the failure is logged with whatever Akismet said was wrong. |
|
|
1039
|
+
|
|
1040
|
+
`false` is deliberately not a positive opinion. Nothing Akismet does can lose a
|
|
1041
|
+
comment: every failure is no opinion, no opinion is the queue, and a call is
|
|
1042
|
+
abandoned after ten seconds.
|
|
1043
|
+
|
|
1044
|
+
Marking something spam or not spam on `/admin/comments` posts `submit-spam` or
|
|
1045
|
+
`submit-ham` with the same fields, minus `user_ip`, `user_agent` and `referrer`
|
|
1046
|
+
— a stored comment keeps a salted hash of the address and nothing else.
|
|
1047
|
+
|
|
1048
|
+
## Webmentions
|
|
1049
|
+
|
|
1050
|
+
The open web's version of what ActivityPub does: one page telling another that
|
|
1051
|
+
it linked to it. Both directions are on by default and each has a switch on the
|
|
1052
|
+
Discussion settings page.
|
|
1053
|
+
|
|
1054
|
+
| Setting in `site.json` | What it does |
|
|
1055
|
+
| ---------------------- | ------------------------------------------------------------------------ |
|
|
1056
|
+
| `webmentionsSend` | Tell the external pages a post links to, when it is published or edited. |
|
|
1057
|
+
| `webmentionsReceive` | Advertise the endpoint on every page and accept what is sent to it. |
|
|
1058
|
+
|
|
1059
|
+
**Sending** runs off the same index changes federation does, so a post saved in
|
|
1060
|
+
the editor and one edited on disk are one thing, and a full scan sends nothing.
|
|
1061
|
+
Every external link in the rendered body is asked whether it advertises a
|
|
1062
|
+
Webmention endpoint — the `Link` header first, then the first `<link>` or `<a>`
|
|
1063
|
+
with `rel="webmention"` — and told if it does. Both versions of an edited post
|
|
1064
|
+
are read, so a page that has just been _unlinked_ is told too and can drop what
|
|
1065
|
+
it was showing. One outcome is recorded per link (`sent`, `none` for a page that
|
|
1066
|
+
takes none, or `failed`), shown per post on `/admin/federation`, and the Resend
|
|
1067
|
+
button there sends them again from the file as it now reads.
|
|
1068
|
+
|
|
1069
|
+
**Receiving** advertises `/_geekity/webmention` two ways — a `Link` header on
|
|
1070
|
+
every representation of a document, and a `<link rel="webmention">` in the head
|
|
1071
|
+
— and takes a form `POST` of `source` and `target`. It answers `202` for
|
|
1072
|
+
anything worth checking and `400` for anything that is not, and does the looking
|
|
1073
|
+
afterwards: it fetches the source, checks it really links to the target, reads
|
|
1074
|
+
its microformats (`h-entry`, `h-card`, and whether it is a reply, a like, a
|
|
1075
|
+
repost or a plain mention), and files the result as a **pending comment** with
|
|
1076
|
+
`source: "webmention"` in the same file, the same thread and the same moderation
|
|
1077
|
+
queue as everything else — through the same `commentChecker` seam, so a checker
|
|
1078
|
+
can tell one from a form submission by its `source`. Akismet is told it is a
|
|
1079
|
+
`webmention` rather than a `comment`.
|
|
1080
|
+
|
|
1081
|
+
The source URL is its identity. A page that sends its webmention again updates
|
|
1082
|
+
what it left rather than adding a second, and one whose link has gone — or which
|
|
1083
|
+
answers 404 or 410 — takes it away. Closing rules do not apply: a post that
|
|
1084
|
+
stopped taking comments still hears about a page that links to it, exactly as it
|
|
1085
|
+
still hears a fediverse reply.
|
|
1086
|
+
|
|
1087
|
+
## Email
|
|
1088
|
+
|
|
1089
|
+
The CMS sends its mail through one service and one seam. Everything that will
|
|
1090
|
+
ever email — the Send test email button, and the password resets, moderation
|
|
1091
|
+
notices and contact messages built on it — calls `cms.mail.send()`, and what
|
|
1092
|
+
carries the message is decided in one place.
|
|
1093
|
+
|
|
1094
|
+
**A site with no mail configuration still works.** `send` writes a line in the
|
|
1095
|
+
log saying the message was not sent and resolves successfully, so nothing that
|
|
1096
|
+
emails is a feature that breaks without a mail account.
|
|
1097
|
+
|
|
1098
|
+
### Configuring it
|
|
1099
|
+
|
|
1100
|
+
Two halves, kept apart for the reason the Akismet key is kept out of
|
|
1101
|
+
`site.json`:
|
|
1102
|
+
|
|
1103
|
+
| Where | What |
|
|
1104
|
+
| ------------------------- | --------------------------------------------------------------------------------- |
|
|
1105
|
+
| `content/_data/site.json` | `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`, `contactEmail`. |
|
|
1106
|
+
| `data/mail.json` | The Brevo API key, and the SMTP host, port, TLS flag, user and password. |
|
|
1107
|
+
|
|
1108
|
+
`site.json` is public, in git and published with the site; `data/mail.json` is
|
|
1109
|
+
private, mode `0600`, and sits beside the password hashes and the users'
|
|
1110
|
+
private keys. No key or password is ever written to `site.json` and none is
|
|
1111
|
+
ever printed back into the settings screen — the panel shows the last four
|
|
1112
|
+
characters of an API key and the host and user of an SMTP connection, which is
|
|
1113
|
+
enough to tell what is stored and not enough to use it. Leaving a secret field
|
|
1114
|
+
blank keeps the one already stored, so the SMTP port can be corrected without
|
|
1115
|
+
retyping the password. **Remove credentials** forgets both.
|
|
1116
|
+
|
|
1117
|
+
The panel draws the boxes of the provider that is saved and no others: an API
|
|
1118
|
+
key on a Brevo site, a host, port, TLS flag, user and password on an SMTP one,
|
|
1119
|
+
and nothing at all on a site sending through neither. It follows what is in
|
|
1120
|
+
`site.json` rather than what the Provider select above it is showing, so
|
|
1121
|
+
choosing a different provider there takes a **Save settings** before its boxes
|
|
1122
|
+
appear — the panel says as much. A credential stored for the provider that is
|
|
1123
|
+
not the chosen one still gets a line of its own, because it is still on disk
|
|
1124
|
+
and **Remove credentials** is still what forgets it.
|
|
1125
|
+
|
|
1126
|
+
Both files are read on every send, so a credential pasted into the settings
|
|
1127
|
+
screen sends the next message and one removed stops the message after it.
|
|
1128
|
+
Neither needs a restart.
|
|
1129
|
+
|
|
1130
|
+
`mailFromAddress` should be an address the provider has verified; empty falls
|
|
1131
|
+
back to `no-reply@` at the site's host, which most providers will refuse.
|
|
1132
|
+
|
|
1133
|
+
### The two providers
|
|
1134
|
+
|
|
1135
|
+
| `mailProvider` | What it is |
|
|
1136
|
+
| -------------- | ------------------------------------------------------------------------------------- |
|
|
1137
|
+
| `none` | Nothing is sent. The default. |
|
|
1138
|
+
| `brevo` | `POST https://api.brevo.com/v3/smtp/email` with an `api-key` header. No open sockets. |
|
|
1139
|
+
| `smtp` | Any SMTP server, through nodemailer — including Brevo's own relay. |
|
|
1140
|
+
|
|
1141
|
+
### Send test email
|
|
1142
|
+
|
|
1143
|
+
The Email settings page has an address field and a button that sends the `test`
|
|
1144
|
+
message through the whole chain — the template, the From line, the provider and
|
|
1145
|
+
the retry — and reports what came back, the provider's own words and its
|
|
1146
|
+
message id included. It is the one thing that proves mail works before somebody
|
|
1147
|
+
needs it to.
|
|
1148
|
+
|
|
1149
|
+
It is on the page only where the site can actually send — a provider chosen and
|
|
1150
|
+
its credential stored. Without one the only thing the button could report is
|
|
1151
|
+
that it did nothing.
|
|
1152
|
+
|
|
1153
|
+
### Queue, retries and the log
|
|
1154
|
+
|
|
1155
|
+
Messages go on one queue and are sent one at a time, so a provider that hangs
|
|
1156
|
+
delays the next message and nothing else. A refused message is tried three
|
|
1157
|
+
times, waiting two seconds, then eight; every attempt is logged with its
|
|
1158
|
+
outcome and, when it went, the provider's message id, so a message somebody
|
|
1159
|
+
never received can be traced to the attempt that carried it. `send` never
|
|
1160
|
+
throws — a failure is a result with `ok: false` and the provider's own words in
|
|
1161
|
+
`error`.
|
|
1162
|
+
|
|
1163
|
+
### Messages
|
|
1164
|
+
|
|
1165
|
+
Messages are Nunjucks templates under `mail/` in the theme, resolved the way
|
|
1166
|
+
every other template is: the theme the site has chosen first, the packaged theme
|
|
1167
|
+
second, file by file. Each is up to three files — `mail/<name>.subject.njk`,
|
|
1168
|
+
`mail/<name>.txt.njk` (required) and `mail/<name>.html.njk` — so a site can
|
|
1169
|
+
replace the text of a message and keep the subject the package ships, or add a
|
|
1170
|
+
message the package never had. The full table is in
|
|
1171
|
+
[`themes/default/README.md`](./themes/default/README.md).
|
|
1172
|
+
|
|
1173
|
+
### From an entry file, and from a test
|
|
1174
|
+
|
|
1175
|
+
```ts
|
|
1176
|
+
const result = await cms.mail.send({
|
|
1177
|
+
to: 'ada@example.com',
|
|
1178
|
+
template: 'test',
|
|
1179
|
+
data: { name: 'Ada' },
|
|
1180
|
+
});
|
|
1181
|
+
result.ok; // true, whether it went or the site sends no mail
|
|
1182
|
+
result.skipped; // true when the site sends no mail
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
`sendRaw({ to, subject, text, html })` is the same thing for a message the
|
|
1186
|
+
caller built itself. Both resolve when the message has been sent, given up on
|
|
1187
|
+
or skipped; a caller that does not care drops the promise with `void`.
|
|
1188
|
+
|
|
1189
|
+
A test names a provider in the config and reads back what would have gone out.
|
|
1190
|
+
It wins outright over the settings and `data/mail.json`, the way a
|
|
1191
|
+
`commentChecker` wins over the Akismet key:
|
|
1192
|
+
|
|
1193
|
+
```ts
|
|
1194
|
+
import { createCms, createMemoryMailProvider } from '@geekity/cms';
|
|
1195
|
+
|
|
1196
|
+
const provider = createMemoryMailProvider();
|
|
1197
|
+
const cms = createCms({
|
|
1198
|
+
mail: { provider, backoffMs: () => 0 },
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
// …
|
|
1202
|
+
provider.sent[0].subject;
|
|
1203
|
+
provider.failNext(2); // watch the retry without waiting for it
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
`mail` also takes `attempts`, `backoffMs` and `logger`, which is how a test
|
|
1207
|
+
proves the retry without spending ten seconds on it.
|
|
1208
|
+
|
|
1209
|
+
## The archive page
|
|
1210
|
+
|
|
1211
|
+
A page carrying `archive: true` in its front matter lists every published post
|
|
1212
|
+
under its own words, grouped by the month it was published in, newest month
|
|
1213
|
+
first and newest post first inside a month. Nothing paginates it: an archive
|
|
1214
|
+
page is a way of finding one piece of writing rather than a listing to read
|
|
1215
|
+
through. Drafts and posts whose date has not arrived are absent, as they are
|
|
1216
|
+
everywhere else.
|
|
1217
|
+
|
|
1218
|
+
The default theme draws it from `partials/archive.njk`, and the CMS hands the
|
|
1219
|
+
layout `archiveMonths` — `{ month, posts }` per month, the month already read
|
|
1220
|
+
through the site's `timezone` so the heading and the dates agree. Eleventy
|
|
1221
|
+
ignores the key, and a static build of the same directory publishes the page as
|
|
1222
|
+
an ordinary page.
|
|
1223
|
+
|
|
1224
|
+
## The contact form
|
|
1225
|
+
|
|
1226
|
+
A page carrying `contact: true` in its front matter renders a contact form
|
|
1227
|
+
under its content — name, email, subject, message. The editor offers it as a
|
|
1228
|
+
**Contact form** checkbox on the pages editor, and the key is honoured wherever
|
|
1229
|
+
it is written, so a theme that includes `partials/contact-form.njk` in its post
|
|
1230
|
+
layout gets one there too.
|
|
1231
|
+
|
|
1232
|
+
It works with JavaScript switched off, and there is no script on it. The form
|
|
1233
|
+
posts to `/_geekity/contact`, and a message that is stored redirects back to
|
|
1234
|
+
the page with `?contact=sent`, which draws a thank-you where the form was — so
|
|
1235
|
+
a refresh sends nothing twice.
|
|
1236
|
+
|
|
1237
|
+
**Where the message goes never appears in the HTML.** The contact address is
|
|
1238
|
+
read when a submission arrives; it is not on any render context, so no theme
|
|
1239
|
+
can print it and no form carries it.
|
|
1240
|
+
|
|
1241
|
+
### Where a message goes
|
|
1242
|
+
|
|
1243
|
+
| Where | What |
|
|
1244
|
+
| ----------------------------------- | --------------------------------------------------------------------------- |
|
|
1245
|
+
| `data/contact/<id>.json`, mode 0600 | Every message, written **before** anything is emailed. |
|
|
1246
|
+
| The `contactEmail` setting | Where the `contact-message` email is sent, reply-to the sender. |
|
|
1247
|
+
| `/admin/messages` | The inbox: read, mark read, delete. The dashboard carries the unread count. |
|
|
1248
|
+
|
|
1249
|
+
`contactEmail` is on the Settings screen under Email. Empty falls back to the
|
|
1250
|
+
first admin with an email address, by username, so a fresh site with a mail
|
|
1251
|
+
credential takes messages without anybody visiting the field.
|
|
1252
|
+
|
|
1253
|
+
The file is written first and the mail goes out behind the redirect, which is
|
|
1254
|
+
the whole point of the design: a provider that is down, a key that has expired
|
|
1255
|
+
or an address that bounces costs a notification rather than the message. **With
|
|
1256
|
+
no mail configured at all, submissions are still stored and still shown on the
|
|
1257
|
+
Messages screen** — it is the notification, exactly as `/admin/comments` was
|
|
1258
|
+
before there was any email.
|
|
1259
|
+
|
|
1260
|
+
Messages live under `data/` rather than `content/` because they carry the
|
|
1261
|
+
sender's address and were never meant to be published, and there is no SQLite
|
|
1262
|
+
index over them: the screen reads the directory to sort it anyway, and a second
|
|
1263
|
+
copy of the truth would only be a second thing to keep true. One JSON file per
|
|
1264
|
+
message, named by an id that begins with the instant it arrived, so `ls` is the
|
|
1265
|
+
inbox in order. Deleting a message on the screen deletes its file.
|
|
1266
|
+
|
|
1267
|
+
### What is in a message file
|
|
1268
|
+
|
|
1269
|
+
```json
|
|
1270
|
+
{
|
|
1271
|
+
"id": "20260920T120000000Z-1a2b3c4d",
|
|
1272
|
+
"received": "2026-09-20T12:00:00.000Z",
|
|
1273
|
+
"status": "received",
|
|
1274
|
+
"read": false,
|
|
1275
|
+
"page": { "slug": "contact", "permalink": "/contact/", "title": "Say hello" },
|
|
1276
|
+
"from": { "name": "Ada Lovelace", "email": "ada@example.com" },
|
|
1277
|
+
"subject": "About the analytical engine",
|
|
1278
|
+
"message": "It is a lovely machine. Please write back.",
|
|
1279
|
+
"addressHash": "9f2c…"
|
|
1280
|
+
}
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
`addressHash` is the same salted hash a comment keeps — the salt is
|
|
1284
|
+
`data/comment-salt` — so a run of submissions from one machine is visible
|
|
1285
|
+
across both without a reader's address being written down.
|
|
1286
|
+
|
|
1287
|
+
### The defences in front of it
|
|
1288
|
+
|
|
1289
|
+
The same four a comment goes through, out of the same modules, so the two
|
|
1290
|
+
public forms cannot drift into different rules:
|
|
1291
|
+
|
|
1292
|
+
| Defence | What it refuses |
|
|
1293
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
1294
|
+
| The honeypot | A hidden field a person never fills in. A submission that filled it gets the thank-you and is dropped in silence. |
|
|
1295
|
+
| The form's age | Submitted under three seconds after the page loaded, or from a form rendered more than a day ago. |
|
|
1296
|
+
| The rate limit | Five messages per address per ten minutes, on the same limiter the login form uses. The sixth is a 429. |
|
|
1297
|
+
| The comment checker | The `commentChecker` seam, told `comment_type: contact-form`. With an Akismet key stored, that is Akismet. |
|
|
1298
|
+
|
|
1299
|
+
A checker's `discard` stores nothing at all. Its `spam` **stores the message**,
|
|
1300
|
+
on the Spam list of the Messages screen, and does not email it: a false
|
|
1301
|
+
positive on a contact form is somebody's message vanishing, which is a worse
|
|
1302
|
+
failure than a spam list to glance at. A checker that is down or throwing is no
|
|
1303
|
+
opinion, and the message is stored as it would have been before anybody had
|
|
1304
|
+
one.
|
|
1305
|
+
|
|
369
1306
|
## Keeping the index in step
|
|
370
1307
|
|
|
371
1308
|
Booting scans `contentDir`, indexes every Markdown file under `posts/` and
|
|
@@ -380,6 +1317,77 @@ await cms.sync(); // one full scan; what `serve()` runs on boot
|
|
|
380
1317
|
Set `watch: false` (or `GEEKITY_WATCH=false`) for a host whose content cannot
|
|
381
1318
|
change under the process. `geekity sync` sets it for you.
|
|
382
1319
|
|
|
1320
|
+
## Dates and the timezone setting
|
|
1321
|
+
|
|
1322
|
+
Every date the CMS writes into a file — `date`, `updated` and
|
|
1323
|
+
`activitypub.published` — is a UTC instant ending in `Z`. The `timezone`
|
|
1324
|
+
setting is not stored with the date; it is the lens the instant is read
|
|
1325
|
+
through.
|
|
1326
|
+
|
|
1327
|
+
That means one rule in three places:
|
|
1328
|
+
|
|
1329
|
+
- **The editor** shows a stored instant as the clock reads in the site's zone,
|
|
1330
|
+
with the zone named under the field (`Wall-clock time in America/Chicago
|
|
1331
|
+
(CDT). Stored as UTC.`), and reads what you type back in the same zone. Type
|
|
1332
|
+
`2026-09-04 09:00` in a site set to `America/Chicago` and the file gets
|
|
1333
|
+
`date: '2026-09-04T14:00:00Z'`. A value you write with an offset of its own —
|
|
1334
|
+
`2026-09-04T09:00:00+02:00` — already names an instant, so it is kept as one.
|
|
1335
|
+
- **The theme's `date` filter** renders `readable`, `html` and `year` in the
|
|
1336
|
+
site's zone; `iso` stays the instant, because that is what a `<time
|
|
1337
|
+
datetime>` and a feed want. Changing the setting changes what every page
|
|
1338
|
+
shows on the very next request, with no file touched and no rebuild.
|
|
1339
|
+
- **A new post's filename day and its `/{yyyy}/{mm}/` permalink** come from the
|
|
1340
|
+
calendar day the site's zone was on at that instant, so a post published at
|
|
1341
|
+
half past midnight on 1 October in Berlin is filed under October and not
|
|
1342
|
+
under the 30 September that UTC was still on.
|
|
1343
|
+
|
|
1344
|
+
Changing the setting later never moves a URL. The permalink is written
|
|
1345
|
+
explicitly into every file the CMS saves, and the day a post is filed under is
|
|
1346
|
+
the one in its filename — decided once, when it was written, where no setting
|
|
1347
|
+
can reach it.
|
|
1348
|
+
|
|
1349
|
+
Files written by hand keep working. A `date` with an offset, or one YAML parses
|
|
1350
|
+
into a timestamp by itself, is read for the instant it names, sorts and
|
|
1351
|
+
schedules by that instant, and is rewritten as UTC the next time the CMS saves
|
|
1352
|
+
that file — never by a mere scan. A hand-written file with no `permalink` still
|
|
1353
|
+
resolves to the URL Eleventy gives it, which is cut from the date as the file
|
|
1354
|
+
spells it.
|
|
1355
|
+
|
|
1356
|
+
Feeds, the sitemap and the ActivityStreams objects emit instants and are
|
|
1357
|
+
unaffected by the setting, and a scheduled post fires at its instant whatever
|
|
1358
|
+
the zone is set to.
|
|
1359
|
+
|
|
1360
|
+
## Scheduling
|
|
1361
|
+
|
|
1362
|
+
A post whose `date` is in the future is written now and published then, the way
|
|
1363
|
+
WordPress schedules one. Save it with Publish; the editor answers `Scheduled:
|
|
1364
|
+
{title}` and says when it goes out, in the site's `timezone` setting.
|
|
1365
|
+
|
|
1366
|
+
Until that moment the post is not public in any sense: it is off the home page,
|
|
1367
|
+
off every archive, out of all three feeds and out of the ActivityPub outbox,
|
|
1368
|
+
its permalink 404s in every representation, and no follower has been told about
|
|
1369
|
+
it. When the moment comes the running server publishes it — no restart, no
|
|
1370
|
+
build — and the same delivery that a live publish runs sends the
|
|
1371
|
+
`Create(Article)` and pings the notify server.
|
|
1372
|
+
|
|
1373
|
+
There is no `scheduled` key and no state to keep: a scheduled post is an
|
|
1374
|
+
ordinary published one whose date has not arrived, which is a question asked of
|
|
1375
|
+
the clock on every query. So moving the date moves the publication, ticking
|
|
1376
|
+
Draft cancels it, and a date pushed into the future withdraws a post that was
|
|
1377
|
+
already out — with a `Delete` to the followers, exactly as drafting it would.
|
|
1378
|
+
|
|
1379
|
+
A post whose date passed while the server was down is public the moment the
|
|
1380
|
+
next boot has scanned, and is federated once on that boot. The CMS remembers
|
|
1381
|
+
how far through the calendar it has got, so a restart never announces the same
|
|
1382
|
+
post twice and a deleted database never announces the archive.
|
|
1383
|
+
|
|
1384
|
+
The admin lists scheduled posts under their own Scheduled filter, with a
|
|
1385
|
+
Scheduled status beside them. The public permalink 404s for a signed-in admin
|
|
1386
|
+
too: the public site has no session, deliberately — every response it gives is
|
|
1387
|
+
cacheable, and a URL that answered differently for one viewer would be cached
|
|
1388
|
+
and served to the rest. Use the editor's Preview button, which renders the post
|
|
1389
|
+
through the theme's own layout at an admin URL.
|
|
1390
|
+
|
|
383
1391
|
## Hooks
|
|
384
1392
|
|
|
385
1393
|
Two hooks let a site do something of its own when content changes. Both are
|
|
@@ -452,37 +1460,105 @@ and `off()`.
|
|
|
452
1460
|
|
|
453
1461
|
Booting also mounts the admin at `/admin`. It is server-rendered from templates
|
|
454
1462
|
that ship inside the package, deliberately outside the theme search path: a
|
|
455
|
-
site's
|
|
1463
|
+
site's theme may override any public template, and must not be able to
|
|
456
1464
|
shadow the login form.
|
|
457
1465
|
|
|
458
|
-
| Route
|
|
459
|
-
|
|
|
460
|
-
| `/admin`
|
|
461
|
-
| `/admin/
|
|
462
|
-
| `/admin/
|
|
463
|
-
| `/admin/
|
|
464
|
-
| `/admin/
|
|
465
|
-
| `/admin/
|
|
466
|
-
| `/admin/
|
|
467
|
-
| `/admin/
|
|
468
|
-
| `/admin/
|
|
469
|
-
| `/admin/
|
|
470
|
-
| `/admin/
|
|
471
|
-
| `/admin/
|
|
472
|
-
| `/admin/
|
|
473
|
-
| `/admin/
|
|
1466
|
+
| Route | What it does |
|
|
1467
|
+
| ------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
|
1468
|
+
| `/admin` | The dashboard: counts, the five most recent posts, the follower count. |
|
|
1469
|
+
| `/admin/posts`, `/admin/pages` | The listings and the editors. |
|
|
1470
|
+
| `/admin/tags`, `/admin/categories` | Every term in use, with rename, merge and delete. |
|
|
1471
|
+
| `/admin/tags/rename`, `/admin/categories/rename` | `POST` only. Renames a term, or merges it into one that exists. |
|
|
1472
|
+
| `/admin/tags/delete`, `/admin/categories/delete` | `POST` only. Takes a term out of every file. |
|
|
1473
|
+
| `/admin/navigation` | Navigation > Menus: every menu the site holds. `POST` saves one menu's items. |
|
|
1474
|
+
| `/admin/navigation/add` | `POST` only. Adds an empty menu under the name the form gives. |
|
|
1475
|
+
| `/admin/navigation/delete` | `POST` only. Deletes one menu the active theme renders nowhere. |
|
|
1476
|
+
| `/admin/media` | Everything under `content/uploads`, with the URL, the Markdown and what uses it. |
|
|
1477
|
+
| `/admin/media/upload` | `POST` only. Stores one file by the rules the editor's upload enforces. |
|
|
1478
|
+
| `/admin/media/delete` | `POST` only. Deletes one upload, asking first when a document points at it. |
|
|
1479
|
+
| `/admin/comments` | Pending, approved and spam, with approve, spam, delete and reply. |
|
|
1480
|
+
| `/admin/comments/moderate` | `POST` only. Approves one comment, files it as spam, or deletes it. |
|
|
1481
|
+
| `/admin/comments/reply` | `POST` only. Posts an approved reply under the comment it answers. |
|
|
1482
|
+
| `/admin/messages` | The contact form's inbox, with a Spam list beside it. |
|
|
1483
|
+
| `/admin/messages/read` | `POST` only. Marks one message read, or unread again. |
|
|
1484
|
+
| `/admin/messages/delete` | `POST` only. Deletes one message, and its file with it. |
|
|
1485
|
+
| `/admin/appearance/themes` | Appearance > Themes: the themes on disk. `POST` activates the one named. |
|
|
1486
|
+
| `/admin/tools` | Tools > Content index: what the index holds, and the button that rebuilds it. |
|
|
1487
|
+
| `/admin/tools/rebuild-index` | `POST` only. Offers the rebuild, then reads every file again on the live site. |
|
|
1488
|
+
| `/admin/settings` | Settings > General: title, tagline, author, base URL, time zone, language. |
|
|
1489
|
+
| `/admin/settings/reading` | What the homepage displays, posts per page, the notify server. |
|
|
1490
|
+
| `/admin/settings/permalinks` | The tag and category bases, and the archive redirects already recorded. |
|
|
1491
|
+
| `/admin/settings/discussion` | Comments and the closing window, webmentions, and the Akismet key. |
|
|
1492
|
+
| `/admin/settings/akismet` | `POST` only. Saves the Akismet key, or forgets it. |
|
|
1493
|
+
| `/admin/settings/email` | The mail provider, the From line, the reply-to and the contact address. |
|
|
1494
|
+
| `/admin/settings/mail` | `POST` only. Saves a mail credential, or forgets every one of them. |
|
|
1495
|
+
| `/admin/settings/mail/test` | `POST` only. Sends the theme's test message through the whole chain. |
|
|
1496
|
+
| `/admin/users` | Who may sign in: a row each, with Edit and Delete. Nothing on it edits anybody. |
|
|
1497
|
+
| `/admin/users/new` | Users > Add new: the add form. `POST` adds one. |
|
|
1498
|
+
| `/admin/users/<id>` | One user: the account, the profile, the notices, your password, and the delete. |
|
|
1499
|
+
| `/admin/users/password` | `POST` only. Changes the signed-in admin's own password. |
|
|
1500
|
+
| `/admin/users/email` | `POST` only. Sets or clears the email address on the user the form names. |
|
|
1501
|
+
| `/admin/users/profile` | `POST` only. Saves the whole public profile of the user the form names. |
|
|
1502
|
+
| `/admin/users/notifications` | `POST` only. Turns one notice on or off for the user the form names. |
|
|
1503
|
+
| `/admin/users/notifications/mode` | `POST` only. Sets how often that notice arrives: as they arrive, hourly or daily. |
|
|
1504
|
+
| `/admin/users/delete` | `POST` only. Deletes the user the form names. |
|
|
1505
|
+
| `/admin/federation` | The actors, their followers, the inbox log, and per-post delivery. |
|
|
1506
|
+
| `/admin/federation/settings` | Federation > Settings: the relays the site subscribes to, and the WordPress switch. |
|
|
1507
|
+
| `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
|
|
1508
|
+
| `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
|
|
1509
|
+
| `/admin/login` | Username and password. |
|
|
1510
|
+
| `/admin/logout` | `POST` only. Deletes the session row. |
|
|
1511
|
+
| `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
|
|
474
1512
|
|
|
475
1513
|
The screens behind the login share one layout: a bar across the top with the
|
|
476
|
-
site name and a link to the public site, the
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1514
|
+
site name and a link to the public site, the menu down the left, and a place for
|
|
1515
|
+
flash messages. A message queued with `flash(c, 'notice', '…')` is kept on the
|
|
1516
|
+
session row, shown on the next page the browser asks for, and cleared as it is
|
|
1517
|
+
read, so it survives exactly one redirect.
|
|
1518
|
+
|
|
1519
|
+
### The menu
|
|
1520
|
+
|
|
1521
|
+
The menu is WordPress classic: twelve sections — Dashboard, Posts, Pages,
|
|
1522
|
+
Navigation, Media, Comments, Messages, Appearance, Users, Tools, Settings,
|
|
1523
|
+
Federation — each a heading over one or more children. Clicking a heading opens the section and lands on its first
|
|
1524
|
+
child; the open section shows its children and the one you are on carries
|
|
1525
|
+
`aria-current="page"`, so Users tells you that you are on Users > All users.
|
|
1526
|
+
Tags and categories are children of Posts, because a tag with no post on it is
|
|
1527
|
+
nothing. Every section has at least one child even when it has one screen, so
|
|
1528
|
+
the rule never needs an exception and a second child can appear later without
|
|
1529
|
+
the menu changing shape. There is no JavaScript in it: the server knows which
|
|
1530
|
+
section is open, so expanding one is a page rather than a script.
|
|
1531
|
+
|
|
1532
|
+
The whole menu is `ADMIN_SECTIONS` in `src/admin/menu.ts`. A site's own screen
|
|
1533
|
+
joins it in two steps:
|
|
481
1534
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1535
|
+
```ts
|
|
1536
|
+
// 1. One entry in the section's children, naming the screen and its URL.
|
|
1537
|
+
{ child: 'imports', label: 'Imports', url: '/admin/imports' }
|
|
1538
|
+
|
|
1539
|
+
// 2. The same pair on whatever that screen renders.
|
|
1540
|
+
render(c, 'layouts/imports.njk', { section: 'posts', child: 'imports' });
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
Nothing else changes: the heading, the landing URL and the marking all follow.
|
|
1544
|
+
A screen naming a section or a child the registry does not hold throws
|
|
1545
|
+
`UnknownAdminScreenError` rather than rendering a menu expanded around nothing,
|
|
1546
|
+
so the mistake is a failed test instead of a menu that quietly marks nothing.
|
|
1547
|
+
|
|
1548
|
+
Who may sign in is `data/users.json`: one entry per user with an id, a
|
|
1549
|
+
username, an argon2 hash and a created time — and, for a user who has them, an
|
|
1550
|
+
email address, notification preferences, a public profile and the
|
|
1551
|
+
[stored actor id](#a-users-stored-actor-id) they were published under
|
|
1552
|
+
elsewhere. Written atomically with `0600` permissions and serialised against
|
|
1553
|
+
itself, so two admins adding the same name at once cannot both succeed. It is in `data/` rather than `content/` because
|
|
1554
|
+
`content/` is published with the site, and it is one of the two things under
|
|
1555
|
+
`dataDir` that must be backed up — the users' actor keys are the other.
|
|
1556
|
+
|
|
1557
|
+
Sessions stay in SQLite, where the rest of the cache lives. They name a user by
|
|
1558
|
+
id and are joined against the file on every admin request, so a session whose
|
|
1559
|
+
user is no longer there is not a login: deleting `data/geekity.db` signs
|
|
1560
|
+
everybody out and loses nothing else, and restoring an old one cannot bring
|
|
1561
|
+
back an account that was deleted or a password that was changed.
|
|
486
1562
|
|
|
487
1563
|
Passwords are hashed with argon2id through `node:crypto`, so there is no native
|
|
488
1564
|
module to build. The cost parameters travel with each hash, which means raising
|
|
@@ -499,40 +1575,428 @@ planted session id cannot become a logged-in one.
|
|
|
499
1575
|
|
|
500
1576
|
The first admin comes from `/admin/setup` or from
|
|
501
1577
|
[`geekity user add`](#creating-an-admin-from-the-command-line). Every one after
|
|
502
|
-
that comes from `/admin/users`, which lists who may sign in
|
|
503
|
-
password you supply or one it generates and shows once
|
|
504
|
-
and
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1578
|
+
that comes from `/admin/users`, which lists who may sign in and adds a user
|
|
1579
|
+
with a password you supply or one it generates and shows once. The list is a
|
|
1580
|
+
listing and nothing more: a username with the archive URL under it, the email
|
|
1581
|
+
address, when the account was made, and Edit and Delete. Every field lives on
|
|
1582
|
+
that person's own screen at `/admin/users/<id>`, where each box has a label
|
|
1583
|
+
beside it — the account and its email address, the public profile, what this
|
|
1584
|
+
person is emailed about, your own password on your own page, and the delete or
|
|
1585
|
+
the sentence saying why this account cannot go. Each Save lands back on that
|
|
1586
|
+
screen, so it shows its own result. The last remaining user cannot be deleted,
|
|
1587
|
+
and nobody may delete their own account.
|
|
1588
|
+
|
|
1589
|
+
An email address is optional on a user and can be set on anybody's screen,
|
|
1590
|
+
since with one role every user already has every power there is. It buys
|
|
1591
|
+
[password recovery](#forgotten-passwords) and the notices
|
|
1592
|
+
[a comment sets off](#being-told-about-one), and it never appears on the public
|
|
1593
|
+
site. Beside it is which of those notices go to it: one switch per event, on by
|
|
1594
|
+
default, stored in `data/users.json` only when somebody turns one off.
|
|
1595
|
+
|
|
1596
|
+
A site whose database was written by a version that kept the accounts in a
|
|
1597
|
+
`users` table has those rows written into `data/users.json` on the first boot
|
|
1598
|
+
of this one — ids and hashes as they stand, so nobody has to log in again — and
|
|
1599
|
+
the table is dropped. A `users.json` that is already there wins and is left
|
|
1600
|
+
alone.
|
|
508
1601
|
|
|
509
1602
|
Creating one from your own code, which is what the CLI does:
|
|
510
1603
|
|
|
511
1604
|
```ts
|
|
512
|
-
import {
|
|
1605
|
+
import { createUser } from '@geekity/cms';
|
|
513
1606
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1607
|
+
await createUser({
|
|
1608
|
+
dataDir: 'data',
|
|
1609
|
+
username: 'ada',
|
|
1610
|
+
password: process.env.PASSWORD ?? '',
|
|
1611
|
+
});
|
|
517
1612
|
```
|
|
518
1613
|
|
|
1614
|
+
### Settings
|
|
1615
|
+
|
|
1616
|
+
`content/_data/site.json` is the source of truth for a site's settings. The
|
|
1617
|
+
pages under `/admin/settings` — General, Reading, Permalinks, Discussion and
|
|
1618
|
+
Email — each read that file, validate what was typed and write it back; nothing
|
|
1619
|
+
else remembers a setting, and `data/geekity.db` holds none of them. Federation's
|
|
1620
|
+
page is a sixth one of exactly the same kind, filed under its own section at
|
|
1621
|
+
`/admin/federation/settings` rather than under Settings, because the relays and
|
|
1622
|
+
the compatibility switch are about the section that holds the followers.
|
|
1623
|
+
|
|
1624
|
+
Each page saves its own fields and no others, onto the file as re-read inside
|
|
1625
|
+
the write, so two people saving two different pages at the same moment both
|
|
1626
|
+
land, and each page validates only what it shows: a refused save comes back on
|
|
1627
|
+
the page it was sent from and writes nothing at all.
|
|
1628
|
+
|
|
1629
|
+
The write is atomic and serialised: the bytes go to a temporary file beside the
|
|
1630
|
+
real one and are renamed over it, and the read of what the file already held
|
|
1631
|
+
and the write of what it says next are one step nothing else writing that file
|
|
1632
|
+
can get between. So a reader — an Eleventy build, the CMS's own site data
|
|
1633
|
+
source, another process entirely — always sees one whole version, and two saves
|
|
1634
|
+
at once cannot each keep half of what the other kept.
|
|
1635
|
+
|
|
1636
|
+
The file carries `title`, `tagline`, `url`, `author`, `postsPerPage`,
|
|
1637
|
+
`homepage`, `postsPage`,
|
|
1638
|
+
`timezone`, `language`, `tagBase`,
|
|
1639
|
+
`categoryBase`, `notifyServer`, `webmentionsSend`, `webmentionsReceive`,
|
|
1640
|
+
`mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
|
|
1641
|
+
`contactEmail`, `relays`, `menus` and `taxonomyRedirects`,
|
|
1642
|
+
and every other key it already had is kept, `feedSize` and anything a site put
|
|
1643
|
+
there included. A key it does not carry is the default, and a key of the wrong
|
|
1644
|
+
type is the default too: a hand-edited `site.json` cannot take the site down.
|
|
1645
|
+
|
|
1646
|
+
`homepage`, `postsPage` and `wordpressActivityPub` are the only three written
|
|
1647
|
+
just when they have a value. The first two are WordPress's Reading choice, the
|
|
1648
|
+
slug of the page served at `/` and the slug of the page whose own URL carries
|
|
1649
|
+
the post listing, absent altogether on a site that shows its latest posts at
|
|
1650
|
+
`/`; the third is the
|
|
1651
|
+
[WordPress ActivityPub compatibility](#wordpress-activitypub-compatibility)
|
|
1652
|
+
switch, absent until somebody turns it on and absent again when they turn it
|
|
1653
|
+
off. A `postsPage` without a `homepage` is
|
|
1654
|
+
ignored, the listing being at `/` already, and a slug naming no published page
|
|
1655
|
+
is a site back on its latest posts. See [The front page](#the-front-page).
|
|
1656
|
+
|
|
1657
|
+
Because it is the source rather than a copy, editing it by hand while the
|
|
1658
|
+
server runs is picked up on the next request — on the public site, in the
|
|
1659
|
+
feeds and on the settings screen alike — with nothing
|
|
1660
|
+
to restart and nothing to tell. Put the file in git and a checkout of it is the
|
|
1661
|
+
site's settings.
|
|
1662
|
+
|
|
1663
|
+
The one exception is `url`. A base URL decides the absolute URLs in the feeds,
|
|
1664
|
+
the ActivityStreams ids and whether the session cookie is `Secure`, so
|
|
1665
|
+
`GEEKITY_BASE_URL` and a `baseUrl` in the config file both win over the file's,
|
|
1666
|
+
and the General settings page renders the field read-only and says which value
|
|
1667
|
+
is in effect and why. When neither names one, the file's `url` becomes the base URL
|
|
1668
|
+
at boot — at boot rather than on save, so an `https` base URL cannot log out
|
|
1669
|
+
the admin who submitted it over `http`.
|
|
1670
|
+
|
|
1671
|
+
Two things on that screen are not settings and are not in that file: the
|
|
1672
|
+
Akismet key and the mail credential. Both are credentials, both live under
|
|
1673
|
+
`data/` at mode `0600`, and each is its own pair of forms — save and remove —
|
|
1674
|
+
so a key typed wrong cannot lose an edit to the title. See
|
|
1675
|
+
[Akismet](#akismet) and [Email](#email).
|
|
1676
|
+
|
|
1677
|
+
A site whose database was written by a version that kept the settings in SQLite
|
|
1678
|
+
has those rows written into `site.json` on the first boot of this one, and the
|
|
1679
|
+
table is dropped. If the file was written after the rows were — a hand edit, or
|
|
1680
|
+
a content directory restored from git — the file wins outright.
|
|
1681
|
+
|
|
1682
|
+
### The front page
|
|
1683
|
+
|
|
1684
|
+
`/` is the site's latest posts until the Reading settings say otherwise. The
|
|
1685
|
+
choice is WordPress's own, and so are its two answers:
|
|
1686
|
+
|
|
1687
|
+
- **Your latest posts.** The archive at `/`, paginated at `/page/N/`. This is
|
|
1688
|
+
the default, and it is what an empty `homepage` means.
|
|
1689
|
+
- **A static page.** The page is served at `/`, its own permalink answers `301`
|
|
1690
|
+
to `/` so the front page has one URL, and the menu links it at `/`. The
|
|
1691
|
+
default theme draws it with `layouts/front-page.njk` — the page's own words,
|
|
1692
|
+
then `Recent Posts`, then the line of links and the bio — and a theme lays it
|
|
1693
|
+
out differently by overriding that one file.
|
|
1694
|
+
|
|
1695
|
+
With a homepage set, a second pick gives the listing a page of its own: the
|
|
1696
|
+
**posts page**. Its permalink carries the listing, with the page's own title
|
|
1697
|
+
and words above the posts, paginated beneath it at `{permalink}page/N/`;
|
|
1698
|
+
`/page/N/` at the root redirects there, and the theme's `layouts/posts-page.njk` is
|
|
1699
|
+
the theme's override, falling back to the listing layout. A posts page with no
|
|
1700
|
+
homepage is refused, as WordPress refuses it, and so is one page picked as
|
|
1701
|
+
both. With a homepage and no posts page the listing has no page of its own,
|
|
1702
|
+
which is WordPress's answer too.
|
|
1703
|
+
|
|
1704
|
+
The feeds do not move: `/feed/`, `/feed/atom/` and `/feed/json/` syndicate the
|
|
1705
|
+
site's posts wherever the listing is read, and every page advertises them. The
|
|
1706
|
+
sitemap follows the site — `/` once, and the listing's pages under the posts
|
|
1707
|
+
page — and the pages list marks the two rows **Front Page** and **Posts Page**.
|
|
1708
|
+
|
|
1709
|
+
Both settings are page slugs in `content/_data/site.json`, so an Eleventy build
|
|
1710
|
+
of the same directory shows the same front page:
|
|
1711
|
+
`docs/eleventy.config.example.js` puts the homepage at `/` and flags the posts
|
|
1712
|
+
page on the context as `isPostsPage`. A slug whose page is drafted, trashed or
|
|
1713
|
+
deleted names nothing published, and the site is back to its latest posts with
|
|
1714
|
+
the pick kept: publishing the page again puts the front page back.
|
|
1715
|
+
|
|
1716
|
+
### The media library
|
|
1717
|
+
|
|
1718
|
+
`/admin/media` lists every file under `content/uploads`, newest first by
|
|
1719
|
+
modification time, with a thumbnail for a picture and its extension at the same
|
|
1720
|
+
size for anything else, the size in bytes, the date, and — for a library that
|
|
1721
|
+
has grown — a pager, twenty-four files to a page.
|
|
1722
|
+
|
|
1723
|
+
The list is a walk of the directory on every request rather than a query.
|
|
1724
|
+
Nothing records that an upload exists: the filesystem is the truth
|
|
1725
|
+
(decision-1, decision-9), so a file copied in over ssh, pulled in by git, or
|
|
1726
|
+
written by an Eleventy build is on the screen without a restart, and one
|
|
1727
|
+
removed the same way is off it. The only question the index is asked is which
|
|
1728
|
+
documents mention a URL, which is the one thing a directory cannot answer.
|
|
1729
|
+
|
|
1730
|
+
Each row carries the public URL and the ready-made Markdown — ``
|
|
1731
|
+
for a picture and `[name](url)` for anything else, from the same function the
|
|
1732
|
+
editor's upload control pastes from — as readonly text fields, which select and
|
|
1733
|
+
copy on their own. `admin/static/copy.js` reveals a Copy button beside each and
|
|
1734
|
+
does nothing else, so the screen is complete before it loads and a browser
|
|
1735
|
+
without the clipboard API is not shown a button that would fail.
|
|
1736
|
+
|
|
1737
|
+
The upload form is `storeUpload`, the same function behind `POST /admin/uploads`
|
|
1738
|
+
alike: one allowlist, one signature check, one
|
|
1739
|
+
`{yyyy}/{mm}/{slug}{ext}` naming rule, and one set of refusals, shown as a flash
|
|
1740
|
+
on the next page rather than as the editor's JSON.
|
|
1741
|
+
|
|
1742
|
+
Delete asks when it should. A file nothing points at is deleted on the first
|
|
1743
|
+
click. A file a document still mentions — anywhere in its body, as Markdown, as
|
|
1744
|
+
HTML or in prose — brings back a confirmation naming every document that does,
|
|
1745
|
+
linked to its editor and marked when it is in the trash, and only a form
|
|
1746
|
+
carrying `confirm` goes through. The trash counts because a trashed post can be
|
|
1747
|
+
restored, and restoring one whose picture went in the meantime is a broken post
|
|
1748
|
+
nobody was warned about. The submitted path is resolved against
|
|
1749
|
+
`content/uploads` and refused when it lands outside, so a form field cannot
|
|
1750
|
+
name a file elsewhere in the content directory.
|
|
1751
|
+
|
|
1752
|
+
`deleteUpload` takes a `removeDerived` hook, called with the same
|
|
1753
|
+
content-relative path once the original is gone. The original under
|
|
1754
|
+
`content/uploads` is the only source of truth (decision-10) and anything
|
|
1755
|
+
generated from it is derived state that must not outlive it; the hook defaults
|
|
1756
|
+
to `removeImageVariants`, which takes the file's derived images with it.
|
|
1757
|
+
|
|
1758
|
+
### Image variants
|
|
1759
|
+
|
|
1760
|
+
`generateImageVariants(config, uploadPath)` derives one upload's copies with
|
|
1761
|
+
sharp and writes them, plus an `image.json` sidecar, into
|
|
1762
|
+
`<dataDir>/images/<the upload's path>/`. It runs inside `storeUpload`, so the
|
|
1763
|
+
editor's control and the media library both produce the same
|
|
1764
|
+
variants for the same file and the encoding is paid for by whoever uploaded it.
|
|
1765
|
+
It answers `undefined`, having written nothing, for a GIF, an animation, a PDF,
|
|
1766
|
+
a text file, an upload that is not there, and a site with `imageOptimization`
|
|
1767
|
+
off — every one of which means "serve the original", which is what the site
|
|
1768
|
+
would have done anyway.
|
|
1769
|
+
|
|
1770
|
+
| Function | What it is for |
|
|
1771
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------ |
|
|
1772
|
+
| `generateImageVariants` | Derive the whole set and write the sidecar. Two callers at once share one encode. |
|
|
1773
|
+
| `describeImage` | The record, synchronously and cached, for a renderer that must not open a file. |
|
|
1774
|
+
| `findImageVariant` | One derived file by request path, generating it when it is missing. |
|
|
1775
|
+
| `removeImageVariants` | Take a source's derived directory away. |
|
|
1776
|
+
| `responsiveImages(html, describe)` | Rewrite `<img src="/uploads/…">` as `<picture>`. Pure: hand it any lookup. |
|
|
1777
|
+
| `siteImageMarkup(config, html)` | The same, over one site's own records, deriving in the background for a record miss. |
|
|
1778
|
+
| `siteIcons(config, avatar)` | The head's icon links for a site's avatar: `{ rel, sizes, href }`, or none at all. |
|
|
1779
|
+
|
|
1780
|
+
`describeImage` is synchronous and cached because it is called once per image
|
|
1781
|
+
while a page renders, and decision-10 forbids probing an image file at render
|
|
1782
|
+
time. Its cache entry outlives the sidecar on purpose: `data/images/` is
|
|
1783
|
+
disposable, so a page whose variants have been swept away keeps rendering the
|
|
1784
|
+
markup that asks for them back, and the first request for each one rebuilds it.
|
|
1785
|
+
|
|
1786
|
+
The site's icons are derived copies of the same kind, and `siteIcons` is how a
|
|
1787
|
+
head gets at them: three square PNGs, `icon` at 32 and 16 pixels and
|
|
1788
|
+
`apple-touch-icon` at 180, cropped from the middle of the site's avatar and
|
|
1789
|
+
encoded the first time a browser asks for one. It answers an empty list — and
|
|
1790
|
+
the packaged theme then links no icon at all — when the site has no avatar, when
|
|
1791
|
+
its avatar is a file no icon can be made of, or when `imageOptimization` is off,
|
|
1792
|
+
so a head never advertises a URL this server would answer 404 for.
|
|
1793
|
+
|
|
1794
|
+
`documentContext(document, images)` is where the rewrite is applied. Passing the
|
|
1795
|
+
config is what turns the page's `content` into `<picture>` markup; the feeds,
|
|
1796
|
+
`documentJson`, the Markdown representation and `postArticle` all read
|
|
1797
|
+
`document.html` directly and so keep the plain `<img>` by construction.
|
|
1798
|
+
|
|
1799
|
+
### Managing tags and categories
|
|
1800
|
+
|
|
1801
|
+
`/admin/tags` and `/admin/categories` list every term in use with two counts and
|
|
1802
|
+
a link to its archive: **Posts**, what the public site lists under it, and
|
|
1803
|
+
**Files**, every file carrying it — drafts, scheduled posts and the trash
|
|
1804
|
+
included. The second is the one to look at before acting, because it is what an
|
|
1805
|
+
action is about to rewrite.
|
|
1806
|
+
|
|
1807
|
+
Three things can be done to a term, and all three are the same operation. Typing
|
|
1808
|
+
a new name into a row's field and pressing Rename rewrites the front matter of
|
|
1809
|
+
every file carrying it. Renaming onto a term that already exists is a **merge**:
|
|
1810
|
+
the screen comes back with a question naming both counts, and only a confirmed
|
|
1811
|
+
form goes through, because a merge cannot be undone by renaming back. A file
|
|
1812
|
+
that carried both terms keeps the target once, in the place it already had.
|
|
1813
|
+
Delete takes the term out of every file and leaves everything else alone.
|
|
1814
|
+
|
|
1815
|
+
Each of them says how many files it wrote. The files are the truth (doc-1), so
|
|
1816
|
+
each file is re-read and re-parsed from disk before it is rewritten rather than
|
|
1817
|
+
being taken from the index: the index can be a moment behind a hand edit, and a
|
|
1818
|
+
bulk rewrite that trusted it would put that edit back. A file that has gone or
|
|
1819
|
+
will not parse is skipped, counted, and named in the message.
|
|
1820
|
+
|
|
1821
|
+
Every rewrite is announced the way an editor save is, so the index, the feeds,
|
|
1822
|
+
the notify server and the fediverse all follow — one `Update(Article)` per
|
|
1823
|
+
affected published post, because the hashtags on those posts have just changed.
|
|
1824
|
+
A term on many published posts is therefore many deliveries; that is the point,
|
|
1825
|
+
but it is worth knowing before renaming a tag that half the archive carries.
|
|
1826
|
+
|
|
1827
|
+
A rename moves an archive, and an archive is a URL somebody may have linked to,
|
|
1828
|
+
so the site records the move in a `taxonomyRedirects` list — `{ taxonomy, from,
|
|
1829
|
+
to }` — in `content/_data/site.json` with the rest of the settings. The
|
|
1830
|
+
old archive URL and its feed then answer `301` to the new ones. Chains are
|
|
1831
|
+
collapsed as they are recorded, so `a → b` followed by `b → c` is stored as
|
|
1832
|
+
`a → c` and answered in one hop; a term that comes back into use is served
|
|
1833
|
+
rather than redirected; and deleting a term drops every record pointing at it,
|
|
1834
|
+
because a redirect to a 404 is worse than the 404.
|
|
1835
|
+
|
|
1836
|
+
### Login hardening
|
|
1837
|
+
|
|
1838
|
+
`/admin/login` counts failed sign-ins against two keys: the username that was
|
|
1839
|
+
typed, and the address the request came from. Once either has failed
|
|
1840
|
+
`loginAttempts` times — five by default — the admin refuses further attempts
|
|
1841
|
+
from that key for `loginLockout` seconds, fifteen minutes by default, and
|
|
1842
|
+
answers `429` with a `Retry-After` header and a message saying how long is
|
|
1843
|
+
left.
|
|
1844
|
+
|
|
1845
|
+
Three things about that are deliberate:
|
|
1846
|
+
|
|
1847
|
+
- **The lockout is checked before the password is.** A correct password during
|
|
1848
|
+
a lockout is refused too. Verifying first would hand the site to whoever
|
|
1849
|
+
guessed right on the attempt that tripped the limit.
|
|
1850
|
+
- **The wait doubles each time the lockout is tripped again**, up to sixteen
|
|
1851
|
+
times the first one — four hours at the default. There is no artificial pause
|
|
1852
|
+
on an individual attempt: a `sleep` in the handler holds a connection open for
|
|
1853
|
+
as long as it lasts, so it would cost the server more than the attacker.
|
|
1854
|
+
- **The message says nothing about who exists.** A username nobody has is
|
|
1855
|
+
counted and locked out exactly as a real one is, so the lockout cannot be used
|
|
1856
|
+
to enumerate accounts, and a wrong password and an unknown username still read
|
|
1857
|
+
the same.
|
|
1858
|
+
|
|
1859
|
+
A sign-in that works clears both keys, and so does time: a key that stops
|
|
1860
|
+
failing is forgotten. The counts live in memory, so a restart clears them.
|
|
1861
|
+
That is the point — a failed login writes nothing an anonymous caller could
|
|
1862
|
+
grow, and the run of guesses this defends against happens over minutes, which
|
|
1863
|
+
no restart interrupts. Both refusals are logged with the username and the
|
|
1864
|
+
address.
|
|
1865
|
+
|
|
1866
|
+
`trustProxy` decides where the address comes from. It is `false` by default and
|
|
1867
|
+
the address is the socket's own, because anybody may send `X-Forwarded-For`: on
|
|
1868
|
+
a site reached directly, believing it would let one attacker put every guess on
|
|
1869
|
+
a different make-believe address and never be locked out by address at all. Turn
|
|
1870
|
+
it on — and only on — when a reverse proxy in front of the site sets that
|
|
1871
|
+
header; the leftmost entry is then used. With neither available, which is what
|
|
1872
|
+
happens when the app is driven in process rather than served, the username is
|
|
1873
|
+
the only key.
|
|
1874
|
+
|
|
1875
|
+
### Forgotten passwords
|
|
1876
|
+
|
|
1877
|
+
`/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
|
|
1878
|
+
which takes a username _or_ an email address and always answers with the same
|
|
1879
|
+
sentence — whether the name matched, did not match, or matched somebody who has
|
|
1880
|
+
no address on their account. That is the point of the screen: an answer that
|
|
1881
|
+
varied with what it found would be a list of which accounts the site has,
|
|
1882
|
+
handed to anybody who asked for it.
|
|
1883
|
+
|
|
1884
|
+
A match with an address is sent the theme's `password-reset` message, carrying
|
|
1885
|
+
a link to `/admin/reset?token=…`. The token is 256 random bits; the database
|
|
1886
|
+
keeps only its SHA-256, in a table beside the sessions, so a copy of
|
|
1887
|
+
`data/geekity.db` is not a stack of working links, and the token is never
|
|
1888
|
+
rendered into the page of the browser that asked for it. It lasts an hour and
|
|
1889
|
+
works once.
|
|
1890
|
+
|
|
1891
|
+
Setting a password through the link holds it to the same rules every other door
|
|
1892
|
+
does, then closes everything else: the link that was used, every other reset
|
|
1893
|
+
that user had outstanding, and every session they were signed in on. The
|
|
1894
|
+
theme's `password-changed` message goes out afterwards, with no link back in,
|
|
1895
|
+
so a reset somebody did not ask for is something they hear about.
|
|
1896
|
+
|
|
1897
|
+
Requests are rate limited by username and by address exactly as sign-ins are —
|
|
1898
|
+
same limits, same growing lockout, same `429` and `Retry-After` — on a throttle
|
|
1899
|
+
of its own. Sharing the login one would let a stranger lock somebody out of
|
|
1900
|
+
signing in by asking for their password to be reset over and over.
|
|
1901
|
+
|
|
1902
|
+
Reset tokens live in the database, which is a cache: a restart, a
|
|
1903
|
+
`geekity rebuild` or a deleted `data/geekity.db` invalidates every link in
|
|
1904
|
+
flight, and the cost of that is a second click on Forgot password.
|
|
1905
|
+
|
|
1906
|
+
With no mail provider or credential, `/admin/forgot` offers no form at all. It
|
|
1907
|
+
says the site cannot send email and points at
|
|
1908
|
+
[`geekity user add`](#creating-an-admin-from-the-command-line), which is how a
|
|
1909
|
+
site nobody can sign in to gets a fresh admin from a shell. Saving a credential
|
|
1910
|
+
under [Settings → Email](#email) turns the form on for the next visitor, with
|
|
1911
|
+
no restart.
|
|
1912
|
+
|
|
1913
|
+
### Security headers
|
|
1914
|
+
|
|
1915
|
+
Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
|
|
1916
|
+
`Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
|
|
1917
|
+
is an `https` URL — the same test that decides whether the session cookie is
|
|
1918
|
+
`Secure`, so the two cannot disagree. That is all the public site gets: a theme
|
|
1919
|
+
is somebody else's HTML and the CMS has no business deciding what it may
|
|
1920
|
+
reference.
|
|
1921
|
+
|
|
1922
|
+
Every response under `/admin`, static files and redirects included, carries
|
|
1923
|
+
three more:
|
|
1924
|
+
|
|
1925
|
+
| Header | Value |
|
|
1926
|
+
| ------------------------- | --------------------------------------------- |
|
|
1927
|
+
| `Referrer-Policy` | `same-origin` |
|
|
1928
|
+
| `X-Frame-Options` | `SAMEORIGIN` |
|
|
1929
|
+
| `Content-Security-Policy` | `default-src 'self'` and the directives below |
|
|
1930
|
+
|
|
1931
|
+
The policy is `'self'` almost everywhere: the stylesheet and the editor bundle
|
|
1932
|
+
come from `/admin/_static/`, the editor's preview and upload calls are
|
|
1933
|
+
same-origin `fetch`, and the preview frame renders the site's own theme and
|
|
1934
|
+
uploads. Four directives say more than that:
|
|
1935
|
+
|
|
1936
|
+
- `style-src 'self' 'nonce-…'` — a fresh nonce on every response. CodeMirror 6
|
|
1937
|
+
mounts its themes as `<style>` elements at runtime, which is an inline style
|
|
1938
|
+
however it is written. The nonce is put on the editor bundle's `<script>` tag
|
|
1939
|
+
and read back through the element's `nonce` property, which a browser keeps
|
|
1940
|
+
after blanking the attribute, and handed to CodeMirror through
|
|
1941
|
+
`EditorView.cspNonce`. So the admin never says `'unsafe-inline'`.
|
|
1942
|
+
- `script-src 'self'` — no nonce and no `'unsafe-inline'`, because there is no
|
|
1943
|
+
inline script in the admin at all. The slug and permalink auto-fill lives in
|
|
1944
|
+
`/admin/_static/slug.js`.
|
|
1945
|
+
- `img-src 'self' data:` — a preview of a post somebody is writing may hold a
|
|
1946
|
+
data URI image, and refusing it would make the preview lie about what
|
|
1947
|
+
publishing would show.
|
|
1948
|
+
- `frame-ancestors 'self'` rather than `'none'` — the editor's preview is a
|
|
1949
|
+
sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
|
|
1950
|
+
the admin page itself.
|
|
1951
|
+
|
|
519
1952
|
## The public site
|
|
520
1953
|
|
|
521
1954
|
Booting mounts the public site on the app. The routes are:
|
|
522
1955
|
|
|
523
|
-
| Route
|
|
524
|
-
|
|
|
525
|
-
| `/`
|
|
526
|
-
| `/page/2/` and up
|
|
527
|
-
| a document's permalink
|
|
528
|
-
| `/
|
|
529
|
-
| `/
|
|
530
|
-
| `/
|
|
531
|
-
| `/
|
|
532
|
-
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1956
|
+
| Route | What it serves |
|
|
1957
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
1958
|
+
| `/` | Published posts, newest first. |
|
|
1959
|
+
| `/page/2/` and up | Later pages of the same archive. |
|
|
1960
|
+
| a document's permalink | The post or the page, through the theme. |
|
|
1961
|
+
| `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
|
|
1962
|
+
| `/category/{name}/` | The second taxonomy, paginated the same way. |
|
|
1963
|
+
| `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
|
|
1964
|
+
| `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
|
|
1965
|
+
| `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
|
|
1966
|
+
| `/author/{username}/feed/` and siblings | The same, for one person. |
|
|
1967
|
+
| `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
|
|
1968
|
+
| `{permalink}feed/` | One post's replies, the same way. |
|
|
1969
|
+
| `/sitemap.xml` | Every public URL, for a search engine. |
|
|
1970
|
+
| `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
|
|
1971
|
+
| `/robots.txt` | What a crawler may have, and where the sitemap is. |
|
|
1972
|
+
| `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
|
|
1973
|
+
| `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
|
|
1974
|
+
| `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
|
|
1975
|
+
| `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
|
|
1976
|
+
| `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
|
|
1977
|
+
| `/theme/…` | The theme's own files, from its `static/` directory. |
|
|
1978
|
+
| `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
|
|
1979
|
+
| `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
|
|
1980
|
+
| anything else | The theme's 404. |
|
|
1981
|
+
|
|
1982
|
+
The federation routes go on before it, and answer only their own paths:
|
|
1983
|
+
|
|
1984
|
+
| Route | What it serves |
|
|
1985
|
+
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
|
1986
|
+
| `/author/{username}/` | The `Person` on an ActivityStreams request; the archive above to anything else. |
|
|
1987
|
+
| `/author/{username}/inbox/` | `POST` only. That user's inbox, signature-verified by Fedify. |
|
|
1988
|
+
| `/author/{username}/outbox/` | Their published posts as `Create` activities, paged 20 at a time. |
|
|
1989
|
+
| `/author/{username}/followers/` | Who follows them, paged 20 at a time. |
|
|
1990
|
+
| `/author/{username}/following/` | Always empty: a relay is a subscription rather than a relationship. |
|
|
1991
|
+
| `/inbox/` | `POST` only. The instance-wide shared inbox; the addressee is read out of the body. |
|
|
1992
|
+
| `/@{username}` | A 301 to their archive. |
|
|
1993
|
+
| `/.well-known/webfinger` | The handle, the author URL or `/@{username}`, all resolving to the same actor. |
|
|
1994
|
+
| `/.well-known/nodeinfo` | A link to the NodeInfo document. |
|
|
1995
|
+
| `/nodeinfo/2.1` | What software this is, how many users and how many posts. |
|
|
1996
|
+
| a post's permalink | The `Article` on an ActivityStreams request (decision-13); the page to anything else. |
|
|
1997
|
+
|
|
1998
|
+
Drafts, documents in the trash and posts whose date has not arrived are not on
|
|
1999
|
+
the public site: their URLs 404, and they are in no listing.
|
|
536
2000
|
|
|
537
2001
|
Trailing slashes are canonical. A request that arrives without one redirects
|
|
538
2002
|
301 to the URL with it, but only when that URL resolves — a genuinely missing
|
|
@@ -541,7 +2005,116 @@ address 404s straight away rather than bouncing first. `/page/1/` redirects to
|
|
|
541
2005
|
|
|
542
2006
|
A document is resolved after every registered route has failed to match, so
|
|
543
2007
|
routes a site adds always win over a permalink that happens to collide with
|
|
544
|
-
them.
|
|
2008
|
+
them. The taxonomy archives are resolved there too, ahead of the documents: a
|
|
2009
|
+
route table is fixed when the app is built and the archive bases are a setting,
|
|
2010
|
+
so `/{tagBase}/{tag}/` is matched against whatever the site holds at the moment
|
|
2011
|
+
of the request.
|
|
2012
|
+
|
|
2013
|
+
`tag` and `category` are the bases a site has until it says otherwise —
|
|
2014
|
+
WordPress's own, so a site imported from it keeps every archive URL it
|
|
2015
|
+
published. `tagBase` and `categoryBase` on the Permalinks settings page move
|
|
2016
|
+
them, and
|
|
2017
|
+
the routes, the paging, the canonical redirects, the tag feeds, the theme's
|
|
2018
|
+
links and the ActivityStreams hashtags all follow on the next request. A base
|
|
2019
|
+
is one URL-safe path segment: no slashes, and not a path the site already
|
|
2020
|
+
answers on (`page`, `feed`, `comments`, `admin`, `theme`, `uploads`,
|
|
2021
|
+
`nodeinfo`, `author`, `inbox`), and
|
|
2022
|
+
the two may not be the same word. Both are `tagBase` and `categoryBase` in
|
|
2023
|
+
`content/_data/site.json`, so an Eleventy build of the same content directory
|
|
2024
|
+
can put its archives at the same URLs.
|
|
2025
|
+
|
|
2026
|
+
### Navigation
|
|
2027
|
+
|
|
2028
|
+
A menu is a named thing the site stores and the theme asks for.
|
|
2029
|
+
|
|
2030
|
+
**The site stores menus by name.** `content/_data/site.json` holds a `menus`
|
|
2031
|
+
object keyed by the name of each menu, each an ordered list of items:
|
|
2032
|
+
|
|
2033
|
+
```json
|
|
2034
|
+
{
|
|
2035
|
+
"menus": {
|
|
2036
|
+
"primary": [{ "label": "About", "url": "/about/" }],
|
|
2037
|
+
"footer": [
|
|
2038
|
+
{ "label": "Colophon", "url": "/colophon/" },
|
|
2039
|
+
{ "label": "Mastodon", "url": "https://example.social/@me", "me": true }
|
|
2040
|
+
]
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
An item is a label, a URL and the flags it carries. The URL is a site-root path
|
|
2046
|
+
or an absolute `http(s)` URL; anything else is refused. There is one flag,
|
|
2047
|
+
`me`, present only when it is set and read only when it is spelled exactly
|
|
2048
|
+
`true`: it gives the link `rel="me"`, which is how Mastodon and the rest of the
|
|
2049
|
+
IndieWeb verify that the site and the profile it links are the same person.
|
|
2050
|
+
|
|
2051
|
+
**The theme declares the areas it renders.** `theme.json` carries an `areas`
|
|
2052
|
+
list, each entry a `name` — the key under `menus` — and a `label` a person
|
|
2053
|
+
reads:
|
|
2054
|
+
|
|
2055
|
+
```json
|
|
2056
|
+
{ "areas": [{ "name": "primary", "label": "Site menu" }] }
|
|
2057
|
+
```
|
|
2058
|
+
|
|
2059
|
+
The packaged theme declares `primary` ("Site menu") and `footer` ("Footer
|
|
2060
|
+
links"). A theme that wants a third declares it; an `areas` that is missing or
|
|
2061
|
+
unreadable leaves that theme declaring none rather than failing to load it, and
|
|
2062
|
+
the Navigation screen then offers the packaged theme's — a site theme is laid
|
|
2063
|
+
over the packaged one a file at a time, so a theme that has declared nothing has
|
|
2064
|
+
not overridden the declaration, and its inherited `layouts/base.njk` really is
|
|
2065
|
+
still rendering `menus.primary` and `menus.footer`.
|
|
2066
|
+
|
|
2067
|
+
**A theme renders a menu by name.** Every template gets `menus`, keyed by the
|
|
2068
|
+
same names, each item already marked `current` for the path being rendered.
|
|
2069
|
+
`{% for item in menus.footer %}` is the whole interface, so a site theme can
|
|
2070
|
+
render a menu the packaged theme has never heard of. Every stored menu is on
|
|
2071
|
+
the context, not only the declared ones: the declaration is what the screen
|
|
2072
|
+
that edits menus reads, and a menu nothing loops over is rendered nowhere and
|
|
2073
|
+
kept — which is how somebody writes the menu a theme will use before switching
|
|
2074
|
+
to that theme.
|
|
2075
|
+
|
|
2076
|
+
**The menus are managed on `/admin/navigation`.** It is a top-level section
|
|
2077
|
+
after Pages rather than a settings page: a menu is content a site arranges, the
|
|
2078
|
+
way its pages are, and the shape of the screen comes from the active theme.
|
|
2079
|
+
The screen is two lists. First the areas that theme declares, in the theme's
|
|
2080
|
+
order and under its labels — an area the site has never filled in is an empty
|
|
2081
|
+
box, not a missing one. Then every other stored menu, under a heading saying
|
|
2082
|
+
this theme renders them nowhere; those are still editable, which is how the
|
|
2083
|
+
menu a theme will use is written before the site switches to it, and each has
|
|
2084
|
+
the Delete that is the only way a menu is removed. An area the theme declares
|
|
2085
|
+
is emptied rather than deleted: the theme goes on asking for the name, and the
|
|
2086
|
+
box has to stay for the next thing typed into it.
|
|
2087
|
+
|
|
2088
|
+
Each menu is one box of `Label | URL` lines, in the order they are typed, with
|
|
2089
|
+
a trailing `| me` marking the flag. Only a flag this CMS has is taken off the
|
|
2090
|
+
end of a line, so a URL holding a bar survives and a trailing word that is not
|
|
2091
|
+
a flag stays part of the URL. A line that is not an item is refused by name,
|
|
2092
|
+
with the box still holding every line of what was typed, and nothing written.
|
|
2093
|
+
|
|
2094
|
+
**A menu name** is lower-case ASCII letters, digits and underscores, starting
|
|
2095
|
+
with a letter, at most 32 characters. It is the word a theme writes after the
|
|
2096
|
+
dot — `{% for item in menus.footer %}` — so a dash is refused: after a dot it
|
|
2097
|
+
is a minus sign, and `menus.top-bar` renders nothing and raises nothing. Lower
|
|
2098
|
+
case is the rest of the rule and it is what refuses the confusable spellings:
|
|
2099
|
+
`Footer` is refused rather than quietly lowered, because `footer` and `Footer`
|
|
2100
|
+
would be two menus on one screen that nothing could tell apart. A name the site
|
|
2101
|
+
already holds is refused too, and the message says its box is already on the
|
|
2102
|
+
page. The rule governs a name somebody types; a name a `theme.json` declares is
|
|
2103
|
+
a menu name by declaration and is shown as the theme spells it, because a
|
|
2104
|
+
screen that hid a declared area over a spelling would leave somebody unable to
|
|
2105
|
+
fill in a menu their site renders.
|
|
2106
|
+
|
|
2107
|
+
There is no second source. A page cannot put itself in a menu, so there is one
|
|
2108
|
+
screen to edit it on, one order, and no way for the same link to appear twice.
|
|
2109
|
+
A page that should be linked is linked by typing a line for it — including the
|
|
2110
|
+
page a site serves as its front page, which is typed `Home | /`, the URL a
|
|
2111
|
+
reader lands on, rather than at the permalink that redirects there.
|
|
2112
|
+
|
|
2113
|
+
Because the menus live in `site.json`, an Eleventy build renders the same ones;
|
|
2114
|
+
the example config assembles them as `collections.menus`. A `menus` in a
|
|
2115
|
+
hand-edited `site.json` that is not an object of lists of items yields no menu
|
|
2116
|
+
rather than an error, exactly as a bad archive base falls back rather than
|
|
2117
|
+
taking the site down.
|
|
545
2118
|
|
|
546
2119
|
## Content negotiation
|
|
547
2120
|
|
|
@@ -554,7 +2127,7 @@ three; a listing has two.
|
|
|
554
2127
|
| `text/markdown` | `.md` | The file exactly as it is stored, front matter included. |
|
|
555
2128
|
| `application/json` | `.json` | The document as data, see below. |
|
|
556
2129
|
|
|
557
|
-
Listings — `/`, `/page/N/`, `/
|
|
2130
|
+
Listings — `/`, `/page/N/`, `/tag/{tag}/` and `/tag/{tag}/page/N/` — offer
|
|
558
2131
|
HTML and JSON only. There is no Markdown file behind a listing to serve.
|
|
559
2132
|
|
|
560
2133
|
The representation is chosen like this:
|
|
@@ -666,17 +2239,32 @@ federation middleware before the negotiator sees them.
|
|
|
666
2239
|
|
|
667
2240
|
## Feeds
|
|
668
2241
|
|
|
669
|
-
The recent posts are syndicated in
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
|
673
|
-
|
|
|
674
|
-
| `/feed
|
|
675
|
-
| `/
|
|
676
|
-
| `/
|
|
2242
|
+
The recent posts are syndicated in three formats, at the URLs WordPress uses,
|
|
2243
|
+
so a site moving off it keeps every subscriber it had:
|
|
2244
|
+
|
|
2245
|
+
| URL | Format |
|
|
2246
|
+
| ------------------------ | ------------------------------------------------ |
|
|
2247
|
+
| `/feed/` | RSS 2.0 (`application/rss+xml`) |
|
|
2248
|
+
| `/feed/atom/` | Atom 1.0 (`application/atom+xml`) |
|
|
2249
|
+
| `/feed/json/` | JSON Feed 1.1 (`application/feed+json`) |
|
|
2250
|
+
| `/tag/{tag}/feed/` | One tag archive, in RSS; `atom/` and `json/` too |
|
|
2251
|
+
| `/category/{name}/feed/` | One category archive, the same three |
|
|
2252
|
+
| `/comments/feed/` | Every reply the site has been sent, in RSS |
|
|
2253
|
+
| `{permalink}feed/` | One post's replies, in RSS |
|
|
2254
|
+
|
|
2255
|
+
`/feed/` is RSS because that is the format nearly every existing subscriber
|
|
2256
|
+
holds. The two archive bases are settings (`tagBase`, `categoryBase`), so the
|
|
2257
|
+
per-archive feeds follow wherever the archives live.
|
|
2258
|
+
|
|
2259
|
+
WordPress's older spellings redirect `301` rather than 404: `/feed/rss/` to
|
|
2260
|
+
`/feed/`, and the query forms `?feed=rss2`, `?feed=rss`, `?feed=atom` and
|
|
2261
|
+
`?feed=json` on any listing to that listing's feed, and `?feed=rss2` or
|
|
2262
|
+
`?feed=rss` on a post's permalink to that post's comments feed. A feed URL that
|
|
2263
|
+
arrives without its trailing slash redirects to the canonical one in a single
|
|
2264
|
+
hop.
|
|
677
2265
|
|
|
678
2266
|
A feed holds the newest published posts, newest first. Drafts, documents in the
|
|
679
|
-
trash and pages are never in one, and a
|
|
2267
|
+
trash and pages are never in one, and a term nothing published carries 404s
|
|
680
2268
|
rather than serving an empty feed. `feedSize` in `content/_data/site.json` sets
|
|
681
2269
|
how many entries a feed holds; without it a feed holds 20, which is deliberately
|
|
682
2270
|
more than an archive page, so a reader that polls once a day does not miss a
|
|
@@ -686,56 +2274,248 @@ post on a site that publishes several.
|
|
|
686
2274
|
{ "title": "My Site", "tagline": "Notes", "author": "Me", "feedSize": 20 }
|
|
687
2275
|
```
|
|
688
2276
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
2277
|
+
### What every format says about a post
|
|
2278
|
+
|
|
2279
|
+
The three formats render one **feed item**, derived once per post, rather than
|
|
2280
|
+
each reading the file for itself. So they agree about the three things a reader
|
|
2281
|
+
actually keys on.
|
|
2282
|
+
|
|
2283
|
+
**Identity.** A post is named by its ActivityStreams object id, which is its
|
|
2284
|
+
permalink (see [Federation](#federation)) or the `activitypub.id` the front
|
|
2285
|
+
matter stores for a post migrated from elsewhere. That id is RSS's `guid`,
|
|
2286
|
+
Atom's `id` and JSON Feed's `id`. The permalink is always the link: RSS's
|
|
2287
|
+
`link`, Atom's `link rel="alternate"` and JSON Feed's `url`. The two are the
|
|
2288
|
+
same URL for every post born on this CMS, and differ only for one carrying a
|
|
2289
|
+
stored id — which is what lets a migrated post keep the name its WordPress
|
|
2290
|
+
subscribers already hold. RSS's `isPermaLink` says which of the two a `guid`
|
|
2291
|
+
is: `true` when it is the permalink, `false` for a stored id like `?p=813`.
|
|
2292
|
+
|
|
2293
|
+
**Terms.** Every format lists the post's categories and then its tags, in file
|
|
2294
|
+
order, as one flat list: RSS's `category` elements, Atom's `category term`
|
|
2295
|
+
attributes, JSON Feed's `tags`. None of the three can say which vocabulary a
|
|
2296
|
+
term came from, which is how WordPress publishes both too.
|
|
2297
|
+
|
|
2298
|
+
**Summary.** Every format summarises a post the same way: the `description`
|
|
2299
|
+
front matter when the post has one — it is what the author wrote for exactly
|
|
2300
|
+
this — and otherwise the first paragraph of the rendered body, stripped to
|
|
2301
|
+
plain text and cut at 55 words, WordPress's own excerpt length. It is RSS's
|
|
2302
|
+
`description`, Atom's `summary` and JSON Feed's `summary`, and it is left out
|
|
2303
|
+
only when there is nothing to summarise at all. The whole post goes in the
|
|
2304
|
+
content element beside it, so a reader that shows both has something to choose
|
|
2305
|
+
between.
|
|
2306
|
+
|
|
2307
|
+
### RSS 2.0
|
|
2308
|
+
|
|
2309
|
+
The channel carries `title`, `link`, `description` (the tagline), `language`
|
|
2310
|
+
(the `language` setting, `en` by default), `lastBuildDate`, `generator`, an
|
|
2311
|
+
`atom:link rel="self"`, the three notify-server elements described under
|
|
2312
|
+
[Real-time notification](#real-time-notification), and an `image` built from the
|
|
2313
|
+
avatar when the site has one.
|
|
2314
|
+
|
|
2315
|
+
An item carries `title`, `link`, `guid`, `pubDate` in RFC 822, `dc:creator` from
|
|
2316
|
+
the post's author or the site's, one `category` per term, `description` holding
|
|
2317
|
+
the summary, `content:encoded` holding the whole rendered post, and
|
|
2318
|
+
`source:markdown` holding the Markdown the post was written from. The `guid`,
|
|
2319
|
+
the terms and the summary are the ones described above.
|
|
2320
|
+
|
|
2321
|
+
`source:markdown` is Dave Winer's [source namespace][source-ns]: a reader that
|
|
2322
|
+
understands Markdown should render from it rather than from `content:encoded`.
|
|
2323
|
+
It is the same text the ActivityStreams `Article` carries as its `source`.
|
|
2324
|
+
|
|
2325
|
+
Every item also says where its comments are, three ways: `comments` is the page
|
|
2326
|
+
to read them on, `wfw:commentRss` (the [Well-Formed Web][wfw-ns] comment API) is
|
|
2327
|
+
the feed to poll, and `source:comments` is that same feed with a `count`
|
|
2328
|
+
attribute, so a reader can say "3 comments" without fetching anything.
|
|
2329
|
+
|
|
2330
|
+
[source-ns]: https://source.scripting.com/
|
|
2331
|
+
[wfw-ns]: http://wellformedweb.org/CommentAPI/
|
|
2332
|
+
|
|
2333
|
+
### Comments
|
|
2334
|
+
|
|
2335
|
+
This CMS stores no comments of its own. What it has instead is the fediverse
|
|
2336
|
+
replies its inbox has been sent: a `Create` of a `Note` whose `inReplyTo` names
|
|
2337
|
+
a post's ActivityStreams object id (see [Federation](#federation)). Those are
|
|
2338
|
+
what the comments feeds publish, at the URLs WordPress publishes its own at —
|
|
2339
|
+
`{permalink}feed/` for one post, `/comments/feed/` for the whole site.
|
|
2340
|
+
|
|
2341
|
+
Both are RSS 2.0 and nothing else: a comments feed has no Atom or JSON spelling
|
|
2342
|
+
here, so `{permalink}feed/atom/` 404s. A post with no replies answers an empty
|
|
2343
|
+
feed rather than a 404 — it exists, and a reader that subscribed before anybody
|
|
2344
|
+
answered should keep polling — while a permalink that is no published post 404s
|
|
2345
|
+
like any other. A page has no comments feed at all: only posts federate, so
|
|
2346
|
+
nothing can ever have replied to one.
|
|
2347
|
+
|
|
2348
|
+
A channel carries the usual `title` (`Comments on: {post}`), `link`,
|
|
2349
|
+
`description`, `language`, `lastBuildDate`, `generator`, `atom:link rel="self"`
|
|
2350
|
+
and the same notify-server elements every other feed carries. An item carries the author's name as its `title` and `dc:creator`,
|
|
2351
|
+
the reply's `url` as its `link` and its id as `guid isPermaLink="false"`, the
|
|
2352
|
+
`published` time the note gave (or when it arrived, if it gave none),
|
|
2353
|
+
`description` holding a plain-text excerpt and `content:encoded` holding the
|
|
2354
|
+
note. On the site-wide feed the title names the post as well: `{author} on
|
|
2355
|
+
{post}`.
|
|
2356
|
+
|
|
2357
|
+
The author's name is the profile the site stored when that actor followed it,
|
|
2358
|
+
and otherwise the `@user@host` the actor's own URL implies. Naming them
|
|
2359
|
+
properly would mean dereferencing the actor, which is a network round trip per
|
|
2360
|
+
comment shown.
|
|
2361
|
+
|
|
2362
|
+
**The note's HTML is sanitised before it is published.** It is markup a
|
|
2363
|
+
stranger wrote, so it is tokenised and rebuilt from an allowlist rather than
|
|
2364
|
+
passed through: `p`, `br`, `a`, `em`, `strong`, `del`, `code`, `pre`,
|
|
2365
|
+
`blockquote` and the list elements survive; `script` and `style` are dropped
|
|
2366
|
+
along with their contents; every other element is unwrapped, keeping its text;
|
|
2367
|
+
every attribute goes except an `a`'s `href`, which must be `http`, `https` or
|
|
2368
|
+
`mailto` and is marked `rel="nofollow noopener noreferrer"`. `sanitizeCommentHtml`
|
|
2369
|
+
is exported for a site that shows comments in its own templates.
|
|
2370
|
+
|
|
2371
|
+
A reply to a post that has since been unpublished or moved to the trash
|
|
2372
|
+
disappears from `/comments/feed/`, and that post's own feed 404s with the post.
|
|
2373
|
+
`feedSize` caps both feeds.
|
|
2374
|
+
|
|
2375
|
+
### Atom and JSON Feed
|
|
2376
|
+
|
|
2377
|
+
An Atom entry carries `id` (the post's object id), `title`, `updated`,
|
|
2378
|
+
`published`, `link rel="alternate"` (the permalink), an `author` when the front
|
|
2379
|
+
matter names one, a `category` per term, a `summary`, and the whole rendered
|
|
2380
|
+
post as `content type="html"`. The feed itself carries `id`, `title`,
|
|
2381
|
+
`subtitle` from the site's tagline, `updated`, `link rel="self"`,
|
|
2382
|
+
`link rel="alternate"` to the HTML page, a `generator`, an
|
|
2383
|
+
`xml:lang` from the `language` setting, and — when the site names a notify
|
|
2384
|
+
server — a `source:cloud` and a `link rel="hub"`.
|
|
2385
|
+
|
|
2386
|
+
A JSON Feed item carries `id` (the object id), `url` (the permalink), `title`,
|
|
2387
|
+
`content_html`, `summary`, `date_published`, `date_modified`, `tags` (the
|
|
2388
|
+
terms) and `authors`; the feed carries `version`, `title`, `home_page_url`,
|
|
2389
|
+
`feed_url`, `description`, `authors` and `hubs`. Keys with nothing behind them
|
|
2390
|
+
are left out rather than sent empty.
|
|
2391
|
+
|
|
2392
|
+
The XML is written by this package rather than by a library. Text is escaped;
|
|
2393
|
+
`content:encoded` and `source:markdown` are CDATA sections, with any `]]>` in
|
|
2394
|
+
the text split across two sections so it cannot end one early.
|
|
2395
|
+
|
|
2396
|
+
### Real-time notification
|
|
2397
|
+
|
|
2398
|
+
A feed reader that polls hears about a post when it next polls. An [rssCloud][]
|
|
2399
|
+
or [WebSub][] server turns that around: the reader registers with the server,
|
|
2400
|
+
the site tells the server when a feed changes, and the server tells every
|
|
2401
|
+
subscriber at once.
|
|
2402
|
+
|
|
2403
|
+
One setting, `notifyServer`, is the whole of it. It defaults to
|
|
2404
|
+
`https://rpc.rsscloud.io`, which speaks both protocols, and everything else is
|
|
2405
|
+
derived from it: `{notifyServer}/pleaseNotify` is where a subscriber registers,
|
|
2406
|
+
`{notifyServer}/websub` is the hub, and `{notifyServer}/ping` is where this site
|
|
2407
|
+
says a feed changed. Emptying the setting removes every element, every header
|
|
2408
|
+
and every ping; a different URL moves all of them.
|
|
2409
|
+
|
|
2410
|
+
**Advertising.** An RSS channel carries all three spellings, so a reader of any
|
|
2411
|
+
age finds one it understands:
|
|
2412
|
+
|
|
2413
|
+
```xml
|
|
2414
|
+
<cloud domain="rpc.rsscloud.io" port="80" path="/pleaseNotify"
|
|
2415
|
+
registerProcedure="" protocol="http-post"/>
|
|
2416
|
+
<source:cloud>https://rpc.rsscloud.io/pleaseNotify</source:cloud>
|
|
2417
|
+
<atom:link rel="hub" href="https://rpc.rsscloud.io/websub"/>
|
|
2418
|
+
```
|
|
2419
|
+
|
|
2420
|
+
The port and protocol of the legacy `<cloud>` are `80` and `http-post` whatever
|
|
2421
|
+
scheme the server is actually reached over, because that is what
|
|
2422
|
+
[rpc.rsscloud.io's quick start][quick-start] prescribes and what a 2001
|
|
2423
|
+
aggregator expects to read. An Atom feed carries the `source:cloud` and the
|
|
2424
|
+
`rel="hub"` link but no `<cloud>`, which is an RSS 2.0 element with no namespace.
|
|
2425
|
+
A JSON Feed carries JSON Feed 1.1's `hubs`:
|
|
2426
|
+
|
|
2427
|
+
```json
|
|
2428
|
+
{ "hubs": [{ "type": "WebSub", "url": "https://rpc.rsscloud.io/websub" }] }
|
|
2429
|
+
```
|
|
2430
|
+
|
|
2431
|
+
And every feed response in every format — the site's, each archive's and both
|
|
2432
|
+
comments feeds — carries WebSub's discovery header, on the `304` as well as on
|
|
2433
|
+
the body, because a poller mostly gets the `304`:
|
|
2434
|
+
|
|
2435
|
+
```
|
|
2436
|
+
Link: <https://rpc.rsscloud.io/websub>; rel="hub", <https://example.com/feed/>; rel="self"
|
|
2437
|
+
```
|
|
2438
|
+
|
|
2439
|
+
**Pinging.** The server notifies nobody until it hears the feed changed. So when
|
|
2440
|
+
a post is published, edited or withdrawn — the same index changes that drive
|
|
2441
|
+
ActivityPub delivery, and never a full scan — the site posts
|
|
2442
|
+
`url={feed}` to `{notifyServer}/ping`, once for each feed whose contents moved:
|
|
2443
|
+
the three site feeds, and the three feeds of every tag and category the post
|
|
2444
|
+
carried before _and_ after the change, because a post that left a tag changed
|
|
2445
|
+
that tag's feed as much as the one it joined.
|
|
2446
|
+
|
|
2447
|
+
Pings are best effort. They run one after another on a queue of their own, a
|
|
2448
|
+
repeated URL is sent once, each is given ten seconds, and a refusal is logged
|
|
2449
|
+
rather than thrown: a notify server that is down costs a line in the log, never
|
|
2450
|
+
a save. A site can ping a feed of its own:
|
|
2451
|
+
|
|
2452
|
+
```js
|
|
2453
|
+
await cms.notifyFeeds(['https://example.com/podcast/feed/']);
|
|
2454
|
+
```
|
|
2455
|
+
|
|
2456
|
+
`cms.notifier` is the service behind it — `notify(urls)`, `feedsFor(change)`,
|
|
2457
|
+
`handle(change)` and `settled()` — already subscribed to the index.
|
|
2458
|
+
|
|
2459
|
+
[rsscloud]: https://rpc.rsscloud.io/docs
|
|
2460
|
+
[websub]: https://www.w3.org/TR/websub/
|
|
2461
|
+
[quick-start]: https://rpc.rsscloud.io/docs/quick-start
|
|
2462
|
+
|
|
2463
|
+
### Caching and discovery
|
|
697
2464
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
2465
|
+
Every feed carries `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and
|
|
2466
|
+
answers `If-None-Match` and `If-Modified-Since` with `304`. The validator covers
|
|
2467
|
+
the feed's metadata as well as its entries, and each format and each scope gets
|
|
2468
|
+
its own, so a reader holding the RSS feed is never told the Atom one is
|
|
2469
|
+
unchanged. Feeds are validated even while `watch` is on, because a feed is not
|
|
2470
|
+
rendered through the theme.
|
|
702
2471
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
scopes each get their own, so a reader holding the Atom feed is never told the
|
|
707
|
-
JSON one is unchanged. Feeds are validated even while `watch` is on, because a
|
|
708
|
-
feed is not rendered through the theme.
|
|
2472
|
+
The validator also covers the revision of the item format itself, so a release
|
|
2473
|
+
that changes what a feed says about a post moves every post feed's `ETag` once
|
|
2474
|
+
rather than leaving a polling reader with a `304` that hides the new bytes.
|
|
709
2475
|
|
|
710
2476
|
```sh
|
|
711
|
-
curl -i https://example.com/feed
|
|
712
|
-
curl -i https://example.com/
|
|
2477
|
+
curl -i https://example.com/feed/
|
|
2478
|
+
curl -i https://example.com/tag/releases/feed/json/
|
|
713
2479
|
```
|
|
714
2480
|
|
|
715
|
-
Every page of the default theme advertises
|
|
716
|
-
|
|
717
|
-
|
|
2481
|
+
Every page of the default theme advertises all three feeds in its `<head>`, RSS
|
|
2482
|
+
first, then the site's comments feed; an archive advertises that archive's three
|
|
2483
|
+
as well, and a post its own comments feed (the renderer puts that URL in
|
|
2484
|
+
`commentsFeed`, so a layout that overrides `post.njk` keeps it). A theme that
|
|
2485
|
+
does not extend `layouts/base.njk` should emit them itself:
|
|
718
2486
|
|
|
719
2487
|
```html
|
|
2488
|
+
<link
|
|
2489
|
+
rel="alternate"
|
|
2490
|
+
type="application/rss+xml"
|
|
2491
|
+
title="My Site"
|
|
2492
|
+
href="/feed/"
|
|
2493
|
+
/>
|
|
720
2494
|
<link
|
|
721
2495
|
rel="alternate"
|
|
722
2496
|
type="application/atom+xml"
|
|
723
2497
|
title="My Site"
|
|
724
|
-
href="/feed
|
|
2498
|
+
href="/feed/atom/"
|
|
725
2499
|
/>
|
|
726
2500
|
<link
|
|
727
2501
|
rel="alternate"
|
|
728
2502
|
type="application/feed+json"
|
|
729
2503
|
title="My Site"
|
|
730
|
-
href="/feed
|
|
2504
|
+
href="/feed/json/"
|
|
2505
|
+
/>
|
|
2506
|
+
<link
|
|
2507
|
+
rel="alternate"
|
|
2508
|
+
type="application/rss+xml"
|
|
2509
|
+
title="My Site comments"
|
|
2510
|
+
href="/comments/feed/"
|
|
731
2511
|
/>
|
|
732
2512
|
```
|
|
733
2513
|
|
|
734
2514
|
The builders are exported, so a site can write a feed of its own — one
|
|
735
|
-
category, one author — without reimplementing
|
|
2515
|
+
category, one author — without reimplementing any of the three formats:
|
|
736
2516
|
|
|
737
2517
|
```ts
|
|
738
|
-
import { atomFeed, createRenderer, jsonFeed } from '@geekity/cms';
|
|
2518
|
+
import { atomFeed, createRenderer, jsonFeed, rssFeed } from '@geekity/cms';
|
|
739
2519
|
|
|
740
2520
|
const site = createRenderer({ config: cms.config }).site();
|
|
741
2521
|
|
|
@@ -743,31 +2523,156 @@ const source = {
|
|
|
743
2523
|
site,
|
|
744
2524
|
documents: cms.store.listByTag('releases', { limit: 20 }),
|
|
745
2525
|
title: `${site.title}: releases`,
|
|
746
|
-
href: '/
|
|
747
|
-
feedHref: '/
|
|
2526
|
+
href: '/tag/releases/',
|
|
2527
|
+
feedHref: '/tag/releases/feed/',
|
|
748
2528
|
baseUrl: cms.config.baseUrl,
|
|
749
2529
|
};
|
|
750
2530
|
|
|
2531
|
+
rssFeed(source); // a string
|
|
751
2532
|
atomFeed(source); // a string
|
|
752
2533
|
jsonFeed(source); // a JSON Feed object
|
|
753
2534
|
```
|
|
754
2535
|
|
|
2536
|
+
`commentsRssFeed` is the same for comments, and what goes in it comes from the
|
|
2537
|
+
conversation reader — the one thing that reads the inbox log and the comment
|
|
2538
|
+
index to show somebody what has been said, so a feed and the page under the
|
|
2539
|
+
post carry the same entries:
|
|
2540
|
+
|
|
2541
|
+
```ts
|
|
2542
|
+
import {
|
|
2543
|
+
commentsRssFeed,
|
|
2544
|
+
createConversation,
|
|
2545
|
+
feedComments,
|
|
2546
|
+
} from '@geekity/cms';
|
|
2547
|
+
|
|
2548
|
+
const conversation = createConversation({
|
|
2549
|
+
admin: cms.admin,
|
|
2550
|
+
store: cms.store,
|
|
2551
|
+
baseUrl: cms.config.baseUrl,
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
commentsRssFeed({
|
|
2555
|
+
site,
|
|
2556
|
+
comments: feedComments(conversation.latest(20), {
|
|
2557
|
+
baseUrl: cms.config.baseUrl,
|
|
2558
|
+
limit: 20,
|
|
2559
|
+
}),
|
|
2560
|
+
title: `${site.title}: comments`,
|
|
2561
|
+
href: '/',
|
|
2562
|
+
feedHref: '/comments/feed/',
|
|
2563
|
+
baseUrl: cms.config.baseUrl,
|
|
2564
|
+
});
|
|
2565
|
+
```
|
|
2566
|
+
|
|
2567
|
+
`conversation.thread(document)` is the same reading for one post — the shape the
|
|
2568
|
+
theme's `conversation.njk` is handed — `spokenIn` flattens it into the entries a
|
|
2569
|
+
feed carries, and `conversation.counts(documents)` is the number `source:comments`
|
|
2570
|
+
puts beside each item of a post feed.
|
|
2571
|
+
|
|
2572
|
+
## Sitemap and robots.txt
|
|
2573
|
+
|
|
2574
|
+
Two more fixed routes, at the two paths a crawler goes looking for on its own.
|
|
2575
|
+
|
|
2576
|
+
`/sitemap.xml` is every URL the public site publishes, in the
|
|
2577
|
+
[sitemap protocol's](https://www.sitemaps.org/protocol.html) `<urlset>`:
|
|
2578
|
+
|
|
2579
|
+
- the home archive and each of its pages, `/`, `/page/2/` and so on;
|
|
2580
|
+
- every published post and every page, at its permalink;
|
|
2581
|
+
- every tag archive and every category archive, with each of their pages,
|
|
2582
|
+
under whatever bases the site is configured with.
|
|
2583
|
+
|
|
2584
|
+
Each `<loc>` is absolute, built on `baseUrl`. `<lastmod>` is a document's
|
|
2585
|
+
`updated` when it has one and its `date` otherwise, and for a listing page it
|
|
2586
|
+
is the newest of those among the documents on that page. Something nothing
|
|
2587
|
+
dates — a page with no date in its front matter — carries no `<lastmod>`
|
|
2588
|
+
rather than an invented one.
|
|
2589
|
+
|
|
2590
|
+
Nothing hidden reaches it. The sitemap is drawn from the same queries the
|
|
2591
|
+
listings are and checked against the same `isPublicDocument`, so drafts, the
|
|
2592
|
+
trash and posts whose date has not arrived are absent for the same reason
|
|
2593
|
+
their permalinks 404.
|
|
2594
|
+
|
|
2595
|
+
The protocol caps one file at 50,000 URLs. Past that, `/sitemap.xml` becomes a
|
|
2596
|
+
`<sitemapindex>` naming `/sitemap-1.xml`, `/sitemap-2.xml` and so on, each
|
|
2597
|
+
holding its own slice and dated by the newest URL in it. The address a search
|
|
2598
|
+
engine holds does not change, which is the point of the index living there.
|
|
2599
|
+
While the whole sitemap fits in one file the children name nothing and 404.
|
|
2600
|
+
|
|
2601
|
+
`/robots.txt` is short:
|
|
2602
|
+
|
|
2603
|
+
```
|
|
2604
|
+
User-agent: *
|
|
2605
|
+
Disallow: /admin/
|
|
2606
|
+
|
|
2607
|
+
Sitemap: https://example.com/sitemap.xml
|
|
2608
|
+
```
|
|
2609
|
+
|
|
2610
|
+
Everything public is crawlable; only the admin is not. The actors and their
|
|
2611
|
+
collections are deliberately left open — an actor and an object exist to be
|
|
2612
|
+
fetched, they carry the same
|
|
2613
|
+
content as the pages that link to them, and a crawler that follows one gets
|
|
2614
|
+
JSON it will ignore.
|
|
2615
|
+
|
|
2616
|
+
Both are registered routes rather than anything resolved from the content
|
|
2617
|
+
index, so a document permalinked at `/sitemap.xml` cannot take the URL a
|
|
2618
|
+
search engine polls, and both carry an `ETag` and answer a conditional request
|
|
2619
|
+
with 304 the way the feeds do — the sitemap with a `Last-Modified` as well,
|
|
2620
|
+
robots without one, because nothing dates it.
|
|
2621
|
+
|
|
2622
|
+
Nothing links the sitemap from a page: `robots.txt` names it, which is where a
|
|
2623
|
+
crawler looks.
|
|
2624
|
+
|
|
755
2625
|
## Theme overrides
|
|
756
2626
|
|
|
757
|
-
A
|
|
758
|
-
|
|
2627
|
+
A site's themes live one directory per theme under `themesDir` (`themes/` by
|
|
2628
|
+
default), each with a `theme.json` naming it:
|
|
2629
|
+
|
|
2630
|
+
```json
|
|
2631
|
+
{
|
|
2632
|
+
"name": "Midnight",
|
|
2633
|
+
"kind": "site",
|
|
2634
|
+
"description": "Dark, quiet, mostly type."
|
|
2635
|
+
}
|
|
2636
|
+
```
|
|
2637
|
+
|
|
2638
|
+
`name` is what a person reads, `kind` is `site` — the only kind there is, and
|
|
2639
|
+
the field is in the file so another can be added later without the format
|
|
2640
|
+
changing — and `description` is optional. The directory name is the theme's id.
|
|
2641
|
+
|
|
2642
|
+
One setting in `content/_data/site.json`, `theme`, says which one the site is
|
|
2643
|
+
wearing, and that id is what it holds:
|
|
2644
|
+
|
|
2645
|
+
```json
|
|
2646
|
+
{ "theme": "midnight" }
|
|
2647
|
+
```
|
|
2648
|
+
|
|
2649
|
+
**Appearance > Themes** in the admin is where the choice is made. It lists the
|
|
2650
|
+
theme inside this package and every folder under `themesDir` with its name and
|
|
2651
|
+
description, marks the one in use, and Activate on another writes the setting —
|
|
2652
|
+
or, on the packaged theme, takes the key out of `site.json` again, which is why
|
|
2653
|
+
a site wearing it has no `theme` key rather than an empty one. A folder whose
|
|
2654
|
+
`theme.json` is missing, unparseable or names another kind is listed under
|
|
2655
|
+
"Not themes" with the reason, so a typo is something you can see on the screen
|
|
2656
|
+
that would otherwise have shown the theme. Activating takes effect on the next
|
|
2657
|
+
request, with no restart. Editing the setting by hand does exactly the same
|
|
2658
|
+
thing.
|
|
2659
|
+
|
|
2660
|
+
A template is then looked up in that theme first and in the theme that ships
|
|
2661
|
+
inside this package second, file by file. Sites override one template at a time
|
|
759
2662
|
and keep receiving updates to the rest. Files under `/theme/` resolve the same
|
|
760
|
-
way, so `
|
|
2663
|
+
way, so `themes/midnight/static/style.css` replaces the packaged stylesheet,
|
|
2664
|
+
and the [email messages](#messages) under `mail/` resolve the same way too.
|
|
761
2665
|
|
|
762
|
-
`
|
|
763
|
-
|
|
764
|
-
theme
|
|
765
|
-
|
|
2666
|
+
Neither the setting nor `themesDir` need exist. A site that names no theme
|
|
2667
|
+
serves every page, the 404 and the stylesheet out of the packaged theme, and an
|
|
2668
|
+
unchosen theme sitting in `themes/` changes nothing. A `theme` naming a
|
|
2669
|
+
directory that is not there, or one whose manifest will not read, falls back to
|
|
2670
|
+
the packaged theme with a warning in the log rather than a broken site.
|
|
766
2671
|
|
|
767
|
-
A
|
|
2672
|
+
A theme that ships only
|
|
768
2673
|
|
|
769
2674
|
```
|
|
770
|
-
|
|
2675
|
+
themes/midnight/layouts/post.njk
|
|
771
2676
|
```
|
|
772
2677
|
|
|
773
2678
|
replaces the post layout; the base layout, the archive, the tag pages and the
|
|
@@ -783,11 +2688,43 @@ can extend a packaged one by name:
|
|
|
783
2688
|
{% endblock %}
|
|
784
2689
|
```
|
|
785
2690
|
|
|
2691
|
+
The admin is not themed. Its templates and its static files live in a tree of
|
|
2692
|
+
their own with a loader of their own, deliberately off this search path, so no
|
|
2693
|
+
theme can shadow the login form or the CSRF field inside it.
|
|
2694
|
+
|
|
2695
|
+
The packaged theme is the andrewshell.org design (decision-16). `base.njk` is
|
|
2696
|
+
its shell: a skip link, a `.global-wrapper` carrying `data-is-root-path="true"`
|
|
2697
|
+
at `/` only, a header that is the site title and tagline on the front page and a
|
|
2698
|
+
small link home on every other — with `menus.primary` inside it on every page
|
|
2699
|
+
— `<main id="main">`, and a footer with the copyright year, the site author,
|
|
2700
|
+
the colophon and `menus.footer`. The footer reads nothing off an account: it
|
|
2701
|
+
used to print an RSS link and one `rel="me"` link per link on the site author's
|
|
2702
|
+
profile, and a site that wants either types it into its footer menu. Nothing
|
|
2703
|
+
particular to one site is in it — webrings and badges belong in a site theme's
|
|
2704
|
+
`footer` block. Its head
|
|
2705
|
+
carries a description, Open Graph and Twitter card tags, icons derived from the
|
|
2706
|
+
site's avatar, and one JSON-LD `@graph` from `partials/jsonld.njk`: that partial
|
|
2707
|
+
is all the structured data the theme emits, there is no Microdata anywhere, and
|
|
2708
|
+
a site that wants a different graph replaces the one file. The stylesheet
|
|
2709
|
+
is a serif body and sans headings at an 18px root, one column at 42rem, warm
|
|
2710
|
+
paper with a rust primary and a blue secondary, and a second scheme under
|
|
2711
|
+
`prefers-color-scheme: dark` that redefines the same `--color-*` tokens on dark
|
|
2712
|
+
paper. Both meet WCAG 2.2 AA, checked by a test that reads the custom properties
|
|
2713
|
+
out of the stylesheet and computes the ratios.
|
|
2714
|
+
|
|
786
2715
|
The context mirrors what an Eleventy layout receives — `title`, `date`, `tags`,
|
|
787
2716
|
`content`, `page.url`, and every front matter key the file carried — plus
|
|
788
2717
|
`site`, which is `content/_data/site.json`. It is part of the semver contract.
|
|
789
|
-
The
|
|
790
|
-
|
|
2718
|
+
The one key that is not the front matter's own string is `author`: it is the
|
|
2719
|
+
person the file names, resolved against the site's users, with `author.name` to
|
|
2720
|
+
print and `author.url` — their archive at `/author/{username}/` — to link to
|
|
2721
|
+
when the name is one of them. `siteAuthor` is the same object answering a
|
|
2722
|
+
different question — who the page in front of the reader is by, which is the
|
|
2723
|
+
entry's author on a document, the archive's person on an author archive and the
|
|
2724
|
+
profile behind the site's `author` setting everywhere else — so the bio, the
|
|
2725
|
+
`rel="me"` links and any structured data a theme emits all read one profile.
|
|
2726
|
+
The full table of context keys, blocks and
|
|
2727
|
+
filters is in [`themes/default/README.md`](./themes/default/README.md).
|
|
791
2728
|
|
|
792
2729
|
Rendering is also callable without a request:
|
|
793
2730
|
|
|
@@ -803,6 +2740,27 @@ renderer.site(); // content/_data/site.json, defaults filled in
|
|
|
803
2740
|
|
|
804
2741
|
Handlers reach the same renderer as `c.var.renderer`.
|
|
805
2742
|
|
|
2743
|
+
### Moving a `theme/` directory
|
|
2744
|
+
|
|
2745
|
+
Before named themes a site had one `theme/` directory, always on the search
|
|
2746
|
+
path, pointed at by `themeDir` and `GEEKITY_THEME_DIR`. Both are gone and
|
|
2747
|
+
neither is aliased, so a site carrying one moves it:
|
|
2748
|
+
|
|
2749
|
+
```sh
|
|
2750
|
+
mkdir -p themes/mine
|
|
2751
|
+
git mv theme/* themes/mine/
|
|
2752
|
+
rmdir theme
|
|
2753
|
+
```
|
|
2754
|
+
|
|
2755
|
+
Then write `themes/mine/theme.json` with a `name` and `"kind": "site"`, choose
|
|
2756
|
+
it on **Appearance > Themes** (or put `"theme": "mine"` in
|
|
2757
|
+
`content/_data/site.json`), and drop `themeDir` from `geekity.config.ts` and
|
|
2758
|
+
`GEEKITY_THEME_DIR` from the environment — a config naming `themeDir` is now a
|
|
2759
|
+
type error and the variable is ignored. Nothing inside the theme changes: the
|
|
2760
|
+
layouts, partials, `mail/` and `static/` keep their names and their meaning.
|
|
2761
|
+
Until it is chosen the site serves the packaged theme, so the move and the
|
|
2762
|
+
choice belong in the same deploy.
|
|
2763
|
+
|
|
806
2764
|
## Building the same content with Eleventy
|
|
807
2765
|
|
|
808
2766
|
The content directory is an [Eleventy](https://www.11ty.dev) 3 input directory.
|
|
@@ -820,14 +2778,58 @@ npx @11ty/eleventy
|
|
|
820
2778
|
```
|
|
821
2779
|
|
|
822
2780
|
It is a plain ESM config with no dependency on this package, and it writes out
|
|
823
|
-
the
|
|
2781
|
+
the rules the CMS follows that Eleventy does not know about on its own:
|
|
2782
|
+
|
|
2783
|
+
| Rule | How the config does it |
|
|
2784
|
+
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2785
|
+
| `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
|
|
2786
|
+
| A file with no `permalink` gets the CMS default | An `addPreprocessor` that fills in `/{yyyy}/{mm}/{slug}/` for a post and `/{slug}/` for a page, slugifying the title exactly as the CMS does. Front matter always wins; the CMS writes `permalink` into every file it saves, so this only matters for hand-authored files. |
|
|
2787
|
+
| `content/uploads/` is served at `/uploads/` | `addPassthroughCopy({ 'content/uploads': 'uploads' })`, plus an `ignores` entry for the same path. Without the ignore, an upload that happens to be Markdown would be copied _and_ rendered as a page; the CMS only ever indexes `posts/` and `pages/`. |
|
|
2788
|
+
| `content/_data/federation/` is data | `followers.json` is an ordinary data file in a namespaced `_data` subdirectory, so Eleventy hands it over as `federation.followers` without being told. The monthly inbox logs are JSON Lines, which Eleventy has no reader for, so the config registers one with `addDataExtension('jsonl', …)`: each month becomes an entry of `federation.inbox`, keyed by its `{yyyy}-{mm}` name. |
|
|
2789
|
+
| `content/_data/comments/` is the same thread | The conversation under a post is not only the inbox log: the approved comments in `content/_data/comments/{slug}.json` thread with it. The `conversation` filter takes the post's slug as a second argument and reads that file directly, rather than through the data cascade — a namespaced `_data/comments/` directory would arrive as a global called `comments`, and `comments: true` in a post's front matter would shadow it on exactly the pages that need it. The slug is on the context as `geekitySlug`. |
|
|
2790
|
+
| `content/_trash/` is not published | `ignores.add('content/_trash/**')`. Eleventy skips `_includes` and `_data` because they are configured directories, not because of the underscore, so the trash has to be named. |
|
|
2791
|
+
| Dates are shown in the site's `timezone` | A `date` filter with the CMS's four formats: `readable`, `html` and `year` are the calendar the site's zone was on at the instant, `iso` is the instant in UTC. The zone is read from `content/_data/site.json`, which the settings screen writes, so a zone changed in the CMS changes the built pages too. |
|
|
2792
|
+
| A future `date` holds a post back | An `addPreprocessor` that returns `false` for it. This is the one rule that cannot be exactly the same in both places: a build has no clock, only a moment. A scheduled post is left out of the build that runs before its date and is in the next build after it, so a scheduled site needs a build on a schedule; the CMS publishes it on the date by itself. `BUILD_SCHEDULED=1` builds them anyway. |
|
|
2793
|
+
|
|
2794
|
+
It also builds two collections Eleventy has no notion of. `collections.categories`
|
|
2795
|
+
is the second taxonomy, one entry of `{ name, posts }` per category in use, for
|
|
2796
|
+
paginating into archives at `/{{ site.categoryBase }}/{name}/`.
|
|
2797
|
+
`collections.menus` is the site's named menus — the `menus` object of
|
|
2798
|
+
`site.json`, and nothing else — each an ordered list of `{ label, url }`
|
|
2799
|
+
entries, plus `me: true` on a link that should carry `rel="me"`; a layout
|
|
2800
|
+
marks the current one itself by comparing `item.url` with `page.url`, because a
|
|
2801
|
+
collection is built once for the whole site.
|
|
2802
|
+
|
|
2803
|
+
The `date` filter is the one that needs a word. The files hold UTC instants and
|
|
2804
|
+
the `timezone` setting decides how they read, so the config's filter takes the
|
|
2805
|
+
zone from `content/_data/site.json` — the file the settings screen writes —
|
|
2806
|
+
and renders with `Intl`, which keeps the file free of dependencies. If your site
|
|
2807
|
+
already uses [Luxon](https://moment.github.io/luxon/), which Eleventy ships
|
|
2808
|
+
anyway, the same filter is shorter:
|
|
2809
|
+
|
|
2810
|
+
```js
|
|
2811
|
+
import { DateTime } from 'luxon';
|
|
2812
|
+
|
|
2813
|
+
const zone =
|
|
2814
|
+
JSON.parse(readFileSync('content/_data/site.json', 'utf8')).timezone ?? 'utc';
|
|
2815
|
+
|
|
2816
|
+
eleventyConfig.addFilter('date', (value, format = 'readable') => {
|
|
2817
|
+
const at = DateTime.fromJSDate(
|
|
2818
|
+
value instanceof Date ? value : new Date(value),
|
|
2819
|
+
).setZone(zone);
|
|
2820
|
+
if (!at.isValid) return '';
|
|
2821
|
+
|
|
2822
|
+
// `iso` is the instant, never the zone: a <time datetime> and a feed want
|
|
2823
|
+
// UTC and must not move when a setting does.
|
|
2824
|
+
if (format === 'iso') return at.toUTC().toISO();
|
|
2825
|
+
if (format === 'html') return at.toFormat('yyyy-MM-dd');
|
|
2826
|
+
if (format === 'year') return at.toFormat('yyyy');
|
|
2827
|
+
return at.toFormat('d LLLL yyyy');
|
|
2828
|
+
});
|
|
2829
|
+
```
|
|
824
2830
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
| `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
|
|
828
|
-
| A file with no `permalink` gets the CMS default | An `addPreprocessor` that fills in `/{yyyy}/{mm}/{slug}/` for a post and `/{slug}/` for a page, slugifying the title exactly as the CMS does. Front matter always wins; the CMS writes `permalink` into every file it saves, so this only matters for hand-authored files. |
|
|
829
|
-
| `content/uploads/` is served at `/uploads/` | `addPassthroughCopy({ 'content/uploads': 'uploads' })`, plus an `ignores` entry for the same path. Without the ignore, an upload that happens to be Markdown would be copied _and_ rendered as a page; the CMS only ever indexes `posts/` and `pages/`. |
|
|
830
|
-
| `content/_trash/` is not published | `ignores.add('content/_trash/**')`. Eleventy skips `_includes` and `_data` because they are configured directories, not because of the underscore, so the trash has to be named. |
|
|
2831
|
+
Either way it is the CMS's own filter, format for format, so a layout moved
|
|
2832
|
+
across prints the same dates.
|
|
831
2833
|
|
|
832
2834
|
It also turns the template engine off for Markdown
|
|
833
2835
|
(`markdownTemplateEngine: false`), because the CMS renders Markdown with
|
|
@@ -840,9 +2842,10 @@ Layouts are yours. The directory data files name them — `posts.json` says
|
|
|
840
2842
|
Eleventy layout receives is the one this package's theme mirrors, so a layout
|
|
841
2843
|
can often be moved across with only its `{% extends %}` removed.
|
|
842
2844
|
|
|
843
|
-
The feeds are the one thing that does not carry over: `/feed
|
|
844
|
-
`/feed
|
|
845
|
-
needs its own.
|
|
2845
|
+
The feeds are the one thing that does not carry over: `/feed/`, `/feed/atom/`
|
|
2846
|
+
and `/feed/json/` are generated in code here, not by a template, so an Eleventy
|
|
2847
|
+
build needs its own. `/sitemap.xml` and `/robots.txt` are the same story, and
|
|
2848
|
+
for the same reason. Everything else is the same directory.
|
|
846
2849
|
|
|
847
2850
|
### The compatibility test
|
|
848
2851
|
|