@geekity/cms 0.30.0 → 0.32.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 +26 -4266
- package/admin/components/badge.njk +4 -0
- package/admin/components/fields.njk +41 -24
- package/admin/components/job-status.njk +45 -0
- package/admin/components/setting-fields.njk +38 -0
- package/admin/layouts/settings-page.njk +16 -0
- package/admin/pages/comments/all.njk +1 -2
- package/admin/pages/documents/editor.njk +17 -15
- package/admin/pages/documents/syndication.njk +4 -4
- package/admin/pages/federation/followers.njk +56 -6
- package/admin/pages/federation/settings.njk +1 -1
- package/admin/pages/media/library.njk +52 -3
- package/admin/pages/navigation/menus.njk +2 -2
- package/admin/pages/plugins/screen.njk +5 -46
- package/admin/pages/settings/discussion.njk +76 -47
- package/admin/pages/settings/email.njk +75 -47
- package/admin/pages/settings/general.njk +15 -15
- package/admin/pages/settings/permalinks.njk +8 -8
- package/admin/pages/settings/privacy.njk +6 -6
- package/admin/pages/settings/reading.njk +36 -36
- package/admin/pages/tools/dev-mode.njk +29 -3
- package/admin/pages/tools/export.njk +89 -0
- package/admin/pages/tools/maintenance.njk +51 -0
- package/admin/pages/users/apps.njk +2 -2
- package/admin/pages/users/edit.njk +3 -3
- package/admin/pages/users/new.njk +2 -2
- package/admin/static/admin.css +80 -0
- package/dist/access-log.d.ts +2 -10
- package/dist/access-log.d.ts.map +1 -1
- package/dist/access-log.js +1 -1
- package/dist/access-log.js.map +1 -1
- package/dist/{web → admin}/admin-bar.d.ts +3 -2
- package/dist/admin/admin-bar.d.ts.map +1 -0
- package/dist/{web → admin}/admin-bar.js +20 -17
- package/dist/admin/admin-bar.js.map +1 -0
- package/dist/{indieauth → admin}/app-activity.d.ts +2 -2
- package/dist/admin/app-activity.d.ts.map +1 -0
- package/dist/{indieauth → admin}/app-activity.js +6 -7
- package/dist/admin/app-activity.js.map +1 -0
- package/dist/admin/appearance.d.ts +4 -8
- package/dist/admin/appearance.d.ts.map +1 -1
- package/dist/admin/appearance.js +19 -20
- package/dist/admin/appearance.js.map +1 -1
- package/dist/admin/assets.d.ts +1 -8
- package/dist/admin/assets.d.ts.map +1 -1
- package/dist/admin/assets.js +2 -9
- package/dist/admin/assets.js.map +1 -1
- package/dist/admin/comments.d.ts +2 -2
- package/dist/admin/comments.d.ts.map +1 -1
- package/dist/admin/comments.js +17 -19
- package/dist/admin/comments.js.map +1 -1
- package/dist/admin/confirm-password.d.ts +4 -0
- package/dist/admin/confirm-password.d.ts.map +1 -0
- package/dist/admin/confirm-password.js +11 -0
- package/dist/admin/confirm-password.js.map +1 -0
- package/dist/{indieauth → admin}/connected-apps.d.ts +2 -2
- package/dist/admin/connected-apps.d.ts.map +1 -0
- package/dist/{indieauth → admin}/connected-apps.js +33 -39
- package/dist/admin/connected-apps.js.map +1 -0
- package/dist/admin/credentials.js +1 -1
- package/dist/admin/credentials.js.map +1 -1
- package/dist/admin/dev-mode.d.ts +1 -1
- package/dist/admin/dev-mode.d.ts.map +1 -1
- package/dist/admin/dev-mode.js +35 -10
- package/dist/admin/dev-mode.js.map +1 -1
- package/dist/admin/documents.d.ts +16 -301
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +94 -1040
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/editor-actions.d.ts +3 -2
- package/dist/admin/editor-actions.d.ts.map +1 -1
- package/dist/admin/editor-actions.js +8 -9
- package/dist/admin/editor-actions.js.map +1 -1
- package/dist/admin/editor-fields.d.ts +45 -0
- package/dist/admin/editor-fields.d.ts.map +1 -0
- package/dist/admin/editor-fields.js +71 -0
- package/dist/admin/editor-fields.js.map +1 -0
- package/dist/admin/editor-layout.d.ts +2 -10
- package/dist/admin/editor-layout.d.ts.map +1 -1
- package/dist/admin/editor-layout.js +2 -2
- package/dist/admin/editor-layout.js.map +1 -1
- package/dist/admin/export.d.ts +10 -0
- package/dist/admin/export.d.ts.map +1 -0
- package/dist/admin/export.js +72 -0
- package/dist/admin/export.js.map +1 -0
- package/dist/admin/federation.d.ts +6 -17
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +49 -104
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/flash.d.ts +2 -2
- package/dist/admin/flash.d.ts.map +1 -1
- package/dist/admin/headers.d.ts +1 -1
- package/dist/admin/headers.d.ts.map +1 -1
- package/dist/admin/headers.js +1 -1
- package/dist/admin/headers.js.map +1 -1
- package/dist/admin/index.d.ts +36 -21
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +26 -14
- package/dist/admin/index.js.map +1 -1
- package/dist/{indieauth/consent.d.ts → admin/indieauth-consent.d.ts} +4 -4
- package/dist/admin/indieauth-consent.d.ts.map +1 -0
- package/dist/{indieauth/consent.js → admin/indieauth-consent.js} +20 -23
- package/dist/admin/indieauth-consent.js.map +1 -0
- package/dist/admin/job-status.d.ts +25 -0
- package/dist/admin/job-status.d.ts.map +1 -0
- package/dist/admin/job-status.js +53 -0
- package/dist/admin/job-status.js.map +1 -0
- package/dist/admin/legacy-migrations.d.ts +131 -0
- package/dist/admin/legacy-migrations.d.ts.map +1 -0
- package/dist/admin/legacy-migrations.js +236 -0
- package/dist/admin/legacy-migrations.js.map +1 -0
- package/dist/admin/maintenance.d.ts +9 -0
- package/dist/admin/maintenance.d.ts.map +1 -0
- package/dist/admin/maintenance.js +42 -0
- package/dist/admin/maintenance.js.map +1 -0
- package/dist/admin/media.d.ts +8 -109
- package/dist/admin/media.d.ts.map +1 -1
- package/dist/admin/media.js +35 -188
- package/dist/admin/media.js.map +1 -1
- package/dist/admin/menu.d.ts.map +1 -1
- package/dist/admin/menu.js +11 -7
- package/dist/admin/menu.js.map +1 -1
- package/dist/admin/messages.d.ts +1 -1
- package/dist/admin/messages.d.ts.map +1 -1
- package/dist/admin/messages.js +11 -14
- package/dist/admin/messages.js.map +1 -1
- package/dist/admin/navigation.d.ts +3 -3
- package/dist/admin/navigation.d.ts.map +1 -1
- package/dist/admin/navigation.js +34 -33
- package/dist/admin/navigation.js.map +1 -1
- package/dist/admin/personal-data.d.ts +1 -1
- package/dist/admin/personal-data.d.ts.map +1 -1
- package/dist/admin/personal-data.js +3 -6
- package/dist/admin/personal-data.js.map +1 -1
- package/dist/admin/plugins.d.ts +1 -1
- package/dist/admin/plugins.d.ts.map +1 -1
- package/dist/admin/plugins.js +28 -79
- package/dist/admin/plugins.js.map +1 -1
- package/dist/admin/preview.d.ts +2 -1
- package/dist/admin/preview.d.ts.map +1 -1
- package/dist/admin/preview.js +31 -36
- package/dist/admin/preview.js.map +1 -1
- package/dist/admin/recovery.d.ts +3 -3
- package/dist/admin/recovery.d.ts.map +1 -1
- package/dist/admin/recovery.js +12 -14
- package/dist/admin/recovery.js.map +1 -1
- package/dist/admin/routes.d.ts +1 -3
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +34 -29
- package/dist/admin/routes.js.map +1 -1
- package/dist/admin/setting-overrides.d.ts +4 -0
- package/dist/admin/setting-overrides.d.ts.map +1 -0
- package/dist/admin/setting-overrides.js +24 -0
- package/dist/admin/setting-overrides.js.map +1 -0
- package/dist/admin/settings-discussion.d.ts +6 -4
- package/dist/admin/settings-discussion.d.ts.map +1 -1
- package/dist/admin/settings-discussion.js +79 -46
- package/dist/admin/settings-discussion.js.map +1 -1
- package/dist/admin/settings-email.d.ts +12 -9
- package/dist/admin/settings-email.d.ts.map +1 -1
- package/dist/admin/settings-email.js +94 -80
- package/dist/admin/settings-email.js.map +1 -1
- package/dist/admin/settings-federation.d.ts +0 -7
- package/dist/admin/settings-federation.d.ts.map +1 -1
- package/dist/admin/settings-federation.js +0 -36
- package/dist/admin/settings-federation.js.map +1 -1
- package/dist/admin/settings-fields.d.ts +32 -0
- package/dist/admin/settings-fields.d.ts.map +1 -0
- package/dist/admin/settings-fields.js +368 -0
- package/dist/admin/settings-fields.js.map +1 -0
- package/dist/admin/settings-general.d.ts.map +1 -1
- package/dist/admin/settings-general.js +17 -23
- package/dist/admin/settings-general.js.map +1 -1
- package/dist/admin/settings-page.d.ts +5 -32
- package/dist/admin/settings-page.d.ts.map +1 -1
- package/dist/admin/settings-page.js +39 -55
- package/dist/admin/settings-page.js.map +1 -1
- package/dist/admin/settings-pages.d.ts +1 -1
- package/dist/admin/settings-pages.d.ts.map +1 -1
- package/dist/admin/settings-permalinks.js +3 -3
- package/dist/admin/settings-permalinks.js.map +1 -1
- package/dist/admin/settings-privacy.js +1 -1
- package/dist/admin/settings-privacy.js.map +1 -1
- package/dist/admin/settings-reading.d.ts +2 -2
- package/dist/admin/settings-reading.d.ts.map +1 -1
- package/dist/admin/settings-reading.js +17 -16
- package/dist/admin/settings-reading.js.map +1 -1
- package/dist/admin/settings-renderer.d.ts +32 -0
- package/dist/admin/settings-renderer.d.ts.map +1 -0
- package/dist/admin/settings-renderer.js +79 -0
- package/dist/admin/settings-renderer.js.map +1 -0
- package/dist/admin/settings.d.ts +0 -579
- package/dist/admin/settings.d.ts.map +1 -1
- package/dist/admin/settings.js +1 -1155
- package/dist/admin/settings.js.map +1 -1
- package/dist/admin/site-setting-edit.d.ts +4 -0
- package/dist/admin/site-setting-edit.d.ts.map +1 -0
- package/dist/admin/site-setting-edit.js +20 -0
- package/dist/admin/site-setting-edit.js.map +1 -0
- package/dist/admin/syndication-targets.d.ts +1 -1
- package/dist/admin/syndication-targets.d.ts.map +1 -1
- package/dist/admin/syndication-targets.js +11 -15
- package/dist/admin/syndication-targets.js.map +1 -1
- package/dist/admin/taxonomy.d.ts +2 -2
- package/dist/admin/taxonomy.d.ts.map +1 -1
- package/dist/admin/taxonomy.js +17 -29
- package/dist/admin/taxonomy.js.map +1 -1
- package/dist/admin/templates.d.ts +2 -0
- package/dist/admin/templates.d.ts.map +1 -1
- package/dist/admin/templates.js +3 -1
- package/dist/admin/templates.js.map +1 -1
- package/dist/admin/tools.d.ts +4 -4
- package/dist/admin/tools.d.ts.map +1 -1
- package/dist/admin/tools.js +3 -6
- package/dist/admin/tools.js.map +1 -1
- package/dist/admin/uploads.d.ts +1 -106
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +2 -191
- package/dist/admin/uploads.js.map +1 -1
- package/dist/admin/users.d.ts +20 -15
- package/dist/admin/users.d.ts.map +1 -1
- package/dist/admin/users.js +84 -81
- package/dist/admin/users.js.map +1 -1
- package/dist/avatars/avatars.d.ts +2 -4
- package/dist/avatars/avatars.d.ts.map +1 -1
- package/dist/avatars/avatars.js +15 -19
- package/dist/avatars/avatars.js.map +1 -1
- package/dist/avatars/index.d.ts +1 -2
- package/dist/avatars/index.d.ts.map +1 -1
- package/dist/avatars/index.js +1 -2
- package/dist/avatars/index.js.map +1 -1
- package/dist/cache.d.ts +4 -4
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +13 -9
- package/dist/cache.js.map +1 -1
- package/dist/cli.d.ts +2 -33
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +729 -344
- package/dist/cli.js.map +1 -1
- package/dist/cms/app.d.ts +11 -0
- package/dist/cms/app.d.ts.map +1 -0
- package/dist/cms/app.js +126 -0
- package/dist/cms/app.js.map +1 -0
- package/dist/cms/boot.d.ts +26 -0
- package/dist/cms/boot.d.ts.map +1 -0
- package/dist/cms/boot.js +128 -0
- package/dist/cms/boot.js.map +1 -0
- package/dist/cms/create.d.ts +272 -0
- package/dist/cms/create.d.ts.map +1 -0
- package/dist/cms/create.js +181 -0
- package/dist/cms/create.js.map +1 -0
- package/dist/cms/lifecycle.d.ts +11 -0
- package/dist/cms/lifecycle.d.ts.map +1 -0
- package/dist/cms/lifecycle.js +15 -0
- package/dist/cms/lifecycle.js.map +1 -0
- package/dist/{web/errors.d.ts → cms/server-error.d.ts} +2 -8
- package/dist/cms/server-error.d.ts.map +1 -0
- package/dist/{web/errors.js → cms/server-error.js} +4 -14
- package/dist/cms/server-error.js.map +1 -0
- package/dist/cms/services.d.ts +57 -0
- package/dist/cms/services.d.ts.map +1 -0
- package/dist/cms/services.js +410 -0
- package/dist/cms/services.js.map +1 -0
- package/dist/cms/site-records.d.ts +4 -0
- package/dist/cms/site-records.d.ts.map +1 -0
- package/dist/cms/site-records.js +13 -0
- package/dist/cms/site-records.js.map +1 -0
- package/dist/comments/akismet.d.ts +14 -47
- package/dist/comments/akismet.d.ts.map +1 -1
- package/dist/comments/akismet.js +30 -71
- package/dist/comments/akismet.js.map +1 -1
- package/dist/comments/conversation.d.ts +340 -0
- package/dist/comments/conversation.d.ts.map +1 -0
- package/dist/comments/conversation.js +51 -0
- package/dist/comments/conversation.js.map +1 -0
- package/dist/comments/form.d.ts +2 -2
- package/dist/comments/form.d.ts.map +1 -1
- package/dist/comments/form.js +2 -2
- package/dist/comments/form.js.map +1 -1
- package/dist/comments/index.d.ts +15 -9
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +9 -6
- package/dist/comments/index.js.map +1 -1
- package/dist/comments/intake.d.ts +147 -0
- package/dist/comments/intake.d.ts.map +1 -0
- package/dist/comments/intake.js +161 -0
- package/dist/comments/intake.js.map +1 -0
- package/dist/comments/markdown.d.ts.map +1 -1
- package/dist/comments/markdown.js +2 -9
- package/dist/comments/markdown.js.map +1 -1
- package/dist/comments/migrations.d.ts +45 -0
- package/dist/comments/migrations.d.ts.map +1 -0
- package/dist/comments/migrations.js +194 -0
- package/dist/comments/migrations.js.map +1 -0
- package/dist/comments/moderate.d.ts +2 -2
- package/dist/comments/moderate.d.ts.map +1 -1
- package/dist/comments/moderate.js +3 -13
- package/dist/comments/moderate.js.map +1 -1
- package/dist/comments/page-form.d.ts +52 -0
- package/dist/comments/page-form.d.ts.map +1 -0
- package/dist/comments/page-form.js +62 -0
- package/dist/comments/page-form.js.map +1 -0
- package/dist/comments/policy.d.ts +24 -35
- package/dist/comments/policy.d.ts.map +1 -1
- package/dist/comments/policy.js +16 -38
- package/dist/comments/policy.js.map +1 -1
- package/dist/comments/records.d.ts +17 -195
- package/dist/comments/records.d.ts.map +1 -1
- package/dist/comments/records.js +24 -394
- package/dist/comments/records.js.map +1 -1
- package/dist/comments/reply-notices.d.ts +10 -11
- package/dist/comments/reply-notices.d.ts.map +1 -1
- package/dist/comments/reply-notices.js +32 -20
- package/dist/comments/reply-notices.js.map +1 -1
- package/dist/comments/reply-post.d.ts +2 -2
- package/dist/comments/reply-post.d.ts.map +1 -1
- package/dist/comments/reply-post.js +8 -7
- package/dist/comments/reply-post.js.map +1 -1
- package/dist/comments/routes.d.ts +3 -3
- package/dist/comments/routes.d.ts.map +1 -1
- package/dist/comments/routes.js +19 -31
- package/dist/comments/routes.js.map +1 -1
- package/dist/comments/submission.d.ts +5 -102
- package/dist/comments/submission.d.ts.map +1 -1
- package/dist/comments/submission.js +5 -22
- package/dist/comments/submission.js.map +1 -1
- package/dist/comments/viewer.d.ts +1 -25
- package/dist/comments/viewer.d.ts.map +1 -1
- package/dist/comments/viewer.js +1 -44
- package/dist/comments/viewer.js.map +1 -1
- package/dist/config-loader.d.ts +35 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config-loader.js +117 -0
- package/dist/config-loader.js.map +1 -0
- package/dist/config.d.ts +99 -51
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +137 -181
- package/dist/config.js.map +1 -1
- package/dist/contact/delivery.d.ts +3 -5
- package/dist/contact/delivery.d.ts.map +1 -1
- package/dist/contact/delivery.js +5 -14
- package/dist/contact/delivery.js.map +1 -1
- package/dist/contact/form.d.ts +2 -4
- package/dist/contact/form.d.ts.map +1 -1
- package/dist/contact/form.js +3 -4
- package/dist/contact/form.js.map +1 -1
- package/dist/contact/index.d.ts +3 -3
- package/dist/contact/index.d.ts.map +1 -1
- package/dist/contact/index.js +3 -3
- package/dist/contact/index.js.map +1 -1
- package/dist/contact/records.d.ts +0 -2
- package/dist/contact/records.d.ts.map +1 -1
- package/dist/contact/records.js +11 -16
- package/dist/contact/records.js.map +1 -1
- package/dist/contact/submission.d.ts +2 -2
- package/dist/contact/submission.d.ts.map +1 -1
- package/dist/contact/submission.js +7 -16
- package/dist/contact/submission.js.map +1 -1
- package/dist/{images → content}/alt-text.d.ts +1 -1
- package/dist/content/alt-text.d.ts.map +1 -0
- package/dist/{images → content}/alt-text.js +3 -5
- package/dist/content/alt-text.js.map +1 -0
- package/dist/content/authors.d.ts +79 -0
- package/dist/content/authors.d.ts.map +1 -0
- package/dist/content/authors.js +106 -0
- package/dist/content/authors.js.map +1 -0
- package/dist/content/citation.d.ts +4 -0
- package/dist/content/citation.d.ts.map +1 -1
- package/dist/content/citation.js +3 -0
- package/dist/content/citation.js.map +1 -1
- package/dist/content/comments.d.ts +3 -0
- package/dist/content/comments.d.ts.map +1 -0
- package/dist/content/comments.js +3 -0
- package/dist/content/comments.js.map +1 -0
- package/dist/content/contact.d.ts +3 -0
- package/dist/content/contact.d.ts.map +1 -0
- package/dist/content/contact.js +3 -0
- package/dist/content/contact.js.map +1 -0
- package/dist/content/document-form.d.ts +197 -0
- package/dist/content/document-form.d.ts.map +1 -0
- package/dist/content/document-form.js +201 -0
- package/dist/content/document-form.js.map +1 -0
- package/dist/content/document-kind.d.ts +55 -0
- package/dist/content/document-kind.d.ts.map +1 -0
- package/dist/content/document-kind.js +30 -0
- package/dist/content/document-kind.js.map +1 -0
- package/dist/{admin/enclosure-field.d.ts → content/enclosure-form.d.ts} +14 -34
- package/dist/content/enclosure-form.d.ts.map +1 -0
- package/dist/{admin/enclosure-field.js → content/enclosure-form.js} +43 -81
- package/dist/content/enclosure-form.js.map +1 -0
- package/dist/content/enclosure.d.ts.map +1 -1
- package/dist/content/enclosure.js +3 -4
- package/dist/content/enclosure.js.map +1 -1
- package/dist/{admin/event-field.d.ts → content/event-form.d.ts} +6 -5
- package/dist/content/event-form.d.ts.map +1 -0
- package/dist/{admin/event-field.js → content/event-form.js} +45 -12
- package/dist/content/event-form.js.map +1 -0
- package/dist/content/excerpt.d.ts +26 -0
- package/dist/content/excerpt.d.ts.map +1 -0
- package/dist/content/excerpt.js +46 -0
- package/dist/content/excerpt.js.map +1 -0
- package/dist/content/form-error.d.ts +10 -0
- package/dist/content/form-error.d.ts.map +1 -0
- package/dist/content/form-error.js +2 -0
- package/dist/content/form-error.js.map +1 -0
- package/dist/{web → content}/guids.d.ts +12 -5
- package/dist/content/guids.d.ts.map +1 -0
- package/dist/{web → content}/guids.js +28 -5
- package/dist/content/guids.js.map +1 -0
- package/dist/content/image-tags.d.ts +25 -0
- package/dist/content/image-tags.d.ts.map +1 -0
- package/dist/content/image-tags.js +55 -0
- package/dist/content/image-tags.js.map +1 -0
- package/dist/content/index.d.ts +11 -6
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +6 -3
- package/dist/content/index.js.map +1 -1
- package/dist/content/kept-properties.d.ts +0 -1
- package/dist/content/kept-properties.d.ts.map +1 -1
- package/dist/content/kept-properties.js +2 -2
- package/dist/content/kept-properties.js.map +1 -1
- package/dist/content/language.d.ts +19 -0
- package/dist/content/language.d.ts.map +1 -0
- package/dist/content/language.js +19 -0
- package/dist/content/language.js.map +1 -0
- package/dist/content/license.d.ts.map +1 -0
- package/dist/content/license.js.map +1 -0
- package/dist/{web → content}/locale.d.ts +0 -6
- package/dist/content/locale.d.ts.map +1 -0
- package/dist/{web → content}/locale.js +1 -6
- package/dist/content/locale.js.map +1 -0
- package/dist/content/location-form.d.ts +17 -0
- package/dist/content/location-form.d.ts.map +1 -0
- package/dist/{admin/location-field.js → content/location-form.js} +2 -23
- package/dist/content/location-form.js.map +1 -0
- package/dist/content/location.d.ts.map +1 -1
- package/dist/content/location.js +1 -3
- package/dist/content/location.js.map +1 -1
- package/dist/content/locations.d.ts +0 -1
- package/dist/content/locations.d.ts.map +1 -1
- package/dist/content/locations.js +2 -2
- package/dist/content/locations.js.map +1 -1
- package/dist/content/markdown-links.d.ts +11 -0
- package/dist/content/markdown-links.d.ts.map +1 -0
- package/dist/content/markdown-links.js +223 -0
- package/dist/content/markdown-links.js.map +1 -0
- package/dist/content/markdown.d.ts +2 -1
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +1 -1
- package/dist/content/markdown.js.map +1 -1
- package/dist/content/media.d.ts +7 -0
- package/dist/content/media.d.ts.map +1 -1
- package/dist/content/media.js +12 -1
- package/dist/content/media.js.map +1 -1
- package/dist/content/metadata/strip-job.d.ts +16 -0
- package/dist/content/metadata/strip-job.d.ts.map +1 -0
- package/dist/content/metadata/strip-job.js +24 -0
- package/dist/content/metadata/strip-job.js.map +1 -0
- package/dist/content/metadata/sweep.d.ts +13 -1
- package/dist/content/metadata/sweep.d.ts.map +1 -1
- package/dist/content/metadata/sweep.js +47 -25
- package/dist/content/metadata/sweep.js.map +1 -1
- package/dist/content/migrated.js +1 -1
- package/dist/content/migrated.js.map +1 -1
- package/dist/content/parser.d.ts.map +1 -1
- package/dist/content/parser.js +1 -12
- package/dist/content/parser.js.map +1 -1
- package/dist/content/permalink-file.d.ts +2 -1
- package/dist/content/permalink-file.d.ts.map +1 -1
- package/dist/content/permalink-file.js +3 -3
- package/dist/content/permalink-file.js.map +1 -1
- package/dist/content/permalinks.d.ts +34 -0
- package/dist/content/permalinks.d.ts.map +1 -0
- package/dist/{web/documents.js → content/permalinks.js} +6 -56
- package/dist/content/permalinks.js.map +1 -0
- package/dist/content/photo-form.d.ts +29 -0
- package/dist/content/photo-form.d.ts.map +1 -0
- package/dist/{admin/photo-field.js → content/photo-form.js} +43 -40
- package/dist/content/photo-form.js.map +1 -0
- package/dist/content/photo.d.ts +2 -1
- package/dist/content/photo.d.ts.map +1 -1
- package/dist/content/photo.js +2 -2
- package/dist/content/photo.js.map +1 -1
- package/dist/content/post-fields.d.ts +40 -0
- package/dist/content/post-fields.d.ts.map +1 -0
- package/dist/content/post-fields.js +252 -0
- package/dist/content/post-fields.js.map +1 -0
- package/dist/content/read-form.d.ts +31 -0
- package/dist/content/read-form.d.ts.map +1 -0
- package/dist/content/read-form.js +157 -0
- package/dist/content/read-form.js.map +1 -0
- package/dist/content/read.d.ts +9 -2
- package/dist/content/read.d.ts.map +1 -1
- package/dist/content/read.js +28 -13
- package/dist/content/read.js.map +1 -1
- package/dist/content/redirects.d.ts +84 -0
- package/dist/content/redirects.d.ts.map +1 -0
- package/dist/content/redirects.js +234 -0
- package/dist/content/redirects.js.map +1 -0
- package/dist/content/sanitize.d.ts +13 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/{web → content}/sanitize.js +4 -71
- package/dist/content/sanitize.js.map +1 -0
- package/dist/content/save.d.ts +5 -8
- package/dist/content/save.d.ts.map +1 -1
- package/dist/content/save.js +5 -26
- package/dist/content/save.js.map +1 -1
- package/dist/content/schedule.d.ts +2 -2
- package/dist/content/schedule.d.ts.map +1 -1
- package/dist/content/schedule.js +1 -3
- package/dist/content/schedule.js.map +1 -1
- package/dist/content/served.d.ts +58 -0
- package/dist/content/served.d.ts.map +1 -0
- package/dist/content/served.js +65 -0
- package/dist/content/served.js.map +1 -0
- package/dist/content/site-links.d.ts +72 -0
- package/dist/content/site-links.d.ts.map +1 -0
- package/dist/content/site-links.js +191 -0
- package/dist/content/site-links.js.map +1 -0
- package/dist/content/sqlite/archive.d.ts +4 -0
- package/dist/content/sqlite/archive.d.ts.map +1 -0
- package/dist/content/sqlite/archive.js +96 -0
- package/dist/content/sqlite/archive.js.map +1 -0
- package/dist/content/sqlite/backlinks.d.ts +4 -0
- package/dist/content/sqlite/backlinks.d.ts.map +1 -0
- package/dist/content/sqlite/backlinks.js +46 -0
- package/dist/content/sqlite/backlinks.js.map +1 -0
- package/dist/content/sqlite/catalog.d.ts +4 -0
- package/dist/content/sqlite/catalog.d.ts.map +1 -0
- package/dist/content/sqlite/catalog.js +62 -0
- package/dist/content/sqlite/catalog.js.map +1 -0
- package/dist/content/sqlite/clauses.d.ts +57 -0
- package/dist/content/sqlite/clauses.d.ts.map +1 -0
- package/dist/content/sqlite/clauses.js +76 -0
- package/dist/content/sqlite/clauses.js.map +1 -0
- package/dist/content/sqlite/database.d.ts +26 -0
- package/dist/content/sqlite/database.d.ts.map +1 -0
- package/dist/content/sqlite/database.js +27 -0
- package/dist/content/sqlite/database.js.map +1 -0
- package/dist/content/sqlite/documents.d.ts +12 -0
- package/dist/content/sqlite/documents.d.ts.map +1 -0
- package/dist/content/sqlite/documents.js +40 -0
- package/dist/content/sqlite/documents.js.map +1 -0
- package/dist/content/sqlite/lookups.d.ts +6 -0
- package/dist/content/sqlite/lookups.d.ts.map +1 -0
- package/dist/content/sqlite/lookups.js +25 -0
- package/dist/content/sqlite/lookups.js.map +1 -0
- package/dist/content/sqlite/open.d.ts +8 -0
- package/dist/content/sqlite/open.d.ts.map +1 -0
- package/dist/content/sqlite/open.js +63 -0
- package/dist/content/sqlite/open.js.map +1 -0
- package/dist/content/sqlite/replies.d.ts +4 -0
- package/dist/content/sqlite/replies.d.ts.map +1 -0
- package/dist/content/sqlite/replies.js +21 -0
- package/dist/content/sqlite/replies.js.map +1 -0
- package/dist/content/sqlite/rows.d.ts +22 -0
- package/dist/content/sqlite/rows.d.ts.map +1 -0
- package/dist/content/sqlite/rows.js +93 -0
- package/dist/content/sqlite/rows.js.map +1 -0
- package/dist/content/sqlite/schedule.d.ts +4 -0
- package/dist/content/sqlite/schedule.d.ts.map +1 -0
- package/dist/content/sqlite/schedule.js +16 -0
- package/dist/content/sqlite/schedule.js.map +1 -0
- package/dist/content/sqlite/schema.d.ts +10 -0
- package/dist/content/sqlite/schema.d.ts.map +1 -0
- package/dist/content/sqlite/schema.js +209 -0
- package/dist/content/sqlite/schema.js.map +1 -0
- package/dist/content/sqlite/search.d.ts +4 -0
- package/dist/content/sqlite/search.d.ts.map +1 -0
- package/dist/content/sqlite/search.js +54 -0
- package/dist/content/sqlite/search.js.map +1 -0
- package/dist/content/sqlite/spellings.d.ts +6 -0
- package/dist/content/sqlite/spellings.d.ts.map +1 -0
- package/dist/content/sqlite/spellings.js +80 -0
- package/dist/content/sqlite/spellings.js.map +1 -0
- package/dist/content/sqlite/taxonomy.d.ts +4 -0
- package/dist/content/sqlite/taxonomy.d.ts.map +1 -0
- package/dist/content/sqlite/taxonomy.js +75 -0
- package/dist/content/sqlite/taxonomy.js.map +1 -0
- package/dist/content/sqlite/writes.d.ts +9 -0
- package/dist/content/sqlite/writes.d.ts.map +1 -0
- package/dist/content/sqlite/writes.js +164 -0
- package/dist/content/sqlite/writes.js.map +1 -0
- package/dist/content/store.d.ts +142 -138
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +0 -1051
- package/dist/content/store.js.map +1 -1
- package/dist/content/sync.d.ts +12 -4
- package/dist/content/sync.d.ts.map +1 -1
- package/dist/content/sync.js +24 -41
- package/dist/content/sync.js.map +1 -1
- package/dist/content/syndicate-to.d.ts +25 -0
- package/dist/content/syndicate-to.d.ts.map +1 -0
- package/dist/content/syndicate-to.js +8 -0
- package/dist/content/syndicate-to.js.map +1 -0
- package/dist/{web → content}/taxonomy.d.ts +1 -1
- package/dist/content/taxonomy.d.ts.map +1 -0
- package/dist/{web → content}/taxonomy.js +2 -2
- package/dist/content/taxonomy.js.map +1 -0
- package/dist/content/time.d.ts +2 -2
- package/dist/content/time.d.ts.map +1 -1
- package/dist/content/time.js +3 -3
- package/dist/content/time.js.map +1 -1
- package/dist/content/trash.d.ts +35 -0
- package/dist/content/trash.d.ts.map +1 -0
- package/dist/content/trash.js +83 -0
- package/dist/content/trash.js.map +1 -0
- package/dist/content/uploads.d.ts +24 -0
- package/dist/content/uploads.d.ts.map +1 -0
- package/dist/content/uploads.js +32 -0
- package/dist/content/uploads.js.map +1 -0
- package/dist/content/video.d.ts.map +1 -1
- package/dist/content/video.js +3 -5
- package/dist/content/video.js.map +1 -1
- package/dist/content/write.d.ts +84 -0
- package/dist/content/write.d.ts.map +1 -0
- package/dist/content/write.js +570 -0
- package/dist/content/write.js.map +1 -0
- package/dist/content/writer.d.ts.map +1 -1
- package/dist/content/writer.js +1 -2
- package/dist/content/writer.js.map +1 -1
- package/dist/dev-mode.d.ts +6 -26
- package/dist/dev-mode.d.ts.map +1 -1
- package/dist/dev-mode.js +5 -54
- package/dist/dev-mode.js.map +1 -1
- package/dist/drain.d.ts +2 -3
- package/dist/drain.d.ts.map +1 -1
- package/dist/drain.js +2 -1
- package/dist/drain.js.map +1 -1
- package/dist/export/export-archives.d.ts +29 -0
- package/dist/export/export-archives.d.ts.map +1 -0
- package/dist/export/export-archives.js +88 -0
- package/dist/export/export-archives.js.map +1 -0
- package/dist/export/export-job.d.ts +21 -0
- package/dist/export/export-job.d.ts.map +1 -0
- package/dist/export/export-job.js +63 -0
- package/dist/export/export-job.js.map +1 -0
- package/dist/export/site-export.d.ts +101 -0
- package/dist/export/site-export.d.ts.map +1 -0
- package/dist/export/site-export.js +243 -0
- package/dist/export/site-export.js.map +1 -0
- package/dist/export/site-import.d.ts +75 -0
- package/dist/export/site-import.d.ts.map +1 -0
- package/dist/export/site-import.js +492 -0
- package/dist/export/site-import.js.map +1 -0
- package/dist/exports.d.ts +31 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/exports.js +10 -0
- package/dist/exports.js.map +1 -0
- package/dist/federation/actor.d.ts +1 -1
- package/dist/federation/actor.d.ts.map +1 -1
- package/dist/federation/actor.js +6 -13
- package/dist/federation/actor.js.map +1 -1
- package/dist/federation/article.d.ts +4 -14
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +40 -51
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/backfill.d.ts +7 -5
- package/dist/federation/backfill.d.ts.map +1 -1
- package/dist/federation/backfill.js +12 -13
- package/dist/federation/backfill.js.map +1 -1
- package/dist/federation/citations.d.ts.map +1 -1
- package/dist/federation/citations.js.map +1 -1
- package/dist/federation/cited-post.d.ts +1 -1
- package/dist/federation/cited-post.d.ts.map +1 -1
- package/dist/federation/cited-post.js +3 -5
- package/dist/federation/cited-post.js.map +1 -1
- package/dist/federation/delivery-plan.d.ts +85 -0
- package/dist/federation/delivery-plan.d.ts.map +1 -0
- package/dist/federation/delivery-plan.js +137 -0
- package/dist/federation/delivery-plan.js.map +1 -0
- package/dist/federation/delivery.d.ts +12 -34
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +79 -212
- package/dist/federation/delivery.js.map +1 -1
- package/dist/federation/federation.d.ts +12 -12
- package/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js +12 -18
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/followers.d.ts +1 -1
- package/dist/federation/followers.d.ts.map +1 -1
- package/dist/federation/handles.d.ts +1 -1
- package/dist/federation/handles.d.ts.map +1 -1
- package/dist/federation/handles.js +1 -1
- package/dist/federation/handles.js.map +1 -1
- package/dist/federation/inbox.d.ts +1 -1
- package/dist/federation/inbox.d.ts.map +1 -1
- package/dist/federation/inbox.js +6 -5
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/index.d.ts +10 -8
- package/dist/federation/index.d.ts.map +1 -1
- package/dist/federation/index.js +7 -5
- package/dist/federation/index.js.map +1 -1
- package/dist/federation/keys.d.ts +8 -27
- package/dist/federation/keys.d.ts.map +1 -1
- package/dist/federation/keys.js +7 -50
- package/dist/federation/keys.js.map +1 -1
- package/dist/federation/mount.d.ts +2 -2
- package/dist/federation/mount.d.ts.map +1 -1
- package/dist/federation/mount.js +9 -7
- package/dist/federation/mount.js.map +1 -1
- package/dist/federation/paths.d.ts +1 -9
- package/dist/federation/paths.d.ts.map +1 -1
- package/dist/federation/paths.js +2 -12
- package/dist/federation/paths.js.map +1 -1
- package/dist/federation/profiles.d.ts +2 -2
- package/dist/federation/profiles.d.ts.map +1 -1
- package/dist/federation/profiles.js +6 -9
- package/dist/federation/profiles.js.map +1 -1
- package/dist/federation/queue.d.ts +32 -17
- package/dist/federation/queue.d.ts.map +1 -1
- package/dist/federation/queue.js +173 -39
- package/dist/federation/queue.js.map +1 -1
- package/dist/federation/quotes.d.ts +2 -2
- package/dist/federation/quotes.d.ts.map +1 -1
- package/dist/federation/quotes.js +4 -4
- package/dist/federation/quotes.js.map +1 -1
- package/dist/federation/records.d.ts +2 -2
- package/dist/federation/records.d.ts.map +1 -1
- package/dist/federation/records.js +12 -19
- package/dist/federation/records.js.map +1 -1
- package/dist/federation/relays.d.ts +8 -5
- package/dist/federation/relays.d.ts.map +1 -1
- package/dist/federation/relays.js +52 -53
- package/dist/federation/relays.js.map +1 -1
- package/dist/federation/resend-job.d.ts +44 -0
- package/dist/federation/resend-job.d.ts.map +1 -0
- package/dist/federation/resend-job.js +59 -0
- package/dist/federation/resend-job.js.map +1 -0
- package/dist/federation/site-loaders.js +1 -1
- package/dist/federation/site-loaders.js.map +1 -1
- package/dist/federation/webfinger.d.ts +23 -0
- package/dist/federation/webfinger.d.ts.map +1 -0
- package/dist/federation/webfinger.js +70 -0
- package/dist/federation/webfinger.js.map +1 -0
- package/dist/files/atomic.d.ts +9 -0
- package/dist/files/atomic.d.ts.map +1 -1
- package/dist/files/atomic.js +5 -10
- package/dist/files/atomic.js.map +1 -1
- package/dist/files/data-files.d.ts +104 -0
- package/dist/files/data-files.d.ts.map +1 -0
- package/dist/files/data-files.js +330 -0
- package/dist/files/data-files.js.map +1 -0
- package/dist/files/export-archives.d.ts +12 -0
- package/dist/files/export-archives.d.ts.map +1 -0
- package/dist/files/export-archives.js +67 -0
- package/dist/files/export-archives.js.map +1 -0
- package/dist/files/static-files.d.ts +21 -0
- package/dist/files/static-files.d.ts.map +1 -0
- package/dist/files/static-files.js +73 -0
- package/dist/files/static-files.js.map +1 -0
- package/dist/files/tar.d.ts +29 -0
- package/dist/files/tar.d.ts.map +1 -0
- package/dist/files/tar.js +206 -0
- package/dist/files/tar.js.map +1 -0
- package/dist/files/upload-types.d.ts +81 -0
- package/dist/files/upload-types.d.ts.map +1 -0
- package/dist/files/upload-types.js +234 -0
- package/dist/files/upload-types.js.map +1 -0
- package/dist/forms/checker.d.ts +93 -0
- package/dist/forms/checker.d.ts.map +1 -0
- package/dist/forms/checker.js +2 -0
- package/dist/forms/checker.js.map +1 -0
- package/dist/forms/protection.d.ts +3 -9
- package/dist/forms/protection.d.ts.map +1 -1
- package/dist/forms/protection.js +5 -11
- package/dist/forms/protection.js.map +1 -1
- package/dist/images/icons.d.ts.map +1 -1
- package/dist/images/icons.js +4 -2
- package/dist/images/icons.js.map +1 -1
- package/dist/images/index.d.ts +2 -1
- package/dist/images/index.d.ts.map +1 -1
- package/dist/images/index.js +2 -1
- package/dist/images/index.js.map +1 -1
- package/dist/images/markup.d.ts +4 -21
- package/dist/images/markup.d.ts.map +1 -1
- package/dist/images/markup.js +15 -70
- package/dist/images/markup.js.map +1 -1
- package/dist/images/paths.d.ts +1 -14
- package/dist/images/paths.d.ts.map +1 -1
- package/dist/images/paths.js +9 -23
- package/dist/images/paths.js.map +1 -1
- package/dist/images/variants.d.ts +5 -3
- package/dist/images/variants.d.ts.map +1 -1
- package/dist/images/variants.js +23 -22
- package/dist/images/variants.js.map +1 -1
- package/dist/index.d.ts +4 -291
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -839
- package/dist/index.js.map +1 -1
- package/dist/indexnow.d.ts +4 -2
- package/dist/indexnow.d.ts.map +1 -1
- package/dist/indexnow.js +52 -45
- package/dist/indexnow.js.map +1 -1
- package/dist/indieauth/client.d.ts +1 -1
- package/dist/indieauth/client.d.ts.map +1 -1
- package/dist/indieauth/client.js +1 -1
- package/dist/indieauth/client.js.map +1 -1
- package/dist/indieauth/grants.d.ts +1 -1
- package/dist/indieauth/grants.d.ts.map +1 -1
- package/dist/indieauth/identity.d.ts +2 -2
- package/dist/indieauth/identity.d.ts.map +1 -1
- package/dist/indieauth/identity.js +5 -7
- package/dist/indieauth/identity.js.map +1 -1
- package/dist/indieauth/redeem.d.ts +1 -1
- package/dist/indieauth/redeem.d.ts.map +1 -1
- package/dist/indieauth/redeem.js.map +1 -1
- package/dist/indieauth/request.d.ts +5 -1
- package/dist/indieauth/request.d.ts.map +1 -1
- package/dist/indieauth/request.js +6 -2
- package/dist/indieauth/request.js.map +1 -1
- package/dist/indieauth/tokens.d.ts +1 -3
- package/dist/indieauth/tokens.d.ts.map +1 -1
- package/dist/indieauth/tokens.js +4 -6
- package/dist/indieauth/tokens.js.map +1 -1
- package/dist/jobs/file-records.d.ts +13 -0
- package/dist/jobs/file-records.d.ts.map +1 -0
- package/dist/jobs/file-records.js +104 -0
- package/dist/jobs/file-records.js.map +1 -0
- package/dist/jobs/in-process.d.ts +10 -0
- package/dist/jobs/in-process.d.ts.map +1 -0
- package/dist/jobs/in-process.js +45 -0
- package/dist/jobs/in-process.js.map +1 -0
- package/dist/jobs/lock-file.d.ts +13 -0
- package/dist/jobs/lock-file.d.ts.map +1 -0
- package/dist/jobs/lock-file.js +107 -0
- package/dist/jobs/lock-file.js.map +1 -0
- package/dist/jobs/processes.d.ts +11 -0
- package/dist/jobs/processes.d.ts.map +1 -0
- package/dist/jobs/processes.js +50 -0
- package/dist/jobs/processes.js.map +1 -0
- package/dist/jobs/queue.d.ts +18 -0
- package/dist/jobs/queue.d.ts.map +1 -0
- package/dist/jobs/queue.js +220 -0
- package/dist/jobs/queue.js.map +1 -0
- package/dist/jobs/types.d.ts +159 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +22 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/mail/index.d.ts +1 -7
- package/dist/mail/index.d.ts.map +1 -1
- package/dist/mail/index.js +0 -3
- package/dist/mail/index.js.map +1 -1
- package/dist/mail/provider.d.ts +7 -0
- package/dist/mail/provider.d.ts.map +1 -1
- package/dist/maintenance.d.ts +6 -43
- package/dist/maintenance.d.ts.map +1 -1
- package/dist/maintenance.js +4 -67
- package/dist/maintenance.js.map +1 -1
- package/dist/media/library.d.ts +99 -0
- package/dist/media/library.d.ts.map +1 -0
- package/dist/media/library.js +160 -0
- package/dist/media/library.js.map +1 -0
- package/dist/media/uploads.d.ts +106 -0
- package/dist/media/uploads.d.ts.map +1 -0
- package/dist/media/uploads.js +192 -0
- package/dist/media/uploads.js.map +1 -0
- package/dist/micropub/content.js +1 -1
- package/dist/micropub/content.js.map +1 -1
- package/dist/micropub/create.d.ts +7 -15
- package/dist/micropub/create.d.ts.map +1 -1
- package/dist/micropub/create.js +48 -184
- package/dist/micropub/create.js.map +1 -1
- package/dist/micropub/endpoint.d.ts +2 -2
- package/dist/micropub/endpoint.d.ts.map +1 -1
- package/dist/micropub/endpoint.js +36 -47
- package/dist/micropub/endpoint.js.map +1 -1
- package/dist/micropub/media.d.ts +1 -3
- package/dist/micropub/media.d.ts.map +1 -1
- package/dist/micropub/media.js +10 -12
- package/dist/micropub/media.js.map +1 -1
- package/dist/micropub/update.d.ts +6 -14
- package/dist/micropub/update.d.ts.map +1 -1
- package/dist/micropub/update.js +37 -90
- package/dist/micropub/update.js.map +1 -1
- package/dist/notifications/comments.d.ts +8 -6
- package/dist/notifications/comments.d.ts.map +1 -1
- package/dist/notifications/comments.js +80 -76
- package/dist/notifications/comments.js.map +1 -1
- package/dist/notifications/digest.d.ts +6 -8
- package/dist/notifications/digest.d.ts.map +1 -1
- package/dist/notifications/digest.js +15 -26
- package/dist/notifications/digest.js.map +1 -1
- package/dist/notifications/index.d.ts +9 -6
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +6 -5
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/links.d.ts.map +1 -1
- package/dist/notifications/links.js +2 -2
- package/dist/notifications/links.js.map +1 -1
- package/dist/{mail/service.d.ts → notifications/mail-service.d.ts} +16 -20
- package/dist/notifications/mail-service.d.ts.map +1 -0
- package/dist/{mail/service.js → notifications/mail-service.js} +100 -121
- package/dist/notifications/mail-service.js.map +1 -0
- package/dist/{mail/templates.d.ts → notifications/mail-templates.d.ts} +4 -2
- package/dist/notifications/mail-templates.d.ts.map +1 -0
- package/dist/{mail/templates.js → notifications/mail-templates.js} +5 -3
- package/dist/notifications/mail-templates.js.map +1 -0
- package/dist/notifications/optouts.d.ts +0 -2
- package/dist/notifications/optouts.d.ts.map +1 -1
- package/dist/notifications/optouts.js +2 -4
- package/dist/notifications/optouts.js.map +1 -1
- package/dist/notifications/tokens.d.ts +2 -4
- package/dist/notifications/tokens.d.ts.map +1 -1
- package/dist/notifications/tokens.js +4 -30
- package/dist/notifications/tokens.js.map +1 -1
- package/dist/notify.d.ts +4 -2
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +35 -35
- package/dist/notify.js.map +1 -1
- package/dist/plugin.d.ts +59 -19
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +22 -4
- package/dist/plugin.js.map +1 -1
- package/dist/plugins/changes.d.ts +0 -1
- package/dist/plugins/changes.d.ts.map +1 -1
- package/dist/plugins/changes.js +2 -3
- package/dist/plugins/changes.js.map +1 -1
- package/dist/plugins/data.d.ts +19 -0
- package/dist/plugins/data.d.ts.map +1 -0
- package/dist/plugins/data.js +117 -0
- package/dist/plugins/data.js.map +1 -0
- package/dist/plugins/enabled.d.ts +1 -13
- package/dist/plugins/enabled.d.ts.map +1 -1
- package/dist/plugins/enabled.js +12 -26
- package/dist/plugins/enabled.js.map +1 -1
- package/dist/plugins/fetch.d.ts +1 -1
- package/dist/plugins/fetch.d.ts.map +1 -1
- package/dist/plugins/fetch.js +1 -1
- package/dist/plugins/fetch.js.map +1 -1
- package/dist/plugins/folder.d.ts.map +1 -1
- package/dist/plugins/folder.js +1 -3
- package/dist/plugins/folder.js.map +1 -1
- package/dist/plugins/install.d.ts +1 -1
- package/dist/plugins/install.d.ts.map +1 -1
- package/dist/plugins/install.js +14 -45
- package/dist/plugins/install.js.map +1 -1
- package/dist/plugins/manage.d.ts +1 -1
- package/dist/plugins/manage.d.ts.map +1 -1
- package/dist/plugins/migrate-settings.d.ts +7 -0
- package/dist/plugins/migrate-settings.d.ts.map +1 -0
- package/dist/plugins/migrate-settings.js +41 -0
- package/dist/plugins/migrate-settings.js.map +1 -0
- package/dist/plugins/registry.d.ts +10 -14
- package/dist/plugins/registry.d.ts.map +1 -1
- package/dist/plugins/registry.js +63 -45
- package/dist/plugins/registry.js.map +1 -1
- package/dist/plugins/settings.d.ts +5 -57
- package/dist/plugins/settings.d.ts.map +1 -1
- package/dist/plugins/settings.js +47 -181
- package/dist/plugins/settings.js.map +1 -1
- package/dist/plugins/site.d.ts +3 -2
- package/dist/plugins/site.d.ts.map +1 -1
- package/dist/plugins/site.js +8 -8
- package/dist/plugins/site.js.map +1 -1
- package/dist/plugins/upgrade.d.ts.map +1 -1
- package/dist/plugins/upgrade.js +4 -3
- package/dist/plugins/upgrade.js.map +1 -1
- package/dist/privacy/erase.d.ts +1 -1
- package/dist/privacy/erase.d.ts.map +1 -1
- package/dist/privacy/retention.d.ts +6 -2
- package/dist/privacy/retention.d.ts.map +1 -1
- package/dist/privacy/retention.js +20 -15
- package/dist/privacy/retention.js.map +1 -1
- package/dist/settings/akismet.d.ts +9 -0
- package/dist/settings/akismet.d.ts.map +1 -0
- package/dist/settings/akismet.js +46 -0
- package/dist/settings/akismet.js.map +1 -0
- package/dist/settings/declare.d.ts +15 -0
- package/dist/settings/declare.d.ts.map +1 -0
- package/dist/settings/declare.js +20 -0
- package/dist/settings/declare.js.map +1 -0
- package/dist/settings/dev-mode.d.ts +29 -0
- package/dist/settings/dev-mode.d.ts.map +1 -0
- package/dist/settings/dev-mode.js +77 -0
- package/dist/settings/dev-mode.js.map +1 -0
- package/dist/settings/kinds.d.ts +13 -0
- package/dist/settings/kinds.d.ts.map +1 -0
- package/dist/settings/kinds.js +108 -0
- package/dist/settings/kinds.js.map +1 -0
- package/dist/settings/mail.d.ts +10 -0
- package/dist/settings/mail.d.ts.map +1 -0
- package/dist/settings/mail.js +92 -0
- package/dist/settings/mail.js.map +1 -0
- package/dist/settings/maintenance.d.ts +6 -0
- package/dist/settings/maintenance.d.ts.map +1 -0
- package/dist/settings/maintenance.js +33 -0
- package/dist/settings/maintenance.js.map +1 -0
- package/dist/settings/names.d.ts +7 -0
- package/dist/settings/names.d.ts.map +1 -0
- package/dist/settings/names.js +31 -0
- package/dist/settings/names.js.map +1 -0
- package/dist/settings/presence.d.ts +2 -0
- package/dist/settings/presence.d.ts.map +1 -0
- package/dist/settings/presence.js +7 -0
- package/dist/settings/presence.js.map +1 -0
- package/dist/settings/registry.d.ts +14 -0
- package/dist/settings/registry.d.ts.map +1 -0
- package/dist/settings/registry.js +369 -0
- package/dist/settings/registry.js.map +1 -0
- package/dist/settings/types.d.ts +176 -0
- package/dist/settings/types.d.ts.map +1 -0
- package/dist/settings/types.js +2 -0
- package/dist/settings/types.js.map +1 -0
- package/dist/site/admin-paths.d.ts +23 -0
- package/dist/site/admin-paths.d.ts.map +1 -0
- package/dist/site/admin-paths.js +25 -0
- package/dist/site/admin-paths.js.map +1 -0
- package/dist/site/admin-theme.d.ts.map +1 -0
- package/dist/site/admin-theme.js.map +1 -0
- package/dist/{web → site}/authors.d.ts +1 -72
- package/dist/site/authors.d.ts.map +1 -0
- package/dist/site/authors.js +107 -0
- package/dist/site/authors.js.map +1 -0
- package/dist/site/feed-settings.d.ts +28 -0
- package/dist/site/feed-settings.d.ts.map +1 -0
- package/dist/site/feed-settings.js +22 -0
- package/dist/site/feed-settings.js.map +1 -0
- package/dist/{web/indexnow.d.ts → site/indexnow-key.d.ts} +1 -1
- package/dist/site/indexnow-key.d.ts.map +1 -0
- package/dist/{web/indexnow.js → site/indexnow-key.js} +1 -1
- package/dist/site/indexnow-key.js.map +1 -0
- package/dist/site/namespace.d.ts +88 -0
- package/dist/site/namespace.d.ts.map +1 -0
- package/dist/site/namespace.js +471 -0
- package/dist/site/namespace.js.map +1 -0
- package/dist/{web → site}/navigation.d.ts +7 -1
- package/dist/site/navigation.d.ts.map +1 -0
- package/dist/{web → site}/navigation.js +18 -0
- package/dist/site/navigation.js.map +1 -0
- package/dist/{notifications/preferences.d.ts → site/notification-preferences.d.ts} +2 -2
- package/dist/site/notification-preferences.d.ts.map +1 -0
- package/dist/{notifications/preferences.js → site/notification-preferences.js} +1 -1
- package/dist/site/notification-preferences.js.map +1 -0
- package/dist/site/passwords.d.ts.map +1 -0
- package/dist/site/passwords.js.map +1 -0
- package/dist/{privacy/policy.d.ts → site/retention-policy.d.ts} +1 -1
- package/dist/site/retention-policy.d.ts.map +1 -0
- package/dist/{privacy/policy.js → site/retention-policy.js} +1 -1
- package/dist/site/retention-policy.js.map +1 -0
- package/dist/site/robots.d.ts +62 -0
- package/dist/site/robots.d.ts.map +1 -0
- package/dist/site/robots.js +130 -0
- package/dist/site/robots.js.map +1 -0
- package/dist/site/setting-rows.d.ts +45 -0
- package/dist/site/setting-rows.d.ts.map +1 -0
- package/dist/site/setting-rows.js +361 -0
- package/dist/site/setting-rows.js.map +1 -0
- package/dist/site/setting-values.d.ts +63 -0
- package/dist/site/setting-values.d.ts.map +1 -0
- package/dist/site/setting-values.js +170 -0
- package/dist/site/setting-values.js.map +1 -0
- package/dist/site/settings-form.d.ts +30 -0
- package/dist/site/settings-form.d.ts.map +1 -0
- package/dist/site/settings-form.js +54 -0
- package/dist/site/settings-form.js.map +1 -0
- package/dist/site/settings.d.ts +29 -0
- package/dist/site/settings.d.ts.map +1 -0
- package/dist/site/settings.js +44 -0
- package/dist/site/settings.js.map +1 -0
- package/dist/site/site-json.d.ts +38 -0
- package/dist/site/site-json.d.ts.map +1 -0
- package/dist/site/site-json.js +69 -0
- package/dist/site/site-json.js.map +1 -0
- package/dist/{admin/accounts.d.ts → site/users.d.ts} +25 -50
- package/dist/site/users.d.ts.map +1 -0
- package/dist/{admin/accounts.js → site/users.js} +51 -166
- package/dist/site/users.js.map +1 -0
- package/dist/store/admin-store.d.ts +72 -0
- package/dist/store/admin-store.d.ts.map +1 -0
- package/dist/store/admin-store.js +62 -0
- package/dist/store/admin-store.js.map +1 -0
- package/dist/store/comments.d.ts +253 -0
- package/dist/store/comments.d.ts.map +1 -0
- package/dist/store/comments.js +234 -0
- package/dist/store/comments.js.map +1 -0
- package/dist/store/conversation.d.ts +28 -0
- package/dist/store/conversation.d.ts.map +1 -0
- package/dist/store/conversation.js +16 -0
- package/dist/store/conversation.js.map +1 -0
- package/dist/store/deliveries.d.ts +92 -0
- package/dist/store/deliveries.d.ts.map +1 -0
- package/dist/store/deliveries.js +88 -0
- package/dist/store/deliveries.js.map +1 -0
- package/dist/store/followers.d.ts +91 -0
- package/dist/store/followers.d.ts.map +1 -0
- package/dist/store/followers.js +79 -0
- package/dist/store/followers.js.map +1 -0
- package/dist/{federation/replies.d.ts → store/inbox-replies.d.ts} +2 -2
- package/dist/store/inbox-replies.d.ts.map +1 -0
- package/dist/{federation/replies.js → store/inbox-replies.js} +2 -4
- package/dist/store/inbox-replies.js.map +1 -0
- package/dist/store/inbox.d.ts +132 -0
- package/dist/store/inbox.d.ts.map +1 -0
- package/dist/store/inbox.js +157 -0
- package/dist/store/inbox.js.map +1 -0
- package/dist/store/profiles.d.ts +49 -0
- package/dist/store/profiles.d.ts.map +1 -0
- package/dist/store/profiles.js +71 -0
- package/dist/store/profiles.js.map +1 -0
- package/dist/store/queue-database.d.ts +11 -0
- package/dist/store/queue-database.d.ts.map +1 -0
- package/dist/store/queue-database.js +39 -0
- package/dist/store/queue-database.js.map +1 -0
- package/dist/store/relays.d.ts +66 -0
- package/dist/store/relays.d.ts.map +1 -0
- package/dist/store/relays.js +69 -0
- package/dist/store/relays.js.map +1 -0
- package/dist/store/rows.d.ts +33 -0
- package/dist/store/rows.d.ts.map +1 -0
- package/dist/store/rows.js +44 -0
- package/dist/store/rows.js.map +1 -0
- package/dist/store/schema.d.ts +14 -0
- package/dist/store/schema.d.ts.map +1 -0
- package/dist/store/schema.js +588 -0
- package/dist/store/schema.js.map +1 -0
- package/dist/store/sessions.d.ts +90 -0
- package/dist/store/sessions.d.ts.map +1 -0
- package/dist/store/sessions.js +107 -0
- package/dist/store/sessions.js.map +1 -0
- package/dist/store/state.d.ts +18 -0
- package/dist/store/state.d.ts.map +1 -0
- package/dist/store/state.js +19 -0
- package/dist/store/state.js.map +1 -0
- package/dist/store/tokens.d.ts +75 -0
- package/dist/store/tokens.d.ts.map +1 -0
- package/dist/store/tokens.js +82 -0
- package/dist/store/tokens.js.map +1 -0
- package/dist/store/webmentions.d.ts +66 -0
- package/dist/store/webmentions.d.ts.map +1 -0
- package/dist/store/webmentions.js +84 -0
- package/dist/store/webmentions.js.map +1 -0
- package/dist/supervisor/control.d.ts +1 -0
- package/dist/supervisor/control.d.ts.map +1 -1
- package/dist/supervisor/control.js +10 -0
- package/dist/supervisor/control.js.map +1 -1
- package/dist/supervisor/primary.d.ts +1 -1
- package/dist/supervisor/primary.d.ts.map +1 -1
- package/dist/supervisor/primary.js +46 -14
- package/dist/supervisor/primary.js.map +1 -1
- package/dist/supervisor/worker.d.ts +6 -2
- package/dist/supervisor/worker.d.ts.map +1 -1
- package/dist/supervisor/worker.js +4 -4
- package/dist/supervisor/worker.js.map +1 -1
- package/dist/testing.d.ts +8 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +6 -0
- package/dist/testing.js.map +1 -0
- package/dist/{web → theme}/assets.d.ts +1 -32
- package/dist/theme/assets.d.ts.map +1 -0
- package/dist/{web → theme}/assets.js +2 -100
- package/dist/theme/assets.js.map +1 -0
- package/dist/theme/site-data.d.ts +187 -0
- package/dist/theme/site-data.d.ts.map +1 -0
- package/dist/theme/site-data.js +160 -0
- package/dist/theme/site-data.js.map +1 -0
- package/dist/{web → theme}/templates.d.ts +2 -0
- package/dist/theme/templates.d.ts.map +1 -0
- package/dist/{web → theme}/templates.js +9 -32
- package/dist/theme/templates.js.map +1 -0
- package/dist/{web → theme}/themes.d.ts +1 -0
- package/dist/theme/themes.d.ts.map +1 -0
- package/dist/{web → theme}/themes.js +6 -3
- package/dist/theme/themes.js.map +1 -0
- package/dist/util/client-id.d.ts.map +1 -0
- package/dist/util/client-id.js.map +1 -0
- package/dist/util/clock.d.ts +9 -0
- package/dist/util/clock.d.ts.map +1 -0
- package/dist/util/clock.js +2 -0
- package/dist/util/clock.js.map +1 -0
- package/dist/util/errors.d.ts +3 -0
- package/dist/util/errors.d.ts.map +1 -0
- package/dist/util/errors.js +5 -0
- package/dist/util/errors.js.map +1 -0
- package/dist/util/files.d.ts +5 -0
- package/dist/util/files.d.ts.map +1 -0
- package/dist/util/files.js +38 -0
- package/dist/util/files.js.map +1 -0
- package/dist/util/forms.d.ts +5 -0
- package/dist/util/forms.d.ts.map +1 -0
- package/dist/util/forms.js +17 -0
- package/dist/util/forms.js.map +1 -0
- package/dist/util/guards.d.ts +4 -0
- package/dist/util/guards.d.ts.map +1 -0
- package/dist/util/guards.js +12 -0
- package/dist/util/guards.js.map +1 -0
- package/dist/{webmention/html.d.ts → util/html-tree.d.ts} +2 -2
- package/dist/util/html-tree.d.ts.map +1 -0
- package/dist/{webmention/html.js → util/html-tree.js} +3 -64
- package/dist/util/html-tree.js.map +1 -0
- package/dist/util/html.d.ts +4 -0
- package/dist/util/html.d.ts.map +1 -0
- package/dist/util/html.js +34 -0
- package/dist/util/html.js.map +1 -0
- package/dist/util/public-address.d.ts.map +1 -0
- package/dist/util/public-address.js.map +1 -0
- package/dist/util/queue.d.ts +7 -0
- package/dist/util/queue.d.ts.map +1 -0
- package/dist/util/queue.js +23 -0
- package/dist/util/queue.js.map +1 -0
- package/dist/util/retry.d.ts +11 -0
- package/dist/util/retry.d.ts.map +1 -0
- package/dist/util/retry.js +14 -0
- package/dist/util/retry.js.map +1 -0
- package/dist/util/url.d.ts +11 -0
- package/dist/util/url.d.ts.map +1 -0
- package/dist/util/url.js +20 -0
- package/dist/util/url.js.map +1 -0
- package/dist/web/absolute-urls.js +1 -1
- package/dist/web/absolute-urls.js.map +1 -1
- package/dist/web/archive.js +2 -2
- package/dist/web/archive.js.map +1 -1
- package/dist/web/asset-routes.d.ts +52 -0
- package/dist/web/asset-routes.d.ts.map +1 -0
- package/dist/web/asset-routes.js +115 -0
- package/dist/web/asset-routes.js.map +1 -0
- package/dist/{avatars/routes.d.ts → web/avatar-routes.d.ts} +2 -2
- package/dist/web/avatar-routes.d.ts.map +1 -0
- package/dist/{avatars/routes.js → web/avatar-routes.js} +3 -3
- package/dist/web/avatar-routes.js.map +1 -0
- package/dist/web/comment-routes.d.ts +18 -0
- package/dist/web/comment-routes.d.ts.map +1 -0
- package/dist/web/comment-routes.js +177 -0
- package/dist/web/comment-routes.js.map +1 -0
- package/dist/web/compression.d.ts.map +1 -1
- package/dist/web/compression.js +1 -2
- package/dist/web/compression.js.map +1 -1
- package/dist/{contact/routes.d.ts → web/contact-routes.d.ts} +2 -2
- package/dist/web/contact-routes.d.ts.map +1 -0
- package/dist/{contact/routes.js → web/contact-routes.js} +15 -18
- package/dist/web/contact-routes.js.map +1 -0
- package/dist/web/context.d.ts +6 -187
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +13 -174
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts +6 -357
- package/dist/web/conversation.d.ts.map +1 -1
- package/dist/web/conversation.js +177 -151
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/document-page.d.ts +13 -0
- package/dist/web/document-page.d.ts.map +1 -0
- package/dist/web/document-page.js +137 -0
- package/dist/web/document-page.js.map +1 -0
- package/dist/web/document-site.d.ts +14 -0
- package/dist/web/document-site.d.ts.map +1 -0
- package/dist/web/document-site.js +29 -0
- package/dist/web/document-site.js.map +1 -0
- package/dist/web/embed.d.ts +13 -0
- package/dist/web/embed.d.ts.map +1 -0
- package/dist/web/embed.js +66 -0
- package/dist/web/embed.js.map +1 -0
- package/dist/{env.d.ts → web/env.d.ts} +36 -26
- package/dist/web/env.d.ts.map +1 -0
- package/dist/web/env.js.map +1 -0
- package/dist/web/federation-routes.d.ts +33 -0
- package/dist/web/federation-routes.d.ts.map +1 -0
- package/dist/web/federation-routes.js +282 -0
- package/dist/web/federation-routes.js.map +1 -0
- package/dist/web/feed-atom.d.ts.map +1 -1
- package/dist/web/feed-atom.js +4 -3
- package/dist/web/feed-atom.js.map +1 -1
- package/dist/web/feed-item.d.ts +4 -39
- package/dist/web/feed-item.d.ts.map +1 -1
- package/dist/web/feed-item.js +9 -73
- package/dist/web/feed-item.js.map +1 -1
- package/dist/web/feed-json.js +2 -2
- package/dist/web/feed-json.js.map +1 -1
- package/dist/web/feed-routes.d.ts +35 -0
- package/dist/web/feed-routes.d.ts.map +1 -0
- package/dist/web/feed-routes.js +126 -0
- package/dist/web/feed-routes.js.map +1 -0
- package/dist/web/feed-rss.d.ts.map +1 -1
- package/dist/web/feed-rss.js +9 -6
- package/dist/web/feed-rss.js.map +1 -1
- package/dist/web/feed-source.d.ts +11 -41
- package/dist/web/feed-source.d.ts.map +1 -1
- package/dist/web/feed-source.js +11 -48
- package/dist/web/feed-source.js.map +1 -1
- package/dist/web/feeds.d.ts +4 -2
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +8 -5
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/health.d.ts +1 -1
- package/dist/web/health.d.ts.map +1 -1
- package/dist/web/health.js +1 -1
- package/dist/web/health.js.map +1 -1
- package/dist/web/index.d.ts +53 -29
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +37 -18
- package/dist/web/index.js.map +1 -1
- package/dist/{indieauth/activity-log.d.ts → web/indieauth-activity-log.d.ts} +3 -5
- package/dist/web/indieauth-activity-log.d.ts.map +1 -0
- package/dist/{indieauth/activity-log.js → web/indieauth-activity-log.js} +7 -10
- package/dist/web/indieauth-activity-log.js.map +1 -0
- package/dist/{indieauth/bearer.d.ts → web/indieauth-bearer.d.ts} +5 -5
- package/dist/web/indieauth-bearer.d.ts.map +1 -0
- package/dist/{indieauth/bearer.js → web/indieauth-bearer.js} +5 -5
- package/dist/web/indieauth-bearer.js.map +1 -0
- package/dist/{indieauth/discovery.d.ts → web/indieauth-discovery.d.ts} +2 -11
- package/dist/web/indieauth-discovery.d.ts.map +1 -0
- package/dist/{indieauth/discovery.js → web/indieauth-discovery.js} +7 -17
- package/dist/web/indieauth-discovery.js.map +1 -0
- package/dist/{indieauth/token.d.ts → web/indieauth-token.d.ts} +2 -2
- package/dist/web/indieauth-token.d.ts.map +1 -0
- package/dist/{indieauth/token.js → web/indieauth-token.js} +9 -9
- package/dist/web/indieauth-token.js.map +1 -0
- package/dist/web/listing-pages.d.ts +19 -0
- package/dist/web/listing-pages.d.ts.map +1 -0
- package/dist/web/listing-pages.js +147 -0
- package/dist/web/listing-pages.js.map +1 -0
- package/dist/web/listing-subject.d.ts +58 -0
- package/dist/web/listing-subject.d.ts.map +1 -0
- package/dist/web/listing-subject.js +89 -0
- package/dist/web/listing-subject.js.map +1 -0
- package/dist/web/llms.d.ts +16 -15
- package/dist/web/llms.d.ts.map +1 -1
- package/dist/web/llms.js +23 -35
- package/dist/web/llms.js.map +1 -1
- package/dist/web/maintenance.d.ts +1 -1
- package/dist/web/maintenance.d.ts.map +1 -1
- package/dist/web/maintenance.js +10 -16
- package/dist/web/maintenance.js.map +1 -1
- package/dist/web/manifest.d.ts +1 -1
- package/dist/web/manifest.d.ts.map +1 -1
- package/dist/web/manifest.js +2 -1
- package/dist/web/manifest.js.map +1 -1
- package/dist/web/markdown-page.d.ts +76 -0
- package/dist/web/markdown-page.d.ts.map +1 -0
- package/dist/web/markdown-page.js +79 -0
- package/dist/web/markdown-page.js.map +1 -0
- package/dist/web/markdown-representation.d.ts +15 -0
- package/dist/web/markdown-representation.d.ts.map +1 -0
- package/dist/web/markdown-representation.js +130 -0
- package/dist/web/markdown-representation.js.map +1 -0
- package/dist/web/negotiate.d.ts +5 -12
- package/dist/web/negotiate.d.ts.map +1 -1
- package/dist/web/negotiate.js +11 -18
- package/dist/web/negotiate.js.map +1 -1
- package/dist/{notifications/routes.d.ts → web/notification-routes.d.ts} +2 -2
- package/dist/web/notification-routes.d.ts.map +1 -0
- package/dist/{notifications/routes.js → web/notification-routes.js} +24 -29
- package/dist/web/notification-routes.js.map +1 -0
- package/dist/web/oembed.d.ts +2 -2
- package/dist/web/oembed.d.ts.map +1 -1
- package/dist/web/oembed.js +5 -4
- package/dist/web/oembed.js.map +1 -1
- package/dist/web/opensearch.d.ts.map +1 -1
- package/dist/web/opensearch.js +2 -1
- package/dist/web/opensearch.js.map +1 -1
- package/dist/web/page-sources.d.ts +14 -0
- package/dist/web/page-sources.d.ts.map +1 -0
- package/dist/web/page-sources.js +43 -0
- package/dist/web/page-sources.js.map +1 -0
- package/dist/web/pagination.d.ts +1 -0
- package/dist/web/pagination.d.ts.map +1 -1
- package/dist/web/pagination.js +4 -1
- package/dist/web/pagination.js.map +1 -1
- package/dist/web/paths.d.ts +52 -0
- package/dist/web/paths.d.ts.map +1 -0
- package/dist/web/paths.js +208 -0
- package/dist/web/paths.js.map +1 -0
- package/dist/web/pingback-routes.d.ts +5 -0
- package/dist/web/pingback-routes.d.ts.map +1 -0
- package/dist/web/pingback-routes.js +100 -0
- package/dist/web/pingback-routes.js.map +1 -0
- package/dist/{plugins/federation.d.ts → web/plugin-federation.d.ts} +3 -3
- package/dist/web/plugin-federation.d.ts.map +1 -0
- package/dist/{plugins/federation.js → web/plugin-federation.js} +55 -45
- package/dist/web/plugin-federation.js.map +1 -0
- package/dist/{plugins/mount.d.ts → web/plugin-routes.d.ts} +3 -3
- package/dist/web/plugin-routes.d.ts.map +1 -0
- package/dist/{plugins/mount.js → web/plugin-routes.js} +2 -2
- package/dist/web/plugin-routes.js.map +1 -0
- package/dist/web/redirects.d.ts +1 -84
- package/dist/web/redirects.d.ts.map +1 -1
- package/dist/web/redirects.js +0 -232
- package/dist/web/redirects.js.map +1 -1
- package/dist/web/render.d.ts +55 -12
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +123 -65
- package/dist/web/render.js.map +1 -1
- package/dist/web/request.d.ts +44 -0
- package/dist/web/request.d.ts.map +1 -0
- package/dist/web/request.js +82 -0
- package/dist/web/request.js.map +1 -0
- package/dist/web/robots.d.ts +1 -46
- package/dist/web/robots.d.ts.map +1 -1
- package/dist/web/robots.js +5 -131
- package/dist/web/robots.js.map +1 -1
- package/dist/web/routes.d.ts +5 -39
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +102 -1374
- package/dist/web/routes.js.map +1 -1
- package/dist/web/search.d.ts.map +1 -1
- package/dist/web/search.js +2 -9
- package/dist/web/search.js.map +1 -1
- package/dist/{admin → web}/session.d.ts +1 -5
- package/dist/web/session.d.ts.map +1 -0
- package/dist/{admin → web}/session.js +0 -4
- package/dist/web/session.js.map +1 -0
- package/dist/web/share-image.d.ts.map +1 -1
- package/dist/web/share-image.js +3 -2
- package/dist/web/share-image.js.map +1 -1
- package/dist/web/signed-in.d.ts +27 -0
- package/dist/web/signed-in.d.ts.map +1 -0
- package/dist/web/signed-in.js +45 -0
- package/dist/web/signed-in.js.map +1 -0
- package/dist/web/site-index.d.ts +13 -0
- package/dist/web/site-index.d.ts.map +1 -0
- package/dist/web/site-index.js +142 -0
- package/dist/web/site-index.js.map +1 -0
- package/dist/web/site-view.d.ts +43 -0
- package/dist/web/site-view.d.ts.map +1 -0
- package/dist/web/site-view.js +65 -0
- package/dist/web/site-view.js.map +1 -0
- package/dist/web/sitemap.d.ts.map +1 -1
- package/dist/web/sitemap.js +5 -7
- package/dist/web/sitemap.js.map +1 -1
- package/dist/{admin → web}/throttle.d.ts +3 -4
- package/dist/web/throttle.d.ts.map +1 -0
- package/dist/{admin → web}/throttle.js +1 -2
- package/dist/web/throttle.js.map +1 -0
- package/dist/web/webmention-routes.d.ts +11 -0
- package/dist/web/webmention-routes.d.ts.map +1 -0
- package/dist/web/webmention-routes.js +51 -0
- package/dist/web/webmention-routes.js.map +1 -0
- package/dist/web/well-known.d.ts +2 -2
- package/dist/web/well-known.d.ts.map +1 -1
- package/dist/web/well-known.js +10 -20
- package/dist/web/well-known.js.map +1 -1
- package/dist/webmention/cited-picture.d.ts +1 -2
- package/dist/webmention/cited-picture.d.ts.map +1 -1
- package/dist/webmention/cited-picture.js +5 -7
- package/dist/webmention/cited-picture.js.map +1 -1
- package/dist/webmention/discovery.d.ts +2 -13
- package/dist/webmention/discovery.d.ts.map +1 -1
- package/dist/webmention/discovery.js +18 -73
- package/dist/webmention/discovery.js.map +1 -1
- package/dist/webmention/endpoint.d.ts +27 -0
- package/dist/webmention/endpoint.d.ts.map +1 -0
- package/dist/webmention/endpoint.js +26 -0
- package/dist/webmention/endpoint.js.map +1 -0
- package/dist/webmention/fetch-public.d.ts +18 -1
- package/dist/webmention/fetch-public.d.ts.map +1 -1
- package/dist/webmention/fetch-public.js +57 -23
- package/dist/webmention/fetch-public.js.map +1 -1
- package/dist/webmention/index.d.ts +11 -9
- package/dist/webmention/index.d.ts.map +1 -1
- package/dist/webmention/index.js +7 -6
- package/dist/webmention/index.js.map +1 -1
- package/dist/webmention/links.d.ts +0 -55
- package/dist/webmention/links.d.ts.map +1 -1
- package/dist/webmention/links.js +2 -185
- package/dist/webmention/links.js.map +1 -1
- package/dist/webmention/microformats.d.ts +1 -1
- package/dist/webmention/microformats.d.ts.map +1 -1
- package/dist/webmention/microformats.js +1 -1
- package/dist/webmention/microformats.js.map +1 -1
- package/dist/webmention/pingback.d.ts +13 -12
- package/dist/webmention/pingback.d.ts.map +1 -1
- package/dist/webmention/pingback.js +6 -113
- package/dist/webmention/pingback.js.map +1 -1
- package/dist/webmention/receive.d.ts +9 -4
- package/dist/webmention/receive.d.ts.map +1 -1
- package/dist/webmention/receive.js +35 -41
- package/dist/webmention/receive.js.map +1 -1
- package/dist/webmention/reply-context/fediverse.d.ts +29 -0
- package/dist/webmention/reply-context/fediverse.d.ts.map +1 -0
- package/dist/webmention/reply-context/fediverse.js +59 -0
- package/dist/webmention/reply-context/fediverse.js.map +1 -0
- package/dist/webmention/reply-context/json-ld.d.ts +5 -0
- package/dist/webmention/reply-context/json-ld.d.ts.map +1 -0
- package/dist/webmention/reply-context/json-ld.js +78 -0
- package/dist/webmention/reply-context/json-ld.js.map +1 -0
- package/dist/webmention/reply-context/oembed.d.ts +21 -0
- package/dist/webmention/reply-context/oembed.d.ts.map +1 -0
- package/dist/webmention/reply-context/oembed.js +137 -0
- package/dist/webmention/reply-context/oembed.js.map +1 -0
- package/dist/webmention/reply-context/page.d.ts +30 -0
- package/dist/webmention/reply-context/page.d.ts.map +1 -0
- package/dist/webmention/reply-context/page.js +134 -0
- package/dist/webmention/reply-context/page.js.map +1 -0
- package/dist/webmention/reply-context/source.d.ts +29 -0
- package/dist/webmention/reply-context/source.d.ts.map +1 -0
- package/dist/webmention/reply-context/source.js +46 -0
- package/dist/webmention/reply-context/source.js.map +1 -0
- package/dist/webmention/reply-context.d.ts +5 -34
- package/dist/webmention/reply-context.d.ts.map +1 -1
- package/dist/webmention/reply-context.js +9 -432
- package/dist/webmention/reply-context.js.map +1 -1
- package/dist/webmention/reply-contexts.d.ts +17 -6
- package/dist/webmention/reply-contexts.d.ts.map +1 -1
- package/dist/webmention/reply-contexts.js +19 -20
- package/dist/webmention/reply-contexts.js.map +1 -1
- package/dist/webmention/routes.d.ts +1 -1
- package/dist/webmention/routes.d.ts.map +1 -1
- package/dist/webmention/routes.js +5 -8
- package/dist/webmention/routes.js.map +1 -1
- package/dist/webmention/service.d.ts +9 -7
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +111 -110
- package/dist/webmention/service.js.map +1 -1
- package/dist/webmention/syndication.d.ts +3 -26
- package/dist/webmention/syndication.d.ts.map +1 -1
- package/dist/webmention/syndication.js +6 -20
- package/dist/webmention/syndication.js.map +1 -1
- package/package.json +17 -11
- package/themes/default/README.md +36 -2339
- package/themes/default/layouts/base.njk +5 -4
- package/themes/default/layouts/front-page.njk +4 -3
- package/themes/default/layouts/page.njk +2 -0
- package/themes/default/partials/comment-form.njk +1 -1
- package/themes/default/partials/contact-form.njk +1 -2
- package/themes/default/partials/conversation.njk +1 -2
- package/themes/default/partials/markdown-footer.md.njk +24 -0
- package/themes/default/partials/markdown-header.md.njk +31 -0
- package/themes/default/partials/post-list.njk +11 -11
- package/themes/default/src/style.css +18 -6
- package/themes/default/static/style.css +19 -0
- package/dist/admin/accounts.d.ts.map +0 -1
- package/dist/admin/accounts.js.map +0 -1
- package/dist/admin/admin-theme.d.ts.map +0 -1
- package/dist/admin/admin-theme.js.map +0 -1
- package/dist/admin/enclosure-field.d.ts.map +0 -1
- package/dist/admin/enclosure-field.js.map +0 -1
- package/dist/admin/event-field.d.ts.map +0 -1
- package/dist/admin/event-field.js.map +0 -1
- package/dist/admin/location-field.d.ts +0 -27
- package/dist/admin/location-field.d.ts.map +0 -1
- package/dist/admin/location-field.js.map +0 -1
- package/dist/admin/passwords.d.ts.map +0 -1
- package/dist/admin/passwords.js.map +0 -1
- package/dist/admin/photo-field.d.ts +0 -38
- package/dist/admin/photo-field.d.ts.map +0 -1
- package/dist/admin/photo-field.js.map +0 -1
- package/dist/admin/read-field.d.ts +0 -22
- package/dist/admin/read-field.d.ts.map +0 -1
- package/dist/admin/read-field.js +0 -61
- package/dist/admin/read-field.js.map +0 -1
- package/dist/admin/session.d.ts.map +0 -1
- package/dist/admin/session.js.map +0 -1
- package/dist/admin/store.d.ts +0 -1025
- package/dist/admin/store.d.ts.map +0 -1
- package/dist/admin/store.js +0 -1628
- package/dist/admin/store.js.map +0 -1
- package/dist/admin/throttle.d.ts.map +0 -1
- package/dist/admin/throttle.js.map +0 -1
- package/dist/avatars/routes.d.ts.map +0 -1
- package/dist/avatars/routes.js.map +0 -1
- package/dist/contact/routes.d.ts.map +0 -1
- package/dist/contact/routes.js.map +0 -1
- package/dist/env.d.ts.map +0 -1
- package/dist/env.js.map +0 -1
- package/dist/federation/replies.d.ts.map +0 -1
- package/dist/federation/replies.js.map +0 -1
- package/dist/images/alt-text.d.ts.map +0 -1
- package/dist/images/alt-text.js.map +0 -1
- package/dist/indieauth/activity-log.d.ts.map +0 -1
- package/dist/indieauth/activity-log.js.map +0 -1
- package/dist/indieauth/app-activity.d.ts.map +0 -1
- package/dist/indieauth/app-activity.js.map +0 -1
- package/dist/indieauth/bearer.d.ts.map +0 -1
- package/dist/indieauth/bearer.js.map +0 -1
- package/dist/indieauth/client-id.d.ts.map +0 -1
- package/dist/indieauth/client-id.js.map +0 -1
- package/dist/indieauth/connected-apps.d.ts.map +0 -1
- package/dist/indieauth/connected-apps.js.map +0 -1
- package/dist/indieauth/consent.d.ts.map +0 -1
- package/dist/indieauth/consent.js.map +0 -1
- package/dist/indieauth/discovery.d.ts.map +0 -1
- package/dist/indieauth/discovery.js.map +0 -1
- package/dist/indieauth/token.d.ts.map +0 -1
- package/dist/indieauth/token.js.map +0 -1
- package/dist/mail/service.d.ts.map +0 -1
- package/dist/mail/service.js.map +0 -1
- package/dist/mail/templates.d.ts.map +0 -1
- package/dist/mail/templates.js.map +0 -1
- package/dist/notifications/preferences.d.ts.map +0 -1
- package/dist/notifications/preferences.js.map +0 -1
- package/dist/notifications/routes.d.ts.map +0 -1
- package/dist/notifications/routes.js.map +0 -1
- package/dist/plugins/federation.d.ts.map +0 -1
- package/dist/plugins/federation.js.map +0 -1
- package/dist/plugins/mount.d.ts.map +0 -1
- package/dist/plugins/mount.js.map +0 -1
- package/dist/privacy/policy.d.ts.map +0 -1
- package/dist/privacy/policy.js.map +0 -1
- package/dist/web/admin-bar.d.ts.map +0 -1
- package/dist/web/admin-bar.js.map +0 -1
- package/dist/web/assets.d.ts.map +0 -1
- package/dist/web/assets.js.map +0 -1
- package/dist/web/authors.d.ts.map +0 -1
- package/dist/web/authors.js +0 -211
- package/dist/web/authors.js.map +0 -1
- package/dist/web/documents.d.ts +0 -81
- package/dist/web/documents.d.ts.map +0 -1
- package/dist/web/documents.js.map +0 -1
- package/dist/web/errors.d.ts.map +0 -1
- package/dist/web/errors.js.map +0 -1
- package/dist/web/guids.d.ts.map +0 -1
- package/dist/web/guids.js.map +0 -1
- package/dist/web/indexnow.d.ts.map +0 -1
- package/dist/web/indexnow.js.map +0 -1
- package/dist/web/license.d.ts.map +0 -1
- package/dist/web/license.js.map +0 -1
- package/dist/web/locale.d.ts.map +0 -1
- package/dist/web/locale.js.map +0 -1
- package/dist/web/navigation.d.ts.map +0 -1
- package/dist/web/navigation.js.map +0 -1
- package/dist/web/recent.d.ts +0 -43
- package/dist/web/recent.d.ts.map +0 -1
- package/dist/web/recent.js +0 -37
- package/dist/web/recent.js.map +0 -1
- package/dist/web/sanitize.d.ts +0 -29
- package/dist/web/sanitize.d.ts.map +0 -1
- package/dist/web/sanitize.js.map +0 -1
- package/dist/web/taxonomy.d.ts.map +0 -1
- package/dist/web/taxonomy.js.map +0 -1
- package/dist/web/templates.d.ts.map +0 -1
- package/dist/web/templates.js.map +0 -1
- package/dist/web/themes.d.ts.map +0 -1
- package/dist/web/themes.js.map +0 -1
- package/dist/webmention/html.d.ts.map +0 -1
- package/dist/webmention/html.js.map +0 -1
- package/dist/webmention/public-address.d.ts.map +0 -1
- package/dist/webmention/public-address.js.map +0 -1
- /package/dist/{web → content}/license.d.ts +0 -0
- /package/dist/{web → content}/license.js +0 -0
- /package/dist/{admin → site}/admin-theme.d.ts +0 -0
- /package/dist/{admin → site}/admin-theme.js +0 -0
- /package/dist/{admin → site}/passwords.d.ts +0 -0
- /package/dist/{admin → site}/passwords.js +0 -0
- /package/dist/{indieauth → util}/client-id.d.ts +0 -0
- /package/dist/{indieauth → util}/client-id.js +0 -0
- /package/dist/{webmention → util}/public-address.d.ts +0 -0
- /package/dist/{webmention → util}/public-address.js +0 -0
- /package/dist/{env.js → web/env.js} +0 -0
package/README.md
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
# @geekity/cms
|
|
2
2
|
|
|
3
|
-
A file-first CMS. Content is Markdown on disk,
|
|
4
|
-
|
|
3
|
+
A file-first CMS. Content is Markdown on disk, in a layout that Eleventy can
|
|
4
|
+
build unchanged. One Node process serves both the editor and the public
|
|
5
5
|
website. SQLite holds a derived index plus the data that has no natural file
|
|
6
6
|
form.
|
|
7
7
|
|
|
8
|
-
This is the guide for someone building a site on the package. The repository's
|
|
9
|
-
own [README](https://github.com/geekitycom/cms#readme) is the contributor guide.
|
|
10
|
-
|
|
11
8
|
Requires Node 24 or newer, the active LTS line.
|
|
12
9
|
|
|
13
10
|
## A new site
|
|
@@ -19,38 +16,9 @@ pnpm install
|
|
|
19
16
|
pnpm dev
|
|
20
17
|
```
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
starter privacy page and the packaged theme. `geekity init` refuses a directory
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
What it writes:
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
package.json depends on @geekity/cms; scripts dev, start and sync
|
|
30
|
-
geekity.config.ts port, directories, base URL
|
|
31
|
-
server.ts the entry file; where your own routes go
|
|
32
|
-
tsconfig.json so the site type checks against the package
|
|
33
|
-
content/
|
|
34
|
-
posts/ Markdown posts, plus posts.json for Eleventy
|
|
35
|
-
pages/ about.md, privacy.md, plus pages.json
|
|
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
|
|
39
|
-
.gitignore node_modules, data and .env
|
|
40
|
-
```
|
|
41
|
-
|
|
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.
|
|
50
|
-
|
|
51
|
-
The generated `package.json` pins `@geekity/cms` to the version of the CLI that
|
|
52
|
-
wrote it, so a site is never scaffolded against a version it has not been tested
|
|
53
|
-
with.
|
|
19
|
+
The site runs on <http://localhost:3000> with a post, an About page, a
|
|
20
|
+
starter privacy page and the packaged theme. `geekity init` refuses a directory
|
|
21
|
+
that is not empty, so it never overwrites an existing site.
|
|
54
22
|
|
|
55
23
|
## An existing project
|
|
56
24
|
|
|
@@ -59,7 +27,7 @@ pnpm add @geekity/cms
|
|
|
59
27
|
```
|
|
60
28
|
|
|
61
29
|
Then write a `geekity.config.ts` and a `content/` directory, and either add an
|
|
62
|
-
entry file or run
|
|
30
|
+
entry file or run `geekity serve`.
|
|
63
31
|
|
|
64
32
|
## Upgrading
|
|
65
33
|
|
|
@@ -67,4236 +35,28 @@ entry file or run the bin. Nothing else is required.
|
|
|
67
35
|
pnpm up @geekity/cms
|
|
68
36
|
```
|
|
69
37
|
|
|
70
|
-
Database migrations ship inside the package and run on boot
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
breaking change to any of them is a major with a migration note in the
|
|
74
|
-
changelog.
|
|
75
|
-
|
|
76
|
-
Upgrading to the release that renamed the session cookie signs everybody out
|
|
77
|
-
of an https site once. See [Session hardening](#session-hardening).
|
|
78
|
-
|
|
79
|
-
Upgrading never deletes a reader's data. The release that added
|
|
80
|
-
[retention](#retention) keeps every commenter email, address hash and contact
|
|
81
|
-
message on a site whose `site.json` has not set a period.
|
|
82
|
-
|
|
83
|
-
## The `geekity` command
|
|
84
|
-
|
|
85
|
-
`geekity` is installed as a bin, so `pnpm geekity <command>` runs it inside a
|
|
86
|
-
site (or `npx geekity`, or a `package.json` script, which is how the generated
|
|
87
|
-
`sync` script calls it).
|
|
88
|
-
|
|
89
|
-
| Command | What it does |
|
|
90
|
-
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
91
|
-
| `geekity serve` | Boot from the config file and listen, in a worker the command supervises: a change to the plugins folder replaces it, and a crash respawns it. The default when no command is given. |
|
|
92
|
-
| `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
|
|
93
|
-
| `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
|
|
94
|
-
| `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
|
|
95
|
-
| `geekity resend --all`, `<slug>...` | Send announced posts to every follower and relay again, as they now read. See [Quote posts](#quote-posts). |
|
|
96
|
-
| `geekity maintenance on`, `off`, `status` | Take the public site down on purpose with a 503 and `Retry-After`, or bring it back, without a restart. `on --until <time>` names when it should be back. |
|
|
97
|
-
| `geekity dev-mode on`, `off`, `status` | Hold everything the site would send to the outside world while it is built or migrated, or take it live. See [Dev mode](#dev-mode). |
|
|
98
|
-
| `geekity strip-metadata` | Remove location and camera metadata from files already in `content/uploads`. See [The media library](#the-media-library). |
|
|
99
|
-
| `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
|
|
100
|
-
| `geekity plugin add <package>`, `remove` | Install a plugin package's bundle from the npm registry into the plugins folder (`GEEKITY_PLUGINS_DIR`), or delete a disabled one. A running `geekity serve` reloads with the change. |
|
|
101
|
-
| `geekity --help`, `-h` | The same table, on the terminal. |
|
|
102
|
-
| `geekity --version` | The installed version. |
|
|
103
|
-
|
|
104
|
-
An installed plugin may add commands of its own, which `geekity --help` lists
|
|
105
|
-
under _Plugin commands_ and which run whether or not the plugin is enabled.
|
|
106
|
-
That includes a plugin in the plugins folder. The repository README's
|
|
107
|
-
_Plugins_ section describes `geekity plugin add` and writing a plugin.
|
|
108
|
-
|
|
109
|
-
`serve`, `sync`, `rebuild`, `resend`, `maintenance`, `dev-mode`, `strip-metadata`, `user add`, `plugin` and every plugin command take
|
|
110
|
-
`--config <file>`; without it they look for `geekity.config.ts`, then
|
|
111
|
-
`geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
|
|
112
|
-
on defaults if there is none.
|
|
113
|
-
|
|
114
|
-
`sync` prints what the scan did and forces watching off whatever the config
|
|
115
|
-
says, because a one-shot scan that then sat in a watcher would never exit:
|
|
116
|
-
|
|
117
|
-
```sh
|
|
118
|
-
$ geekity sync
|
|
119
|
-
Scanned 12: 2 created, 1 updated, 0 removed, 9 unchanged, 0 failed
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
A file that will not parse is logged, left out of the index and counted in
|
|
123
|
-
`failed`; the command then exits `1` so a deploy step notices. Everything else
|
|
124
|
-
in the directory is still indexed.
|
|
125
|
-
|
|
126
|
-
### Starting the database again
|
|
127
|
-
|
|
128
|
-
`geekity rebuild` deletes `data/geekity.db` (with its `-wal` and `-shm`) and
|
|
129
|
-
builds a new one exactly as a boot does — the same migrations, the same scan of
|
|
130
|
-
`content/`, the same read of `content/_data/federation/` — then says what it
|
|
131
|
-
indexed:
|
|
132
|
-
|
|
133
|
-
```sh
|
|
134
|
-
$ geekity rebuild
|
|
135
|
-
Rebuilt /srv/blog/data/geekity.db from the files.
|
|
136
|
-
Scanned 214: 214 created, 0 updated, 0 removed, 0 unchanged, 0 failed
|
|
137
|
-
Indexed 37 followers and 1,204 inbox activities.
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
Nothing in the database is anything but a reading of `content/` and `data/`, so
|
|
141
|
-
this is a command with no undo and nothing to lose; [what a rebuild does cost
|
|
142
|
-
is listed below](#two-directories-content-and-data). It is also the way past a
|
|
143
|
-
database this version refuses to open — a damaged one, or one written by a newer
|
|
144
|
-
`@geekity/cms`.
|
|
145
|
-
|
|
146
|
-
It refuses while the site is running, because deleting the file under a live
|
|
147
|
-
server would leave it writing to a database nothing can find. Stop the site
|
|
148
|
-
first. A file that will not parse is reported and exits `1`, as with `sync`.
|
|
149
|
-
|
|
150
|
-
That is why it is not the usual repair. **Tools > Content index** in the admin
|
|
151
|
-
empties the index and reads every file again in place, on the live connections:
|
|
152
|
-
no downtime, nobody signed out, and the delivery log, the relay handshakes and
|
|
153
|
-
the scheduler's watermark all kept. It is behind a confirm step, because the
|
|
154
|
-
site answers 404 for its documents between the emptying and the end of the
|
|
155
|
-
scan, and it federates nothing — a change a scan makes carries `origin: 'scan'`,
|
|
156
|
-
which delivery, the webmentions and the feed pings ignore. Use the command for
|
|
157
|
-
the one case the screen cannot be the door for: a database this version refuses
|
|
158
|
-
to open, where there is no site running to press a button in.
|
|
159
|
-
|
|
160
|
-
### Creating an admin from the command line
|
|
161
|
-
|
|
162
|
-
`geekity user add <username>` writes a user straight into `data/users.json`,
|
|
163
|
-
which is how a site that cannot reach `/admin/setup` from a browser — a
|
|
164
|
-
headless deploy, a server behind a bastion — gets its first login. It is also
|
|
165
|
-
the way back into a site that has locked everybody out and cannot send email.
|
|
166
|
-
It enforces exactly the rules the setup form does: a username of 1 to 64
|
|
167
|
-
letters, digits, dots, dashes or underscores, and a password of at least 8
|
|
168
|
-
characters.
|
|
169
|
-
|
|
170
|
-
```sh
|
|
171
|
-
$ geekity user add ada
|
|
172
|
-
Password for ada:
|
|
173
|
-
Created admin user ada. Sign in at /admin/login.
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
The prompt does not echo. When standard input is not a terminal the command
|
|
177
|
-
prints no prompt and reads the password as a single line, so a script can pipe
|
|
178
|
-
one in:
|
|
179
|
-
|
|
180
|
-
```sh
|
|
181
|
-
printf '%s\n' "$ADMIN_PASSWORD" | geekity user add ada
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
`--password <pw>` passes it inline instead. That is the least private of the
|
|
185
|
-
three: a password on the command line is visible in the process list and lands
|
|
186
|
-
in shell history.
|
|
187
|
-
|
|
188
|
-
`--email <address>` puts an address on the new user, which is optional and is
|
|
189
|
-
what [password recovery](#forgotten-passwords) works through:
|
|
190
|
-
|
|
191
|
-
```sh
|
|
192
|
-
$ geekity user add ada --email ada@example.com
|
|
193
|
-
Password for ada:
|
|
194
|
-
Created admin user ada (ada@example.com). Sign in at /admin/login.
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
A name that is already taken is refused and nothing is written; so are an
|
|
198
|
-
illegal username, a short password and an address that is not one. Every
|
|
199
|
-
refusal prints why and exits `1`.
|
|
200
|
-
|
|
201
|
-
Once somebody can sign in, the [`/admin/users` screen](#the-admin) is the
|
|
202
|
-
easier door: it adds users, generates passwords and deletes them, and each
|
|
203
|
-
user's own screen under it is where the rest of an account is edited.
|
|
204
|
-
|
|
205
|
-
### How a TypeScript config is loaded
|
|
206
|
-
|
|
207
|
-
`geekity serve` and `geekity sync` import `geekity.config.ts` directly. That is
|
|
208
|
-
all it takes on Node 24, which strips types without a flag. For a config using
|
|
209
|
-
syntax stripping cannot erase, such as `enum`, the
|
|
210
|
-
CLI falls back to registering the **tsx installed in your own site**, which is
|
|
211
|
-
what `geekity init` puts in `devDependencies` (it is what runs `server.ts` too).
|
|
212
|
-
A site that wants neither can write `geekity.config.js` instead; it is in the
|
|
213
|
-
lookup list for exactly that reason. Nothing else is searched: the tsx has to be
|
|
214
|
-
the site's, so a copy that happens to be elsewhere on the machine is never used.
|
|
215
|
-
|
|
216
|
-
## Using the package from an entry file
|
|
217
|
-
|
|
218
|
-
An entry file exists so a site can add routes and middleware of its own. A site
|
|
219
|
-
that adds nothing can delete `server.ts` and run `geekity serve`.
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
import { createCms } from '@geekity/cms';
|
|
223
|
-
|
|
224
|
-
import config from './geekity.config.ts';
|
|
225
|
-
|
|
226
|
-
const cms = createCms(config);
|
|
227
|
-
|
|
228
|
-
cms.app.get('/hello/', (c) => c.text('a route of my own'));
|
|
229
|
-
|
|
230
|
-
await cms.serve();
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
`createCms(config)` returns a `Cms`:
|
|
234
|
-
|
|
235
|
-
| Member | What it is |
|
|
236
|
-
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
|
237
|
-
| `app` | The Hono app. Add routes and middleware before serving; handlers reach `c.var.store`, `c.var.config` and `c.var.renderer`. |
|
|
238
|
-
| `config` | The config after defaults and environment overrides, every path absolute. |
|
|
239
|
-
| `store` | The content index. |
|
|
240
|
-
| `events` | Index changes as they happen. |
|
|
241
|
-
| `onDocumentChange(hook)` | Subscribe to every change; returns the unsubscribe. |
|
|
242
|
-
| `onPublish(hook)` | Subscribe to documents becoming visible; returns the unsubscribe. |
|
|
243
|
-
| `sync()` | One full scan of the content directory. |
|
|
244
|
-
| `serve()` | Scan, start watching, listen. Resolves with the port actually bound, which matters when the configured port is `0`. |
|
|
245
|
-
| `close()` | Stop the watcher, the server and the index. Safe to call twice, and required, because the index holds an open database. |
|
|
246
|
-
|
|
247
|
-
Routes a site registers always win over the content index. Documents are
|
|
248
|
-
resolved in the not-found handler, after every registered route has failed to
|
|
249
|
-
match, so a post whose permalink is `/hello/` cannot shadow the `/hello/` route
|
|
250
|
-
above — and neither can it shadow the admin and federation routes of later
|
|
251
|
-
milestones.
|
|
252
|
-
|
|
253
|
-
## Configuration
|
|
254
|
-
|
|
255
|
-
Declare config in `geekity.config.ts`; `defineConfig` gives it type checking.
|
|
256
|
-
|
|
257
|
-
```ts
|
|
258
|
-
import { defineConfig } from '@geekity/cms';
|
|
259
|
-
|
|
260
|
-
export default defineConfig({
|
|
261
|
-
port: 3000,
|
|
262
|
-
contentDir: 'content',
|
|
263
|
-
dataDir: 'data',
|
|
264
|
-
themesDir: 'themes',
|
|
265
|
-
baseUrl: 'http://localhost:3000',
|
|
266
|
-
watch: true,
|
|
267
|
-
sessionLifetime: 60 * 60 * 24 * 14,
|
|
268
|
-
loginAttempts: 5,
|
|
269
|
-
loginLockout: 15 * 60,
|
|
270
|
-
trustProxy: false,
|
|
271
|
-
});
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
Every field is optional. Relative directories resolve against the working
|
|
275
|
-
directory; absolute ones are used as given.
|
|
276
|
-
|
|
277
|
-
| Field | Default | Environment override | Meaning |
|
|
278
|
-
| ------------------ | ------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
279
|
-
| `port` | `3000` | `GEEKITY_PORT`, then `PORT` | Port the HTTP server listens on. `0` picks a free one. |
|
|
280
|
-
| `contentDir` | `<cwd>/content` | `GEEKITY_CONTENT_DIR` | Markdown content. |
|
|
281
|
-
| `dataDir` | `<cwd>/data` | `GEEKITY_DATA_DIR` | Derived state — the SQLite index, the image variants — and the files that are not derived and must be backed up, such as `users.json`, the actor key pairs under `keys/` and `locations.json`. [Two directories](#two-directories-content-and-data) lists them. |
|
|
282
|
-
| `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. |
|
|
283
|
-
| `baseUrl` | `http://localhost:<port>` | `GEEKITY_BASE_URL` | Public origin for canonical URLs, feeds and ActivityPub ids. A trailing slash is stripped. |
|
|
284
|
-
| `watch` | `true` | `GEEKITY_WATCH` | Watch `contentDir` while serving and keep the index in step. |
|
|
285
|
-
| `sessionLifetime` | `1209600` (14 days) | `GEEKITY_SESSION_LIFETIME` | How long an admin login lasts, in seconds. |
|
|
286
|
-
| `loginAttempts` | `5` | `GEEKITY_LOGIN_ATTEMPTS` | Failed sign-ins a username or an address may make before it is locked out. |
|
|
287
|
-
| `loginLockout` | `900` (15 minutes) | `GEEKITY_LOGIN_LOCKOUT` | How long the first lockout lasts, in seconds. See [Login hardening](#login-hardening). |
|
|
288
|
-
| `trustProxy` | `false` | `GEEKITY_TRUST_PROXY` | Believe `X-Forwarded-For` when deciding which address a sign-in came from. |
|
|
289
|
-
| `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). |
|
|
290
|
-
| `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
|
|
291
|
-
| `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
|
|
292
|
-
| `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
|
|
293
|
-
| `devMode` | `false` | `GEEKITY_DEV_MODE` | Start the site in dev mode, which holds every outbound side effect until `geekity dev-mode off`. See [Dev mode](#dev-mode). |
|
|
294
|
-
| `compression` | `true` | `GEEKITY_COMPRESSION` | Compress text responses with brotli or gzip. Turn it off behind a proxy that compresses. See [Compression](#compression). |
|
|
295
|
-
| `securityHeaders` | [see below](#security-headers) | — | Headers every response carries. A string replaces a default or adds a header, `false` removes one. |
|
|
296
|
-
| `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
|
|
297
|
-
| `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
|
|
298
|
-
| `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
|
|
299
|
-
| `commentChecker` | Akismet | — | A spam checker of the site's own, which wins over the key in `data/akismet.json`. See [Akismet](#akismet). |
|
|
300
|
-
| `mail` | `{}` | — | Mail provider, retries, backoff and logger. See [Email](#email). |
|
|
301
|
-
| `plugins` | `[]` | — | The installed plugins. Which run is the `plugins` key of `site.json`, set under Admin > Plugins. A plugin's secret settings take an environment variable each; see below. |
|
|
302
|
-
|
|
303
|
-
Precedence is environment variable, then config file, then default, so a host
|
|
304
|
-
can override anything without editing the site.
|
|
305
|
-
|
|
306
|
-
A plugin's secret settings, such as an API key, can come from the environment
|
|
307
|
-
too, which is how a Docker site keeps a key in `.env`. The variable is the
|
|
308
|
-
plugin's package name, then `__`, then the setting's key, each upper-cased with
|
|
309
|
-
every run of other characters, underscores included, written as one `_`:
|
|
310
|
-
|
|
311
|
-
| Package | Setting | Variable |
|
|
312
|
-
| --------------------- | --------- | ----------------------------- |
|
|
313
|
-
| `@geekity/plugin-llm` | `api_key` | `GEEKITY_PLUGIN_LLM__API_KEY` |
|
|
314
|
-
| `@acme/plugin-llm` | `api_key` | `ACME_PLUGIN_LLM__API_KEY` |
|
|
315
|
-
|
|
316
|
-
A set variable wins over the value stored in
|
|
317
|
-
`data/plugins/<package name>/secrets.json`, and the plugin's screen shows the
|
|
318
|
-
setting as set by the environment, with no box to change it. The screen prints
|
|
319
|
-
each secret's exact variable name. Only plugin variables contain `__`, so none
|
|
320
|
-
can shadow a core one. Two installed packages whose names convert to the same
|
|
321
|
-
prefix, such as `@a/b-c` and `@a-b/c`, both refuse to load, and Admin > Plugins
|
|
322
|
-
names the two. A boolean environment variable
|
|
323
|
-
takes `true`, `1`, `yes` and `on`, or their opposites; anything else is an error
|
|
324
|
-
rather than a silent `false`.
|
|
325
|
-
|
|
326
|
-
## The access log
|
|
327
|
-
|
|
328
|
-
`geekity serve` writes one line per request to stdout. `createCms` does not:
|
|
329
|
-
a CMS mounted inside somebody else's server has no business writing to its
|
|
330
|
-
stdout uninvited, so an embedder asks for it with `accessLog: true` — in the
|
|
331
|
-
config, no environment variable needed:
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
const cms = createCms({ accessLog: true });
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
The line is the method, the path with its query string, the status and how
|
|
338
|
-
long the request took, in that order, separated by single spaces:
|
|
339
|
-
|
|
340
|
-
```
|
|
341
|
-
GET / 200 4.2ms
|
|
342
|
-
GET /.well-known/webfinger?resource=acct:ada@blog.example 200 1.9ms
|
|
343
|
-
GET /nothing-here 404 2.1ms
|
|
344
|
-
POST /admin/login 303 41.3ms
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
Every route is on it — `/healthz`, the federation endpoints, the admin and the
|
|
348
|
-
public site — and a request whose handler threw gets its `500` line. Nothing
|
|
349
|
-
but the request line is ever read: no body is touched, so a password posted to
|
|
350
|
-
the login form cannot reach the log, and no cookie or `Authorization` header
|
|
351
|
-
is looked at either.
|
|
352
|
-
|
|
353
|
-
With `accessLogAddress: true` the client address goes on the end, after the
|
|
354
|
-
duration. It is off by default because an address is personal data. Which
|
|
355
|
-
address is right is `trustProxy`'s answer — the leftmost `X-Forwarded-For`
|
|
356
|
-
entry behind a proxy that sets it, the socket's own address otherwise — the
|
|
357
|
-
same one the login throttle counts a failed sign-in against.
|
|
358
|
-
|
|
359
|
-
`accessLogWriter` takes the lines somewhere else, one call per line, without
|
|
360
|
-
the newline:
|
|
361
|
-
|
|
362
|
-
```ts
|
|
363
|
-
const lines: string[] = [];
|
|
364
|
-
const cms = createCms({
|
|
365
|
-
accessLog: true,
|
|
366
|
-
accessLogWriter: (line) => lines.push(line),
|
|
367
|
-
});
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
## Two directories: `content/` and `data/`
|
|
371
|
-
|
|
372
|
-
Everything a site cannot afford to lose is a file, and every one of those files
|
|
373
|
-
is in one of two directories. Nothing else needs backing up, and nothing else
|
|
374
|
-
needs carrying across a deploy.
|
|
375
|
-
|
|
376
|
-
`content/` is what the site publishes. It belongs in git, an Eleventy build of
|
|
377
|
-
the same directory reads all of it, and everything in it is meant to be public:
|
|
378
|
-
|
|
379
|
-
| Path | What it holds |
|
|
380
|
-
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
381
|
-
| `content/posts/`, `content/pages/` | The Markdown documents, `_trash/` included. |
|
|
382
|
-
| `content/uploads/` | Uploaded files, with their location and camera metadata removed. |
|
|
383
|
-
| `content/_data/site.json` | Every site setting. |
|
|
384
|
-
| `content/_data/federation/followers.json` | Who follows the site. |
|
|
385
|
-
| `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` | Every activity the inbox was handed, one per line. |
|
|
386
|
-
| `content/_data/comments/{slug}.json` | The comments left on that post, whatever a moderator has done with them. |
|
|
387
|
-
|
|
388
|
-
`data/` is private. It is gitignored, and it is the half to copy somewhere safe:
|
|
389
|
-
|
|
390
|
-
| Path | What it holds |
|
|
391
|
-
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
392
|
-
| `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
|
|
393
|
-
| `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
|
|
394
|
-
| `data/locations.json` | Where each post was written, keyed by permalink (decision-29). Mode `0600`. |
|
|
395
|
-
| `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
|
|
396
|
-
| `data/comments/{slug}.json` | The emails of that post's commenters, and whether each asked to hear about replies, keyed by comment id. Mode `0600`. |
|
|
397
|
-
| `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
|
|
398
|
-
| `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
|
|
399
|
-
| `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. |
|
|
400
|
-
| `data/comment-optouts.json` | The addresses that have unsubscribed from reply notices. Mode `0600`. |
|
|
401
|
-
| `data/indieauth-tokens.json` | A SHA-256 hash of each IndieAuth access and refresh token, with its user, app, scopes and expiry (decision-24). Mode `0600`. |
|
|
402
|
-
| `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
|
|
403
|
-
| `data/plugins/<package name>/` | Each plugin's private folder. Its secret settings are in `secrets.json`, mode `0600`; the rest is the plugin's to say. |
|
|
404
|
-
|
|
405
|
-
Three things under `data/` may be deleted whenever the site is stopped, and
|
|
406
|
-
nothing else in either directory may:
|
|
407
|
-
|
|
408
|
-
| Path | What it is |
|
|
409
|
-
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
|
410
|
-
| `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
|
|
411
|
-
| `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
|
|
412
|
-
| `data/avatars/` | Remote avatars served from `/_geekity/avatars/` (TASK-134). `rm -r` it; they are fetched again. |
|
|
413
|
-
|
|
414
|
-
The next boot builds the database out of the files with no manual step, and a
|
|
415
|
-
request for a variant that is not there derives it and serves it.
|
|
416
|
-
|
|
417
|
-
### What is in the database, and what a rebuild costs
|
|
418
|
-
|
|
419
|
-
No table holds anything that is not either read back from the files or
|
|
420
|
-
something a site is told it may lose:
|
|
421
|
-
|
|
422
|
-
| Table | Where it comes back from |
|
|
423
|
-
| --------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
424
|
-
| `documents`, `document_tags`, `document_categories` | The boot scan of `content/`. |
|
|
425
|
-
| `followers`, `ap_inbox` | `content/_data/federation/`, emptied and read back on every boot. |
|
|
426
|
-
| `comments` | `content/_data/comments/`, emptied and read back on every boot. |
|
|
427
|
-
| `sessions` | Nothing. Everybody signed in is signed out. |
|
|
428
|
-
| `ap_deliveries` | Nothing. The federation screen shows its posts with "Nothing recorded." |
|
|
429
|
-
| `ap_relays` | The relay list in `site.json`: boot sends each of them a fresh `Follow`. |
|
|
430
|
-
| `password_resets`, `spent_tokens` | Nothing. Every reset link and every one-click moderation link stops working. |
|
|
431
|
-
| `cms_state` | Nothing. The scheduler's watermark and its first run. |
|
|
432
|
-
| `migrations`, `admin_migrations` | The package. They record which schema versions have run. |
|
|
433
|
-
|
|
434
|
-
So four things are actually lost:
|
|
435
|
-
|
|
436
|
-
- **Logins.** Everybody signed in has to sign in again. The accounts themselves
|
|
437
|
-
are in `data/users.json` and are untouched.
|
|
438
|
-
- **Whether a one-click link had been used.** The links themselves are signed
|
|
439
|
-
rather than stored, so they go on working; a rebuild only forgets which of
|
|
440
|
-
them had already been spent. Every action they perform is idempotent, so the
|
|
441
|
-
worst that costs is a moderation link that works a second time. A password
|
|
442
|
-
reset link, which _is_ stored, stops working altogether.
|
|
443
|
-
- **Relay handshakes.** A relay named in the settings that the database has
|
|
444
|
-
never heard of is followed on boot, so a rebuild sends every listed relay a
|
|
445
|
-
new `Follow` and the reason one gave for rejecting the last is gone.
|
|
446
|
-
- **A scheduled post that came due while the site was down.** The scheduler
|
|
447
|
-
treats an absent watermark as "start from here", so that a rebuilt database
|
|
448
|
-
cannot re-announce the archive to every follower. Such a post is public on the
|
|
449
|
-
next boot but no `Create` is delivered for it; resend it from
|
|
450
|
-
`/admin/federation` if it should have gone out.
|
|
451
|
-
|
|
452
|
-
The one thing the file-first design gives up is narrower than any of those: a
|
|
453
|
-
post whose **file is gone entirely** can no longer be withdrawn from followers'
|
|
454
|
-
timelines, because the permalink a `Delete` names was in the file. Trashing a
|
|
455
|
-
post in the admin keeps the file under `_trash/`, so the ordinary way of
|
|
456
|
-
unpublishing still sends the `Delete`.
|
|
457
|
-
|
|
458
|
-
### A database this version will not open
|
|
459
|
-
|
|
460
|
-
Two cases refuse the boot rather than being cleaned up behind your back, and
|
|
461
|
-
both name the file and say what to do about it:
|
|
462
|
-
|
|
463
|
-
- **Written by a newer `@geekity/cms`**, meaning its migration ledger records a
|
|
464
|
-
schema version this package does not ship. Upgrading the package back is
|
|
465
|
-
usually what was meant; `geekity rebuild` throws it away if it was not.
|
|
466
|
-
- **Damaged**, meaning SQLite will not open it. `geekity rebuild` is the fix.
|
|
467
|
-
|
|
468
|
-
A database _older_ than the oldest migration the package still ships is the one
|
|
469
|
-
case thrown away and rebuilt without asking: there is by definition no path
|
|
470
|
-
forward from it, and nothing in it is anything but a reading of the files.
|
|
471
|
-
|
|
472
|
-
## Federation
|
|
473
|
-
|
|
474
|
-
**Every user is an ActivityPub actor**, served by [Fedify] at their author URL:
|
|
475
|
-
`{baseUrl}/author/{username}/` is the archive in a browser and the `Person` to
|
|
476
|
-
a peer asking for `application/activity+json`, exactly as a post's permalink is
|
|
477
|
-
its page and its object. The collections are that URL's children —
|
|
478
|
-
`inbox/`, `outbox/`, `followers/` and `following/` — and the instance-wide
|
|
479
|
-
shared inbox is `/inbox/`. `author` and `inbox` are reserved top-level paths
|
|
480
|
-
for that reason: an actor id a settings field could move would be a different
|
|
481
|
-
account to everybody holding it.
|
|
482
|
-
|
|
483
|
-
There is no site actor. A post is announced by the actor of the user its
|
|
484
|
-
`author` names, its `outbox` is that user's archive as activities, and its
|
|
485
|
-
followers are that user's own.
|
|
486
|
-
|
|
487
|
-
Fedify's stores have defaults that suit a single process, and `federation` is
|
|
488
|
-
where a site that has outgrown them says so:
|
|
489
|
-
|
|
490
|
-
| Field | Default | Meaning |
|
|
491
|
-
| --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
492
|
-
| `kv` | `MemoryKvStore` | Fedify's cache and idempotence store. Nothing that has to survive a restart lives in it. |
|
|
493
|
-
| `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. |
|
|
494
|
-
| `allowPrivateAddress` | `false` | Whether Fedify may fetch private and loopback addresses. Leave it off: turning it on removes an SSRF guard. It exists for tests. |
|
|
495
|
-
|
|
496
|
-
Followers and the log of what the inbox was told are the CMS's own, and they
|
|
497
|
-
are files the site publishes (decision-9):
|
|
498
|
-
`content/_data/federation/{username}/followers.json` holds one object per
|
|
499
|
-
follower of that user — actor id, inbox, shared inbox, handle, name, icon,
|
|
500
|
-
profile URL and follow time, oldest follow first — and
|
|
501
|
-
`content/_data/federation/inbox/{yyyy}-{mm}.jsonl` holds one compact JSON-LD
|
|
502
|
-
activity per line, with the `receivedAt` the log stamped it with and the
|
|
503
|
-
`recipient` it was addressed to in front of it. The log stays one chronological
|
|
504
|
-
record because it is a record of what this server was told; each line says
|
|
505
|
-
whose it was. Both are in git beside the posts, and an Eleventy build of the
|
|
506
|
-
same content directory sees them as `federation.{username}.followers` and
|
|
507
|
-
`federation.inbox`. A `Follow` appends to the first, an `Undo(Follow)` or an
|
|
508
|
-
actor's own `Delete` takes the entry out, and a like, a boost or a reply
|
|
509
|
-
appends a line to the second; each write updates the SQLite index in the same
|
|
510
|
-
step, behind a lock on the file, so the two can never disagree. The `followers` and `ap_inbox` tables are that
|
|
511
|
-
index and nothing more: every boot empties them and reads them back from the
|
|
512
|
-
files, which is what makes editing `followers.json` by hand and restarting a
|
|
513
|
-
supported thing to do, and what makes deleting the database cost a site
|
|
514
|
-
nothing. `cms.admin.replaceFollowers` and `replaceInboxActivities` are what the
|
|
515
|
-
rebuild calls; `rebuildFederationIndexes({ admin, contentDir })` is the rebuild
|
|
516
|
-
itself. The delivery outcomes stay in SQLite as a cache of what happened; the
|
|
517
|
-
activities themselves are not stored at all, because they are rebuilt from the
|
|
518
|
-
posts' files whenever they are wanted again.
|
|
519
|
-
|
|
520
|
-
Each user's key pairs are the CMS's own too, but they are the one
|
|
521
|
-
thing a site can never regenerate, so they live in files: one JWK per algorithm
|
|
522
|
-
per user under `dataDir/keys` (`ada.rsassa-pkcs1-v1_5.jwk` and
|
|
523
|
-
`ada.ed25519.jwk`), written `0600` in a `0700` directory, each holding the
|
|
524
|
-
private key alone because the public half is derived from it. They are
|
|
525
|
-
generated on the first request that needs them and read back on every one after
|
|
526
|
-
that. The actor publishes the RSA key as `publicKey` at `{actor}#main-key`,
|
|
527
|
-
which is what a peer verifies an HTTP Signature with, and both as
|
|
528
|
-
`assertionMethod` multikeys at `{actor}#multikey-0` and `#multikey-1`.
|
|
529
|
-
|
|
530
|
-
A key file that is there and will not import stops the boot, with a message
|
|
531
|
-
naming it. That is deliberate: replacing it would give the site a new identity
|
|
532
|
-
and every follower's cached public key would stop verifying, and Fedify's own
|
|
533
|
-
answer — an actor document with no `publicKey` at all — is worse still, because
|
|
534
|
-
peers cache it. Restore the file from a backup, or delete it to ask for a new
|
|
535
|
-
key on purpose.
|
|
536
|
-
|
|
537
|
-
### Delivery
|
|
538
|
-
|
|
539
|
-
Publishing federates. The delivery service listens to the index, so a post
|
|
540
|
-
edited on disk reaches the followers exactly as one saved through the editor
|
|
541
|
-
does:
|
|
542
|
-
|
|
543
|
-
| What happened to the post | What the followers get |
|
|
544
|
-
| ------------------------------------------------------------------------- | ------------------------- |
|
|
545
|
-
| became published — written live, or undrafted, or restored from the trash | `Create` of its object |
|
|
546
|
-
| edited while published | `Update` of its object |
|
|
547
|
-
| drafted, trashed, or its file deleted | `Delete` of a `Tombstone` |
|
|
548
|
-
|
|
549
|
-
Nothing is delivered for a full scan, the boot scan included: a rebuilt index
|
|
550
|
-
reports the whole archive as new, and announcing it again is not what deleting
|
|
551
|
-
`data/geekity.db` should mean.
|
|
552
|
-
|
|
553
|
-
A post's ActivityStreams object id is its permalink, absolute on the site's
|
|
554
|
-
base URL. One URL answers both audiences — a browser gets the page, a peer
|
|
555
|
-
asking for `application/activity+json` gets the post's object — so a shared
|
|
556
|
-
link, a feed item, an object id and a reply's `inReplyTo` all name the same
|
|
557
|
-
thing. Nothing else has to be minted or kept in step.
|
|
558
|
-
|
|
559
|
-
That object is a `Note` or an `Article`, whichever the post's discovered type
|
|
560
|
-
is: a post with no title, or whose text opens with its title, is a `Note`, and
|
|
561
|
-
any other is an `Article`. Mastodon shows the two differently. An `Article`
|
|
562
|
-
arrives as its title, its excerpt and a link, so it sends `name` and a
|
|
563
|
-
`summary`. A `Note` arrives as its `content` and nothing else, and Mastodon
|
|
564
|
-
would show a `summary` as a content warning, so a note sends neither and puts a
|
|
565
|
-
title its text does not open with at the top of its content.
|
|
566
|
-
|
|
567
|
-
Both carry their text twice: `content` as it always was, and `contentMap` keyed
|
|
568
|
-
by the post's language, with `summaryMap` beside an `Article`'s `summary`. The
|
|
569
|
-
language is the post's `lang` front matter when it names one, and the site's
|
|
570
|
-
`language` otherwise, so a client can filter and translate by it. Mastodon
|
|
571
|
-
reads a status's language from that key.
|
|
572
|
-
|
|
573
|
-
An author can choose the type for one post in its front matter:
|
|
574
|
-
|
|
575
|
-
```yaml
|
|
576
|
-
activitypub:
|
|
577
|
-
type: Note
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
`Note` and `Article` are the two values. Anything else is logged as a warning
|
|
581
|
-
and the derived type is sent instead. The CMS never rewrites the key. Changing
|
|
582
|
-
it on a post already announced sends an `Update`, but a remote server may keep
|
|
583
|
-
showing the post the way it first arrived.
|
|
584
|
-
|
|
585
|
-
The first activity about a post writes one key, `activitypub.published`, into
|
|
586
|
-
its front matter: the record that the post has been announced and when, which
|
|
587
|
-
is what decides `Create` against `Update` and what a resend reads. Restoring a
|
|
588
|
-
trashed post reuses it too.
|
|
589
|
-
|
|
590
|
-
A post whose file already names an `activitypub.id` keeps it as its object id
|
|
591
|
-
for the life of the post — that is how a post migrated from WordPress keeps the
|
|
592
|
-
`https://example.com/?p=813` its followers, its replies and its RSS subscribers
|
|
593
|
-
already hold. The CMS serves the post's object at that URL on an ActivityStreams
|
|
594
|
-
request, redirects a browser from it to the permalink, and names it in every
|
|
595
|
-
`Update` and `Delete`.
|
|
596
|
-
|
|
597
|
-
The CMS writes one itself in one case: when a published post's slug or
|
|
598
|
-
permalink is changed in the editor. A fediverse server cannot rename an object
|
|
599
|
-
it holds, so the URL the post is leaving becomes its stored `activitypub.id`.
|
|
600
|
-
Followers get an `Update` of the object they already have, a peer at the old
|
|
601
|
-
URL still gets the object, and a browser there is redirected to the new
|
|
602
|
-
permalink. The object's `url` is the new permalink. See
|
|
603
|
-
[Moved URLs](#moved-urls).
|
|
604
|
-
|
|
605
|
-
One POST serves a whole instance — the shared inbox is preferred — but the
|
|
606
|
-
outcome is recorded per recipient, so an admin can see which one did not get it
|
|
607
|
-
and send the post again:
|
|
608
|
-
|
|
609
|
-
```ts
|
|
610
|
-
const report = await cms.delivery.resend(slug);
|
|
611
|
-
report?.deliveries; // one row per follower and relay: inbox, status, error, time
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
`resend` is "send this post as it now reads", not "send that activity again":
|
|
615
|
-
the activity is built from the file at the moment it is called, so a follower
|
|
616
|
-
whose server was down ends up with the post as it stands. Which activity that
|
|
617
|
-
is follows the same table as a save — a published post nobody has been told
|
|
618
|
-
about is a `Create` and is stamped, a published post they hold is an `Update`
|
|
619
|
-
under a fresh id, a draft or a trashed one is a `Delete` of a `Tombstone` — and
|
|
620
|
-
`undefined` comes back when there is no post by that name and nothing to
|
|
621
|
-
withdraw.
|
|
622
|
-
|
|
623
|
-
Nothing else is stored. What SQLite keeps about anything the site has sent is
|
|
624
|
-
one outcome row per recipient — the activity's id, type, object id and slug,
|
|
625
|
-
the inbox used, how it went, why not, and when — and that is a cache which may
|
|
626
|
-
be empty. `cms.admin.lastDeliveryToObject(objectId)` is the newest outcome
|
|
627
|
-
about one post, and `listDeliveries(activityId)` and
|
|
628
|
-
`countDeliveriesByStatus(activityId)` say how one activity landed with each
|
|
629
|
-
recipient. A status is `sent` (the inbox took it), `queued` (handed to Fedify's
|
|
630
|
-
queue, which retries out of band) or `failed`.
|
|
631
|
-
|
|
632
|
-
The one capability given up is tombstoning a post whose file is gone entirely
|
|
633
|
-
rather than in the trash: there is no file left to build the `Tombstone` from.
|
|
634
|
-
|
|
635
|
-
### Quote posts
|
|
636
|
-
|
|
637
|
-
Mastodon 4.5 lets people quote a post only when the post says who may, and
|
|
638
|
-
shows a quote only once the quoted author's server has approved it
|
|
639
|
-
([FEP-044f]). Every post this site federates is public, so every `Note` and
|
|
640
|
-
`Article` carries `interactionPolicy.canQuote` with `automaticApproval` set to
|
|
641
|
-
the public collection: anybody may quote it, and nobody has to approve it by
|
|
642
|
-
hand. Pages, drafts, scheduled and trashed posts do not federate, so nobody
|
|
643
|
-
can quote them.
|
|
644
|
-
|
|
645
|
-
A quoting server sends a `QuoteRequest` to the post's author. The inbox, and
|
|
646
|
-
any plugin inbox that hands its deliveries to the site's own handlers, answers
|
|
647
|
-
it:
|
|
648
|
-
|
|
649
|
-
- For a post the site federates, whose quote lives on the requester's own
|
|
650
|
-
server, it stores an approval and replies `Accept` with a
|
|
651
|
-
`QuoteAuthorization` as its `result`. The authorization names the quote
|
|
652
|
-
(`interactingObject`), the post (`interactionTarget`) and the author
|
|
653
|
-
(`attributedTo`).
|
|
654
|
-
- For anything else it replies `Reject` and stores nothing.
|
|
655
|
-
|
|
656
|
-
Approvals are files, like followers:
|
|
657
|
-
`content/_data/federation/{username}/quotes.json` holds one object per quote of
|
|
658
|
-
that user's posts. Each is served unsigned, as ActivityStreams JSON, at
|
|
659
|
-
`{baseUrl}/author/{username}/quotes/{id}/`, for as long as the post is still
|
|
660
|
-
published. An `Undo` of the `QuoteRequest`, or a `Delete` of the quoting post,
|
|
661
|
-
removes the approval, and its URL answers 404 after that. A quote does not
|
|
662
|
-
notify the author.
|
|
663
|
-
|
|
664
|
-
Mastodon keeps the copy of a post it fetched first, so a post federated before
|
|
665
|
-
the quote policy existed is not quotable until its followers receive an
|
|
666
|
-
`Update`. Editing the post sends one. To update every announced post at once,
|
|
667
|
-
run `geekity resend --all` in the site directory:
|
|
668
|
-
|
|
669
|
-
```sh
|
|
670
|
-
$ geekity resend --all
|
|
671
|
-
hello-world: Update to 3 of 3 inboxes
|
|
672
|
-
another-post: Update to 3 of 3 inboxes
|
|
673
|
-
```
|
|
674
|
-
|
|
675
|
-
`geekity resend <slug>...` does the same for named posts. Each is what the
|
|
676
|
-
Resend button on the federation screen does: an `Update` for a post still
|
|
677
|
-
published, a `Delete` for one that has been withdrawn, and a `Create` for a
|
|
678
|
-
published post that was never announced. `--all` only reaches posts that were
|
|
679
|
-
announced. The command delivers with no queue, so every activity has been
|
|
680
|
-
posted before it exits, and it exits non-zero when a named slug has nothing to
|
|
681
|
-
resend. It works while the server runs, because both read the same files and
|
|
682
|
-
database.
|
|
683
|
-
|
|
684
|
-
[FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
|
685
|
-
|
|
686
|
-
### Replies to fediverse replies
|
|
687
|
-
|
|
688
|
-
Somebody who answers a fediverse reply on one of the site's posts addresses
|
|
689
|
-
the person they answer, not the site, so their reply never reaches the inbox.
|
|
690
|
-
Most servers list a note's replies in its `replies` collection, and the site
|
|
691
|
-
reads those collections to bring such replies in. The sweep starts when
|
|
692
|
-
the site serves and runs every hour, within these limits:
|
|
693
|
-
|
|
694
|
-
| Limit | Value | Meaning |
|
|
695
|
-
| -------- | -------- | ----------------------------------------------------------------------------------------------------- |
|
|
696
|
-
| Interval | 6 hours | How long a thread is left alone after it was read. |
|
|
697
|
-
| Age | 30 days | How old a post gets before its thread is no longer read, by the post's date. |
|
|
698
|
-
| Depth | 4 levels | How far below the post a fediverse reply may sit and still have its collection read. |
|
|
699
|
-
| Pages | 3 | How many pages of one collection are read. Mastodon's first page holds the author's own replies only. |
|
|
700
|
-
|
|
701
|
-
Every request is signed as the site's first account, as the actor profile
|
|
702
|
-
fetches are. A reply is kept only when it answers the note whose collection
|
|
703
|
-
listed it, is addressed to the public, and is written by an actor on the
|
|
704
|
-
note's own server. A note on the site's own origin is never fetched, because
|
|
705
|
-
the site already knows what it published.
|
|
706
|
-
|
|
707
|
-
A reply found this way is logged as the `Create` nobody delivered: a line in
|
|
708
|
-
`content/_data/federation/inbox/{yyyy}-{mm}.jsonl` with `"fetched": true` in
|
|
709
|
-
front of it, an `actor` taken from the note's `attributedTo` and the id
|
|
710
|
-
`{note id}#fetched`. From there it is a reply like any other. It is threaded
|
|
711
|
-
under the reply it answers with its own URL, counted, given a `/replies/` feed
|
|
712
|
-
and listed on the federation screen, and its author's `Delete` takes it back.
|
|
713
|
-
A note held once is never logged twice. The sweep skips a note the inbox
|
|
714
|
-
already holds or the site holds as a [migrated comment](#migrated-comments),
|
|
715
|
-
and a delivery of a note the sweep fetched first replaces the fetched line. An
|
|
716
|
-
imported fediverse reply's collection is read by its note id, its `origin`.
|
|
717
|
-
|
|
718
|
-
The site removes a fetched reply, with the fetched replies under it, when its
|
|
719
|
-
server answers 404 or 410 for it, or when a collection read to its end no
|
|
720
|
-
longer lists it. A delivered reply is never removed this way. A note with no
|
|
721
|
-
`replies` collection, a server that answers with any other error, and a
|
|
722
|
-
collection cut off by the page limit change nothing. The thread keeps what it
|
|
723
|
-
holds, and the sweep goes on to the next reply.
|
|
724
|
-
|
|
725
|
-
The site reads `replies` and not the conversation a note names in `context`
|
|
726
|
-
([FEP-7888]), because Mastodon publishes `replies` and few servers publish a
|
|
727
|
-
`context` collection yet.
|
|
728
|
-
|
|
729
|
-
[FEP-7888]: https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md
|
|
730
|
-
|
|
731
|
-
### Relays
|
|
732
|
-
|
|
733
|
-
A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
|
|
734
|
-
the instances subscribed to it, which is how a site nobody follows yet reaches
|
|
735
|
-
people. `relays` is the setting: one relay inbox per line on
|
|
736
|
-
`/admin/federation/settings`, kept in `site.json` like every other setting, and
|
|
737
|
-
`https://tags.pub/user/_____relay_____/inbox` is one worth knowing about — it
|
|
738
|
-
boosts any public post carrying a hashtag it tracks, which every `Article` this
|
|
739
|
-
CMS builds already carries one of per tag and per category.
|
|
740
|
-
|
|
741
|
-
Adding a line sends that inbox a `Follow` whose object is the ActivityStreams
|
|
742
|
-
Public collection, signed by the site's first account — a relay subscription is
|
|
743
|
-
an instance-wide agreement rather than one person's, and the first account is
|
|
744
|
-
the one actor that can be chosen without asking — and carrying the Linked Data
|
|
745
|
-
signature a Mastodon-style relay verifies. The relay answers `Accept` or
|
|
746
|
-
`Reject` — possibly days later, because a subscription may need a human to
|
|
747
|
-
approve it — and that answer arrives in the site's inbox and moves the
|
|
748
|
-
subscription. Removing the line sends `Undo` of the same `Follow`.
|
|
749
|
-
|
|
750
|
-
Only an accepted relay is delivered to. From there it is one more inbox in the
|
|
751
|
-
fan-out: every `Create`, `Update` and `Delete` for a post, whoever wrote it,
|
|
752
|
-
and the actor `Update` a profile change sends, goes to it as well as to the
|
|
753
|
-
author's followers, its
|
|
754
|
-
outcome recorded in the delivery log against its actor id and its inbox exactly
|
|
755
|
-
as a follower's is — which is why Resend reaches relays too.
|
|
756
|
-
|
|
757
|
-
```ts
|
|
758
|
-
cms.admin.listRelays(); // inbox, actor, state, reason, follow id, times
|
|
759
|
-
cms.relays.sync(); // reconcile the records with the setting
|
|
760
|
-
await cms.relays.retry(inboxId); // send the follow again
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
The records live in `ap_relays`, keyed by inbox, and are operational state
|
|
764
|
-
rather than a source: the setting is. A relay the settings name that has no
|
|
765
|
-
record — a rebuilt database, a restored content directory — is followed again
|
|
766
|
-
on the next boot.
|
|
767
|
-
|
|
768
|
-
[fepae0c]: https://w3id.org/fep/ae0c
|
|
769
|
-
|
|
770
|
-
### A user's profile
|
|
771
|
-
|
|
772
|
-
An actor's `name`, `summary`, `icon` and `attachment` are the display name, the
|
|
773
|
-
bio, the avatar and the links on their user record, edited on
|
|
774
|
-
`/admin/users/<id>` and stored in `data/users.json` — the same profile the
|
|
775
|
-
author archive is headed with, so a page and an actor cannot say different
|
|
776
|
-
things about somebody. Their `preferredUsername` is their login, and
|
|
777
|
-
`alsoKnownAs` lists every URL they answer to: the actor id, the archive and
|
|
778
|
-
`/@{username}`. The profile holds two more fields the actor does not carry — a
|
|
779
|
-
job title and a location — which are there for a theme to print beside the
|
|
780
|
-
name.
|
|
781
|
-
|
|
782
|
-
The avatar is a path like `/uploads/2026/09/me.png`, stored with the site's
|
|
783
|
-
other uploads. The actor carries it as an absolute URL resolved against the
|
|
784
|
-
base URL in effect, because a peer has no site to resolve a path against; one
|
|
785
|
-
that is already an absolute URL is left alone, which is how a profile picture
|
|
786
|
-
goes on a CDN.
|
|
787
|
-
|
|
788
|
-
Saving a profile delivers an `Update` of that user's actor to their followers,
|
|
789
|
-
so the profile a peer cached is refreshed rather than left showing last year's
|
|
790
|
-
picture. Nothing on the settings screen does: the site is not an actor.
|
|
791
|
-
`cms.delivery.updateActor(user)` sends one from code. Its outcome is recorded
|
|
792
|
-
like every other activity's, with the actor's id as its object and no slug, so
|
|
793
|
-
it is not a row in the federation screen's per-post delivery table, which is
|
|
794
|
-
built from the posts the content index holds.
|
|
795
|
-
|
|
796
|
-
### WebFinger
|
|
797
|
-
|
|
798
|
-
`/.well-known/webfinger` is the CMS's own route rather than Fedify's, because
|
|
799
|
-
Fedify computes its `self` link and its `aliases` from the dispatcher path and
|
|
800
|
-
neither can be added to. It answers for every spelling of the same person — the
|
|
801
|
-
`acct:{username}@{host}` handle, the bare `{username}@{host}`, the author URL,
|
|
802
|
-
`/@{username}` and the stored actor id of somebody who has one — with the actor
|
|
803
|
-
id as `self`, the archive as `profile-page`, and all of the person's URLs as
|
|
804
|
-
`aliases`. A username nobody has is a 404. `/@{username}` itself is a 301 to
|
|
805
|
-
the archive of the user of exactly that name, and a 404 for a handle nobody
|
|
806
|
-
answers to: it is an alias of somebody, so a handle that is not anybody's is
|
|
807
|
-
nowhere rather than a redirect to an archive that does not exist either.
|
|
808
|
-
|
|
809
|
-
### A user's stored actor id
|
|
810
|
-
|
|
811
|
-
A user record in `data/users.json` may carry an `actorId`: the ActivityStreams
|
|
812
|
-
id that person was published under somewhere else, such as the
|
|
813
|
-
`https://example.com/?author=2` the WordPress ActivityPub plugin publishes.
|
|
814
|
-
That is identity, not cache — a follower's server keys the account by the URL
|
|
815
|
-
it first saw — so the CMS serves the person under it for the life of the
|
|
816
|
-
account, the way a post keeps its `activitypub.id`:
|
|
817
|
-
|
|
818
|
-
```json
|
|
819
|
-
{ "id": 2, "username": "ada", "actorId": "https://example.com/?author=2" }
|
|
820
|
-
```
|
|
821
|
-
|
|
822
|
-
The actor document's `id` is then that URL and its keys hang off it
|
|
823
|
-
(`…?author=2#main-key`), every activity the user sends names it as `actor` and
|
|
824
|
-
is signed with a key id under it, and the URL itself — query string and all —
|
|
825
|
-
answers with the `Person` for a peer and a 301 to the author archive for a
|
|
826
|
-
browser. WebFinger publishes it as `self` and resolves a lookup by it, and the
|
|
827
|
-
actor lists it in `alsoKnownAs` beside the archive and `/@{username}`. Nothing
|
|
828
|
-
else moves: `url` is still the archive and the collections are still the
|
|
829
|
-
archive's children, because a peer refetches those.
|
|
830
|
-
|
|
831
|
-
The CMS never mints one and no screen writes one. It arrives with a plugin's
|
|
832
|
-
import, such as the WordPress plugin's, or is typed into the file by hand; `/admin/users/<id>` shows it
|
|
833
|
-
read-only beside the account, and a value that is not an absolute URL is
|
|
834
|
-
ignored.
|
|
835
|
-
|
|
836
|
-
### Moving a site from the WordPress ActivityPub plugin
|
|
837
|
-
|
|
838
|
-
A site that moved here from the WordPress ActivityPub plugin keeps its people's
|
|
839
|
-
actor ids and its posts' object ids, which are identity and are the CMS's own
|
|
840
|
-
(above). What it needs besides is its content, the plugin's old paths, which
|
|
841
|
-
followers' servers keep delivering to until they refetch the actor, and an
|
|
842
|
-
import that brings each person's key pair and followers across. All three are the
|
|
843
|
-
[`@geekity/plugin-wordpress`](../plugin-wordpress/README.md) package, which a
|
|
844
|
-
site born on the CMS never installs. decision-14's switch is that plugin now:
|
|
845
|
-
where the cutover used to say "turn the switch on", it says install and enable
|
|
846
|
-
the WordPress plugin.
|
|
847
|
-
|
|
848
|
-
#### The cutover, end to end
|
|
849
|
-
|
|
850
|
-
1. **Start the new site in dev mode.** Set `GEEKITY_DEV_MODE=true` before
|
|
851
|
-
its first boot (see [Dev mode](#dev-mode)). Followers, relays, links and
|
|
852
|
-
mail are all real from here on, and dev mode keeps every one of them from
|
|
853
|
-
hearing about the import, the edits or a scheduled post until step 4.
|
|
854
|
-
2. **Export, while the WordPress site is still up.** Each person's key pair,
|
|
855
|
-
and the followers if the old site is going away before the import runs. The
|
|
856
|
-
plugin's README says how. Note the actor id and the numeric actor id off
|
|
857
|
-
`https://example.com/?author=2`, take a WXR export under Tools > Export,
|
|
858
|
-
and copy `wp-content/uploads`, which the export does not carry.
|
|
859
|
-
3. **Install the WordPress plugin and import.** Add `@geekity/plugin-wordpress`
|
|
860
|
-
to the site's dependencies and to `plugins` in its config, then run
|
|
861
|
-
`geekity import wordpress example.WordPress.xml --uploads wp-content/uploads`
|
|
862
|
-
for the content and media, and
|
|
863
|
-
`geekity import wordpress-actor ada --actor-id … --wordpress-id … --keypair ada.keypair.json`
|
|
864
|
-
for each person. Both work before the plugin is enabled. Check the reports:
|
|
865
|
-
every post should be written, every conflict or clash resolved, every
|
|
866
|
-
follower added, and any follower that was skipped re-run once its server
|
|
867
|
-
answers. Tools > Dev mode shows that the imports sent nothing. Both imports
|
|
868
|
-
are safe to run again: run them over a fresh export just before step 4 to
|
|
869
|
-
pick up what WordPress received in the meantime.
|
|
870
|
-
4. **Enable the WordPress plugin, move the DNS and go live.** Enable it under
|
|
871
|
-
`/admin/plugins`, then move the DNS. Remove `GEEKITY_DEV_MODE` and run
|
|
872
|
-
`geekity dev-mode off`: nothing held during the migration is sent.
|
|
873
|
-
Followers' servers go on delivering to the plugin's old inbox paths until
|
|
874
|
-
they next refetch the actor, and the plugin is what catches those
|
|
875
|
-
deliveries. Once WordPress no longer answers, run both imports one last time
|
|
876
|
-
over its final export: they merge what arrived between the export and the
|
|
877
|
-
DNS move, and leave alone whatever this site wrote or edited since.
|
|
878
|
-
5. **Watch.** `/admin/federation` lists the users with their actor ids and
|
|
879
|
-
followers; the plugin's own screen under Plugins lists each old path with
|
|
880
|
-
the instant it was last asked for. Deliveries should thin out as each
|
|
881
|
-
follower's server refetches the actor and learns the new endpoints.
|
|
882
|
-
6. **Disable it.** Once every path says _Never_ again for long enough — weeks
|
|
883
|
-
rather than days, since a quiet instance refetches rarely — disable the
|
|
884
|
-
plugin. Disabling it also ends the redirects from the old
|
|
885
|
-
`/wp-content/uploads/` media URLs, so a site that still gets links to
|
|
886
|
-
those keeps it enabled. The paths go away on the very next request, with nothing restarted,
|
|
887
|
-
and if something was still using them you can enable it again just as
|
|
888
|
-
quickly.
|
|
889
|
-
|
|
890
|
-
Retiring the stored actor id itself is a separate, optional step by the Move
|
|
891
|
-
protocol, and is not part of the cutover: not every follower's software honours
|
|
892
|
-
a `Move`, and the ones that do not would simply stop following.
|
|
893
|
-
|
|
894
|
-
### The federation screen
|
|
895
|
-
|
|
896
|
-
`/admin/federation` is all of that with a page in front of it. It shows one
|
|
897
|
-
panel per user — their avatar, their `@username@host` handle, the summary their
|
|
898
|
-
profile carries, their actor id, and the followers each of them has with
|
|
899
|
-
avatars and follow dates — the recent likes, boosts and replies out of the
|
|
900
|
-
inbox log, each linked to the remote object and to the post it was about, and
|
|
901
|
-
one row per post that has been federated: who announced it, its last activity,
|
|
902
|
-
when it went, and how many recipients it reached, is queued for, or failed
|
|
903
|
-
for. Each of those rows has a
|
|
904
|
-
Resend button, which is `cms.delivery.resend` behind a form: it builds the post
|
|
905
|
-
again from its file, sends it to every follower and every accepted relay the
|
|
906
|
-
site has now, and says what came of it.
|
|
907
|
-
|
|
908
|
-
Which posts are listed is a question for the files — the posts carrying an
|
|
909
|
-
`activitypub.published`, the trash included, which is the same thing as the
|
|
910
|
-
posts some follower holds a copy of — and how each landed is a question for the outcome
|
|
911
|
-
cache. So a site that has just deleted `data/geekity.db` sees every federated
|
|
912
|
-
post listed with nothing recorded against it, and can press Resend on any of
|
|
913
|
-
them.
|
|
914
|
-
|
|
915
|
-
A Relays panel sits between the followers and the inbox log: one row per
|
|
916
|
-
subscription with its inbox, whether it is waiting, accepted or rejected, when
|
|
917
|
-
it last moved, why it was refused if it was, and the last activity delivered
|
|
918
|
-
there with how that went. A subscription still waiting has a Retry, which sends
|
|
919
|
-
the `Follow` again under a fresh id.
|
|
920
|
-
|
|
921
|
-
### Testing federation against a real Mastodon account
|
|
922
|
-
|
|
923
|
-
Federation is the one part of the CMS that cannot be finished on localhost.
|
|
924
|
-
Mastodon has to be able to reach the site to fetch the actor, verify a
|
|
925
|
-
signature and deliver a `Follow`, and `http://localhost:3000` is not an
|
|
926
|
-
address it can reach. [`fedify tunnel`] gives the local server a public HTTPS
|
|
927
|
-
address for as long as the command runs, which is enough to do the whole round
|
|
928
|
-
trip by hand:
|
|
929
|
-
|
|
930
|
-
```sh
|
|
931
|
-
pnpm dlx @fedify/cli tunnel 3000
|
|
932
|
-
# The ephemeral public address is up and running:
|
|
933
|
-
# https://quiet-sun-42.serveo.net
|
|
934
|
-
```
|
|
935
|
-
|
|
936
|
-
Leave that running and boot the site with `baseUrl` set to the address it
|
|
937
|
-
printed, because `baseUrl` is what every ActivityStreams id is minted from — a
|
|
938
|
-
site booted on `http://localhost:3000` serves actors whose ids are
|
|
939
|
-
`http://localhost:3000/author/{username}/`, and no remote instance can
|
|
940
|
-
dereference those:
|
|
941
|
-
|
|
942
|
-
```sh
|
|
943
|
-
GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
|
|
944
|
-
```
|
|
945
|
-
|
|
946
|
-
Then, from a Mastodon account:
|
|
947
|
-
|
|
948
|
-
1. Search for `@ada@quiet-sun-42.serveo.net` — the handle is a username at the
|
|
949
|
-
tunnel's host. The profile should come back with that user's display name,
|
|
950
|
-
bio and avatar.
|
|
951
|
-
2. Follow it. The `Follow` lands on `/author/ada/inbox/` (or on `/inbox/`), the
|
|
952
|
-
CMS answers `Accept`, and the account appears on `/admin/federation` within
|
|
953
|
-
a second or two.
|
|
954
|
-
3. Publish a post, from the editor or by writing a file into `content/posts/`.
|
|
955
|
-
A `Create` is delivered, and the post shows up in the follower's
|
|
956
|
-
home timeline; `/admin/federation` records the outcome, with a Resend
|
|
957
|
-
button if it did not land.
|
|
958
|
-
4. Edit the post, then set `draft: true` on it, to see the `Update` and the
|
|
959
|
-
`Delete` arrive.
|
|
960
|
-
|
|
961
|
-
Two things to know before relying on what a tunnel run shows you.
|
|
962
|
-
|
|
963
|
-
The tunnel address changes every time the command starts, and the followers
|
|
964
|
-
gathered under one are throwaway: their instances hold an actor id on a host
|
|
965
|
-
that no longer exists, so they cannot be migrated to the real domain. The
|
|
966
|
-
admin has no button for removing a follower yet, so run tunnel rehearsals
|
|
967
|
-
against a scratch `dataDir` rather than the one the real site will keep.
|
|
968
|
-
|
|
969
|
-
The key pairs live in `dataDir/keys`, not in the base URL, so they
|
|
970
|
-
survive across tunnel sessions. That is usually what you want; it also means a
|
|
971
|
-
`data` directory copied from one deployment to another brings the other's
|
|
972
|
-
identity with it.
|
|
973
|
-
|
|
974
|
-
`pnpm fed:smoke`, in this repository, is the automated half of the same idea:
|
|
975
|
-
it boots the CMS on a free port, runs `fedify lookup` against a user's actor
|
|
976
|
-
and a post object, reads WebFinger, and delivers a `Create(Article)` to two real inboxes over
|
|
977
|
-
loopback. It needs no tunnel and no network, and it is a job in CI. See
|
|
978
|
-
`packages/cms/scripts/fed-smoke.ts`, whose header explains why the `Follow`
|
|
979
|
-
half is sent by a peer built in the script rather than by `fedify inbox`.
|
|
980
|
-
|
|
981
|
-
[Fedify]: https://fedify.dev/
|
|
982
|
-
[`fedify tunnel`]: https://fedify.dev/cli
|
|
983
|
-
|
|
984
|
-
## Dev mode
|
|
985
|
-
|
|
986
|
-
Building or migrating a site means running it with production data: the real
|
|
987
|
-
base URL, the real actor and keys, real followers, real links. Dev mode keeps
|
|
988
|
-
that site quiet until you take it live (decision-35). While it is on, nothing
|
|
989
|
-
the site does reaches the outside world:
|
|
990
|
-
|
|
991
|
-
- No ActivityPub delivery to followers or relays, no relay `Follow` or `Undo`,
|
|
992
|
-
and no `Accept` or `Reject` answering an inbox. This holds whatever caused
|
|
993
|
-
the change: the watcher, the admin, the scheduler, `geekity resend` or a
|
|
994
|
-
reply context arriving.
|
|
995
|
-
- No webmention. Endpoint discovery is skipped too.
|
|
996
|
-
- No rssCloud or WebSub ping.
|
|
997
|
-
- No IndexNow submission.
|
|
998
|
-
- No email.
|
|
999
|
-
|
|
1000
|
-
Reads still go out: reply contexts and cited pages, actor profiles, oEmbed,
|
|
1001
|
-
avatars, the check of an incoming webmention, and a plugin's `host.fetch`. A
|
|
1002
|
-
read tells nobody about the site, and without them it could not show its
|
|
1003
|
-
replies or profiles. Inbound federation is accepted: follows, replies, likes
|
|
1004
|
-
and webmentions are stored as on a live site, and the `Accept` a follow would
|
|
1005
|
-
get is held. Plugins need no switch of their own. A plugin's federation hands
|
|
1006
|
-
what it receives to the site's inbox, so its answers are held the same way.
|
|
1007
|
-
|
|
1008
|
-
Each held side effect is a line in `data/dev-mode.jsonl`: when, what kind,
|
|
1009
|
-
what would have been sent, and to whom. Tools > Dev mode in the admin lists
|
|
1010
|
-
them, newest first. Every admin screen shows a banner while the mode is on,
|
|
1011
|
-
and `geekity serve` says so when it boots.
|
|
1012
|
-
|
|
1013
|
-
```sh
|
|
1014
|
-
$ geekity dev-mode on
|
|
1015
|
-
Dev mode is on: nothing the site does is sent until `geekity dev-mode off`.
|
|
1016
|
-
$ geekity dev-mode status
|
|
1017
|
-
Dev mode is on.
|
|
1018
|
-
$ geekity dev-mode off
|
|
1019
|
-
Dev mode is off: the site is live. Nothing held while it was on will be sent.
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
`devMode: true` in the config, or `GEEKITY_DEV_MODE=true`, starts a site in
|
|
1023
|
-
dev mode: boot writes `data/dev-mode.json`, and the file keeps the mode on
|
|
1024
|
-
from then on. Losing the variable does not take the site live. Only
|
|
1025
|
-
`geekity dev-mode off` does, and it refuses while the config or the
|
|
1026
|
-
environment still asks for dev mode, because the next boot would turn it on
|
|
1027
|
-
again. The running site checks the file before every send, so going live
|
|
1028
|
-
needs no restart, unless the server was started with the variable: that
|
|
1029
|
-
process stays in dev mode until it restarts without it. Going live is a line
|
|
1030
|
-
in the record too.
|
|
1031
|
-
|
|
1032
|
-
Going live replays nothing. A held side effect is dropped, not queued, and no
|
|
1033
|
-
delivery or webmention is recorded as failed, so neither the Federation screen
|
|
1034
|
-
nor `geekity resend` offers it again. A post announced while the mode was on
|
|
1035
|
-
counts as announced, so its next edit goes out as an `Update`. A relay listed
|
|
1036
|
-
while the mode was on stays pending: press Retry on the Federation screen once
|
|
1037
|
-
the site is live.
|
|
1038
|
-
|
|
1039
|
-
Password recovery mail is held too, so in dev mode a password is reset from
|
|
1040
|
-
the shell. A peer that followed while the mode was on never got its `Accept`,
|
|
1041
|
-
and has to follow again.
|
|
1042
|
-
|
|
1043
|
-
### Dev mode on Docker
|
|
1044
|
-
|
|
1045
|
-
Set the variable in the service's environment before the first boot, so the
|
|
1046
|
-
container never starts live:
|
|
1047
|
-
|
|
1048
|
-
```yaml
|
|
1049
|
-
services:
|
|
1050
|
-
geekity:
|
|
1051
|
-
environment:
|
|
1052
|
-
GEEKITY_DEV_MODE: 'true'
|
|
1053
|
-
```
|
|
1054
|
-
|
|
1055
|
-
To go live, remove the variable, recreate the container (the file in the
|
|
1056
|
-
`data` volume keeps the mode on through the restart), then run:
|
|
1057
|
-
|
|
1058
|
-
```sh
|
|
1059
|
-
docker compose exec geekity geekity dev-mode off
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
`geekity dev-mode status` in the same container says which state the site is
|
|
1063
|
-
in. The record is in the `data` volume at `data/dev-mode.jsonl`.
|
|
1064
|
-
|
|
1065
|
-
## Comments
|
|
1066
|
-
|
|
1067
|
-
A reader can answer a post on the page, and what they leave joins the same
|
|
1068
|
-
thread as the fediverse replies rather than sitting in a section of its own.
|
|
1069
|
-
|
|
1070
|
-
A comment is a file. `content/_data/comments/{slug}.json` holds one post's
|
|
1071
|
-
comments (a slug that is not plain ASCII is percent-encoded in the name, so
|
|
1072
|
-
`i-♥-rss` is `i-%E2%99%A5-rss.json`) — id, source, kind, status, author, the Markdown and the HTML it
|
|
1073
|
-
rendered to, when it was submitted, a salted hash of the address, and what it
|
|
1074
|
-
answers — so they are in git beside the posts, an Eleventy build of the same
|
|
1075
|
-
directory shows them, and the `comments` table is an index emptied and read
|
|
1076
|
-
back on every boot. The commenter's email is not in that file: it is in
|
|
1077
|
-
`data/comments/{slug}.json` (mode `0600`), keyed by comment id, because
|
|
1078
|
-
everything under `content/` is published and goes into git. A site upgrading
|
|
1079
|
-
from a version that kept the email in the comment file has it moved there on
|
|
1080
|
-
the next boot, and so does an email somebody types into a comment file by
|
|
1081
|
-
hand.
|
|
1082
|
-
|
|
1083
|
-
### Keys
|
|
1084
|
-
|
|
1085
|
-
Every comment has one key, 16 lowercase hex digits (decision-51). It is the
|
|
1086
|
-
comment's `id` in its file, its anchor `#comment-{key}` on the post, its
|
|
1087
|
-
replies feed `/replies/{key}/` and, for a comment left through the form, its
|
|
1088
|
-
page `/comment/{key}/`. No URL the site prints carries an id of any other shape.
|
|
1089
|
-
|
|
1090
|
-
| Comment | Its key |
|
|
1091
|
-
| ----------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
1092
|
-
| Left through the form | 16 random hex digits, minted when it is submitted. |
|
|
1093
|
-
| A webmention | The first 16 hex digits of the SHA-256 of its source URL, a space and the post's permalink. |
|
|
1094
|
-
| A fediverse reply, like or boost | The first 16 hex digits of the SHA-256 of its note or activity id. |
|
|
1095
|
-
| Imported, with an identity elsewhere (`origin`) | The key the same comment would get arriving here, from its `origin`. |
|
|
1096
|
-
| Imported, with none (a WordPress form comment) | The first 16 hex digits of the SHA-256 of its old comment URL. |
|
|
1097
|
-
|
|
1098
|
-
A comment file holding any other id is refused, naming the file and the id. A
|
|
1099
|
-
site upgrading from 0.29 has its UUID ids, and the URL ids plugin-wordpress
|
|
1100
|
-
0.2.0 wrote, re-keyed in place on the next boot to the key of the old id; the
|
|
1101
|
-
replies naming them, the emails in `data/comments/` and any Markdown linking
|
|
1102
|
-
`/comment/{old}/` or `#comment-{old}` follow. Nothing redirects from the old
|
|
1103
|
-
URLs. A site whose ids are all keys reads its files and writes nothing.
|
|
1104
|
-
|
|
1105
|
-
### Migrated comments
|
|
1106
|
-
|
|
1107
|
-
A comment imported from another system carries `migrated: true`, and `origin`
|
|
1108
|
-
when it had an identity elsewhere: a fediverse reply's note id, a like's or a
|
|
1109
|
-
boost's activity id, a webmention's source URL. A migrated fediverse comment
|
|
1110
|
-
also carries `actor`, the id of the actor who wrote it, when the export named
|
|
1111
|
-
one. plugin-wordpress writes all three.
|
|
1112
|
-
|
|
1113
|
-
A migrated comment shows on its post, in its thread and on its own page like
|
|
1114
|
-
any other, and replies to it thread under it. It is in no feed and no count a
|
|
1115
|
-
feed advertises: not `/comments/feed/`, not `{permalink}feed/`, not
|
|
1116
|
-
`/replies/{key}/` and not `source:comments`. It was published before the site
|
|
1117
|
-
moved here, so a reader of the old comments feed sees nothing new at cutover. A
|
|
1118
|
-
native reply under it is news, and names it in `source:inReplyTo` by its guid:
|
|
1119
|
-
its `/comment/{key}/` page for an imported form comment, its `origin`
|
|
1120
|
-
otherwise, else its `url`.
|
|
1121
|
-
|
|
1122
|
-
The same comment arriving again is the one the site holds, and the stored one
|
|
1123
|
-
wins, with the moderator's decision on it. A resent webmention is matched by
|
|
1124
|
-
its source. A redelivered `Create` or `Like`, and a note the
|
|
1125
|
-
[reply backfill](#replies-to-fediverse-replies) reads, are matched by their
|
|
1126
|
-
note or activity id against the stored comment's `origin`, so the inbox line is
|
|
1127
|
-
not shown and the backfill does not log the note. A like or a boost from the
|
|
1128
|
-
same actor as an imported one, matched by the inbox line's actor against the
|
|
1129
|
-
stored comment's `actor`, is the same reaction.
|
|
1130
|
-
An answer to an imported fediverse note threads under the imported comment,
|
|
1131
|
-
and the backfill reads that note's `replies` collection by its `origin`.
|
|
1132
|
-
|
|
1133
|
-
Three settings and one front-matter key decide whether a post is still taking
|
|
1134
|
-
them, and they are read in one place so the form and the endpoint can never
|
|
1135
|
-
disagree:
|
|
1136
|
-
|
|
1137
|
-
| Where | What it says |
|
|
1138
|
-
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
1139
|
-
| `comments` in `site.json` | Whether the site takes comments at all. On by default; off is off everywhere. |
|
|
1140
|
-
| `commentsCloseAfterDays` in `site.json` | Days after a post's `date` that it closes. `14` by default; `0` never closes. |
|
|
1141
|
-
| `comments: true` / `false` in front matter | The post's own answer, which beats both, in both directions. The editor offers it. |
|
|
1142
|
-
| The document's type | A page is closed unless it says otherwise. |
|
|
1143
|
-
|
|
1144
|
-
None of it touches the fediverse: a reply, a like or a boost arrives because a
|
|
1145
|
-
remote server sent it, and a closed post shows every one of them.
|
|
1146
|
-
|
|
1147
|
-
A comment is held for a moderator unless the same **name and email** have had
|
|
1148
|
-
one approved before — WordPress's rule. In front of that are a honeypot field,
|
|
1149
|
-
a minimum time between the form being rendered and submitted, and a per-address
|
|
1150
|
-
rate limit. Comments are rendered from Markdown with raw HTML off, images
|
|
1151
|
-
unembedded and every link marked `rel="nofollow ugc"`.
|
|
1152
|
-
|
|
1153
|
-
A site can hand the whole thing to a service:
|
|
1154
|
-
|
|
1155
|
-
```ts
|
|
1156
|
-
export default defineConfig({
|
|
1157
|
-
commentChecker: {
|
|
1158
|
-
async check(submission) {
|
|
1159
|
-
// submission carries the comment, the post's URL, and the commenter's
|
|
1160
|
-
// address, user agent and referrer — everything Akismet asks for.
|
|
1161
|
-
return 'spam' | 'discard' | 'ham' | 'unknown';
|
|
1162
|
-
},
|
|
1163
|
-
async reportSpam(report) {}, // a moderator filed something as spam
|
|
1164
|
-
async reportHam(report) {}, // …or let something out of the spam list
|
|
1165
|
-
},
|
|
1166
|
-
});
|
|
1167
|
-
```
|
|
1168
|
-
|
|
1169
|
-
A checker that throws is treated as having no opinion and logged, so a service
|
|
1170
|
-
that is down never stops a site taking comments. `/admin/comments` is the
|
|
1171
|
-
moderation queue and the dashboard carries the number waiting.
|
|
1172
|
-
|
|
1173
|
-
### A comment's own page
|
|
1174
|
-
|
|
1175
|
-
A comment left through the form has a page of its own at `/comment/{key}/`
|
|
1176
|
-
(see [Keys](#keys)). That page is the comment's URL in the thread
|
|
1177
|
-
and in both comments feeds, so a site that replies to the comment fetches the
|
|
1178
|
-
comment rather than the whole post. The `#comment-{key}` anchor on the post
|
|
1179
|
-
stays, so an old link still scrolls to it. A webmention or a fediverse reply
|
|
1180
|
-
has no such page: its URL is where it came from.
|
|
1181
|
-
|
|
1182
|
-
The page holds the comment as its one `h-entry`, with `u-in-reply-to` the post
|
|
1183
|
-
or the comment it answers. Above it is the thread from the post down, each step
|
|
1184
|
-
a nested `u-in-reply-to h-cite`, and below it the replies to it at every depth.
|
|
1185
|
-
An ancestor a reader may not see is a placeholder saying the comment is no
|
|
1186
|
-
longer shown, and the thread on the post prints the same placeholder above an
|
|
1187
|
-
approved reply to a pending, spam or deleted comment. The page answers only an approved comment, and only as the post
|
|
1188
|
-
would: 410 once the post is deleted, 404 while the post is not public or not
|
|
1189
|
-
showing its conversation. It is `noindex` and absent from the sitemap, the post
|
|
1190
|
-
feeds and search. Backlog doc-6 has the rules.
|
|
1191
|
-
|
|
1192
|
-
### Replying while signed in
|
|
1193
|
-
|
|
1194
|
-
A comment is something somebody else says on the site. A post is something a
|
|
1195
|
-
signed-in user says. So a reply written by somebody signed in to the site, in
|
|
1196
|
-
the thread or in the Reply box on the moderation screen, is a reply post, not a
|
|
1197
|
-
comment. It is one file under `posts/`, saved
|
|
1198
|
-
the way the editor and Micropub save one, and the thread shows it inline under
|
|
1199
|
-
what it answers. Its `in-reply-to` is what it answers:
|
|
1200
|
-
|
|
1201
|
-
| Answering | `in-reply-to` | It also |
|
|
1202
|
-
| ------------------ | --------------------------------- | -------------------------------------------------------- |
|
|
1203
|
-
| The post | The post's permalink | |
|
|
1204
|
-
| A native comment | The comment's `/comment/{key}/` | Emails the commenter once, if they asked about replies. |
|
|
1205
|
-
| A webmention reply | The page the webmention came from | Sends that page a webmention. |
|
|
1206
|
-
| A fediverse reply | The note's id | Federates in reply to the note, addressed to its author. |
|
|
1207
|
-
| A reply post | The reply post's permalink | |
|
|
1208
|
-
|
|
1209
|
-
The signed-in form and the moderation screen's Reply box have an "Include in
|
|
1210
|
-
posts and feeds" checkbox, unchecked by default. Unchecked, the reply post is [unlisted](#unlisted-posts): its own page
|
|
1211
|
-
and the thread, but no listing or post feed. Checked, it is public. It reaches
|
|
1212
|
-
whoever it answers either way. A visitor's form has no such box.
|
|
1213
|
-
|
|
1214
|
-
Somebody signed in gets a Reply link on every reply in a thread, webmentions
|
|
1215
|
-
and fediverse replies included. A visitor gets one on native comments only. A
|
|
1216
|
-
visitor's reply is always a native comment, so a visitor answering a fediverse
|
|
1217
|
-
reply sends nothing over ActivityPub.
|
|
1218
|
-
|
|
1219
|
-
A reply post written in the editor or over Micropub joins a thread the same way
|
|
1220
|
-
when its `in-reply-to` names the post or anything in its thread: a comment's
|
|
1221
|
-
page or `#comment-` anchor, a webmention's page, a note's id. Its reply context
|
|
1222
|
-
quotes that comment from the site's own records instead of the page it is on,
|
|
1223
|
-
and says "In reply to a comment by" its writer. A reply post answering one of
|
|
1224
|
-
the site's own posts or pages takes that post's title, words and author from
|
|
1225
|
-
the index too, so the site never fetches its own page. The commenter's email
|
|
1226
|
-
goes once for a reply post from any of these doors, sent when the reply post is
|
|
1227
|
-
first published and never again when it is edited, trashed and restored, or the
|
|
1228
|
-
site restarts. In the thread, a reply post's author link carries no
|
|
1229
|
-
`rel="nofollow ugc"`, which is kept for visitors, webmentions and the
|
|
1230
|
-
fediverse. A
|
|
1231
|
-
webmention or a fediverse reply to a reply post threads under it in the
|
|
1232
|
-
original thread too. Comments written before this stay comments. Backlog doc-6
|
|
1233
|
-
and decision-47 have the rules.
|
|
1234
|
-
|
|
1235
|
-
### Being told about one
|
|
38
|
+
Database migrations ship inside the package and run on boot. To upgrade, bump
|
|
39
|
+
the version. [Upgrading](https://github.com/geekitycom/cms/blob/main/docs/getting-started.md#upgrading)
|
|
40
|
+
lists what semver covers.
|
|
1236
41
|
|
|
1237
|
-
|
|
1238
|
-
user who has an address and has not switched the notice off on their
|
|
1239
|
-
`/admin/users/<id>` screen. The message carries the comment, the post it is on,
|
|
1240
|
-
and three links — approve, spam, delete — that work without a login. Nothing is
|
|
1241
|
-
sent about a comment Akismet filed as spam or told the site to discard: only
|
|
1242
|
-
what is actually waiting for a person.
|
|
42
|
+
## Documentation
|
|
1243
43
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
fetch the URLs in a message as a matter of course, and a link that moderated on
|
|
1247
|
-
being opened would mean a mail gateway silently deleting this site's comments.
|
|
1248
|
-
A link is signed rather than stored — the secret is `data/notification-secret`,
|
|
1249
|
-
so a link goes on working across a `geekity rebuild` — is bound to one action
|
|
1250
|
-
on one comment, lasts a week, and is spent the first time it is used.
|
|
44
|
+
The guides live in the repository's
|
|
45
|
+
[`docs/`](https://github.com/geekitycom/cms/blob/main/docs/README.md) folder:
|
|
1251
46
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
47
|
+
- [Getting started](https://github.com/geekitycom/cms/blob/main/docs/getting-started.md)
|
|
48
|
+
- [The `geekity` command](https://github.com/geekitycom/cms/blob/main/docs/cli.md)
|
|
49
|
+
- [Configuration](https://github.com/geekitycom/cms/blob/main/docs/configuration.md)
|
|
50
|
+
- [Content](https://github.com/geekitycom/cms/blob/main/docs/content.md)
|
|
51
|
+
- [The admin](https://github.com/geekitycom/cms/blob/main/docs/admin.md)
|
|
52
|
+
- [The public site](https://github.com/geekitycom/cms/blob/main/docs/public-site.md)
|
|
53
|
+
- [Federation](https://github.com/geekitycom/cms/blob/main/docs/federation.md)
|
|
54
|
+
- [Feeds](https://github.com/geekitycom/cms/blob/main/docs/feeds.md)
|
|
55
|
+
- [Plugins and hooks](https://github.com/geekitycom/cms/blob/main/docs/plugins.md)
|
|
56
|
+
- [Themes](https://github.com/geekitycom/cms/blob/main/docs/themes.md)
|
|
57
|
+
- [Deploying](https://github.com/geekitycom/cms/blob/main/docs/deploying.md)
|
|
58
|
+
- [Building the same content with Eleventy](https://github.com/geekitycom/cms/blob/main/docs/eleventy.md)
|
|
1261
59
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
Beside each switch on a user's `/admin/users/<id>` screen is how often that
|
|
1265
|
-
notice should arrive: as they arrive, an hourly digest, or a daily one. It is
|
|
1266
|
-
per user, and it starts as "as they arrive", so nothing changes for anybody who
|
|
1267
|
-
does not touch it.
|
|
1268
|
-
|
|
1269
|
-
On a digest, a comment entering the queue sends nothing at all. Instead, once a
|
|
1270
|
-
window has passed, one message goes out listing everything **still waiting**,
|
|
1271
|
-
each item with its own approve, spam and delete links. A digest is built from
|
|
1272
|
-
the queue as it stands rather than from a list of things that happened, so an
|
|
1273
|
-
item somebody moderated in the meantime is simply not in it, and there is no
|
|
1274
|
-
queue to keep true across a restart. A user with nothing waiting is not written
|
|
1275
|
-
to at all, and their window does not start until something is: the promise a
|
|
1276
|
-
mode makes is at most one message per window, and a quiet site sends none.
|
|
1277
|
-
|
|
1278
|
-
A very long queue — a spam wave Akismet let through — is listed up to a hundred
|
|
1279
|
-
items, with the rest counted in a line pointing at `/admin/comments`.
|
|
1280
|
-
|
|
1281
|
-
The only thing written down is when each user last had one, in
|
|
1282
|
-
`data/notification-digests.json`. It is a file rather than a row because
|
|
1283
|
-
decision-9 lets a site delete the database whenever it is stopped, and a
|
|
1284
|
-
forgotten timestamp would mean either a window silently skipped or a digest
|
|
1285
|
-
sent twice. It is its own file rather than a field in `data/users.json` because
|
|
1286
|
-
that file answers "who may sign in", and a timestamp rewritten every hour is
|
|
1287
|
-
bookkeeping rather than anything about the person.
|
|
1288
|
-
|
|
1289
|
-
Reply notices to commenters are not affected: those are one message about one
|
|
1290
|
-
reply, and they go when the reply is approved.
|
|
1291
|
-
|
|
1292
|
-
With no mail configured, none of this happens and nothing breaks: the box is
|
|
1293
|
-
not offered, no notice is sent, no digest runs, and the moderation screen is
|
|
1294
|
-
the notification it was before.
|
|
1295
|
-
|
|
1296
|
-
Notices are per user and keyed by event name, so a later one — a new follower,
|
|
1297
|
-
a digest of failed deliveries — is one entry in the registry in
|
|
1298
|
-
`src/notifications/preferences.ts` and a checkbox that appears by itself; an
|
|
1299
|
-
entry that says `batched: true` gets the how-often select beside it too. An
|
|
1300
|
-
event a user has said nothing about is at its default, which is why turning one
|
|
1301
|
-
on for a site does not need anybody to visit the users screen.
|
|
1302
|
-
|
|
1303
|
-
### Akismet
|
|
1304
|
-
|
|
1305
|
-
The one checker the package ships. It is off until a site has a key, and the
|
|
1306
|
-
key is a credential rather than a setting: it lives in `data/akismet.json` at
|
|
1307
|
-
mode `0600` beside the password hashes and the users' private keys, never in
|
|
1308
|
-
`content/_data/site.json`, which is public, in git and published with the site.
|
|
1309
|
-
|
|
1310
|
-
Paste it into **Spam checking** on `/admin/settings/discussion`. It is checked with
|
|
1311
|
-
Akismet's `verify-key` before it is stored, and the panel then reads Connected,
|
|
1312
|
-
"Akismet does not recognise this key", "Akismet could not be reached", or Not
|
|
1313
|
-
connected. The key is never printed back — the last four characters are, so it
|
|
1314
|
-
is possible to tell which key is in there. A **Remove key** button turns Akismet
|
|
1315
|
-
off again.
|
|
1316
|
-
|
|
1317
|
-
The file is read on every call, so a key saved on that screen filters the next
|
|
1318
|
-
comment and a removed one stops filtering at once. Neither needs a restart.
|
|
1319
|
-
|
|
1320
|
-
A `commentChecker` in the config wins outright: the Akismet checker is built
|
|
1321
|
-
only when the site named none.
|
|
1322
|
-
|
|
1323
|
-
Every comment and every incoming webmention that gets past the honeypot, the
|
|
1324
|
-
form's age and the rate limit goes to `comment-check` with `blog`, `user_ip`,
|
|
1325
|
-
`user_agent`, `referrer`, `permalink`, `comment_type` (`comment` or
|
|
1326
|
-
`webmention`), `comment_author`, `comment_author_email`, `comment_author_url`,
|
|
1327
|
-
`comment_content`, `comment_date_gmt`, `blog_lang`, `blog_charset` and, for a
|
|
1328
|
-
form submission, `honeypot_field_name`. Fediverse replies are never sent: they
|
|
1329
|
-
do not reach the checker seam at all.
|
|
1330
|
-
|
|
1331
|
-
| Akismet says | What happens |
|
|
1332
|
-
| ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
1333
|
-
| `true` | Filed as spam, where a moderator can still see it and change its mind. |
|
|
1334
|
-
| `true` with `X-akismet-pro-tip: discard` | Dropped without a queue entry. |
|
|
1335
|
-
| `false` | The site's own rules decide: approved for a name and email approved before, pending otherwise. |
|
|
1336
|
-
| `invalid`, a non-200, a timeout, no route | The same as `false`, and the failure is logged with whatever Akismet said was wrong. |
|
|
1337
|
-
|
|
1338
|
-
`false` is deliberately not a positive opinion. Nothing Akismet does can lose a
|
|
1339
|
-
comment: every failure is no opinion, no opinion is the queue, and a call is
|
|
1340
|
-
abandoned after ten seconds.
|
|
60
|
+
## License
|
|
1341
61
|
|
|
1342
|
-
|
|
1343
|
-
`submit-ham` with the same fields, minus `user_ip`, `user_agent` and `referrer`
|
|
1344
|
-
— a stored comment keeps a salted hash of the address and nothing else.
|
|
1345
|
-
|
|
1346
|
-
## Webmentions
|
|
1347
|
-
|
|
1348
|
-
The open web's version of what ActivityPub does: one page telling another that
|
|
1349
|
-
it linked to it. Both directions are on by default and each has a switch on the
|
|
1350
|
-
Discussion settings page.
|
|
1351
|
-
|
|
1352
|
-
| Setting in `site.json` | What it does |
|
|
1353
|
-
| ---------------------- | -------------------------------------------------------------------------- |
|
|
1354
|
-
| `webmentionsSend` | Tell the external pages a post links to, when it is published or edited. |
|
|
1355
|
-
| `webmentionsReceive` | Advertise the endpoints and accept webmentions and pingbacks sent to them. |
|
|
1356
|
-
|
|
1357
|
-
**Sending** runs off the same index changes federation does, so a post saved in
|
|
1358
|
-
the editor and one edited on disk are one thing, and a full scan sends nothing.
|
|
1359
|
-
Every external link in the rendered body is asked whether it advertises a
|
|
1360
|
-
Webmention endpoint — the `Link` header first, then the first `<link>` or `<a>`
|
|
1361
|
-
with `rel="webmention"` — and told if it does. Both versions of an edited post
|
|
1362
|
-
are read, so a page that has just been _unlinked_ is told too and can drop what
|
|
1363
|
-
it was showing. One outcome is recorded per link (`sent`, `none` for a page that
|
|
1364
|
-
takes none, or `failed`), shown per post on `/admin/federation`, and the Resend
|
|
1365
|
-
button there sends them again from the file as it now reads.
|
|
1366
|
-
|
|
1367
|
-
**Links to the site's own pages** send no webmention. A post that cites another
|
|
1368
|
-
post on the same site does not go through moderation, does not add to a comment
|
|
1369
|
-
count and does not make an HTTP request to the site itself. The index records
|
|
1370
|
-
the link instead. The linked post or page then lists the linking one among its
|
|
1371
|
-
backlinks, which the default theme prints as "Linked from" under it, apart from
|
|
1372
|
-
the comments. A link counts for the document the site serves at its URL: a link
|
|
1373
|
-
to `/` counts for the page the Reading setting makes the homepage, and a link
|
|
1374
|
-
to a URL the site redirects, such as an imported `/?p=123`, counts for the
|
|
1375
|
-
document the redirect leads to. Redirects are followed when the backlinks are
|
|
1376
|
-
read, so editing a redirect file moves a backlink without touching the post
|
|
1377
|
-
that holds the link. A draft, future-dated, unlisted, private or trashed post is never
|
|
1378
|
-
a backlink. See `themes/default/README.md` for the `backlinks` context.
|
|
1379
|
-
|
|
1380
|
-
**Receiving** advertises `/_geekity/webmention` two ways — a `Link` header on
|
|
1381
|
-
every representation of a document, and a `<link rel="webmention">` in the head
|
|
1382
|
-
— and takes a form `POST` of `source` and `target`. It answers `202` for
|
|
1383
|
-
anything worth checking and `400` for anything that is not, and does the looking
|
|
1384
|
-
afterwards: it fetches the source, checks it really links to the target, reads
|
|
1385
|
-
its microformats (`h-entry`, `h-card`, and whether it is a reply, a like, a
|
|
1386
|
-
repost or a plain mention), and files the result as a **pending comment** with
|
|
1387
|
-
`source: "webmention"` in the same file, the same thread and the same moderation
|
|
1388
|
-
queue as everything else — through the same `commentChecker` seam, so a checker
|
|
1389
|
-
can tell one from a form submission by its `source`. Akismet is told it is a
|
|
1390
|
-
`webmention` rather than a `comment`.
|
|
1391
|
-
|
|
1392
|
-
A reply whose `u-in-reply-to` names a reply already on the post (a comment by
|
|
1393
|
-
its own page or its `#comment-{key}` anchor, an earlier webmention by the page it
|
|
1394
|
-
was sent from, a fediverse reply by its url or id) threads under that reply, and
|
|
1395
|
-
moves with it when the source is edited and sent again. A webmention sent to a
|
|
1396
|
-
comment's page, `/comment/{key}/`, lands on that comment's post.
|
|
1397
|
-
|
|
1398
|
-
The source URL is its identity. A page that sends its webmention again updates
|
|
1399
|
-
what it left rather than adding a second, and one whose link has gone — or which
|
|
1400
|
-
answers 404 or 410 — takes it away. Closing rules do not apply: a post that
|
|
1401
|
-
stopped taking comments still hears about a page that links to it, exactly as it
|
|
1402
|
-
still hears a fediverse reply.
|
|
1403
|
-
|
|
1404
|
-
**Pingbacks** are how most WordPress sites say they linked here. The same
|
|
1405
|
-
switch takes them at `/_geekity/pingback`, advertised as an absolute URL in an
|
|
1406
|
-
`X-Pingback` header and a `<link rel="pingback">` on every post and on every
|
|
1407
|
-
page that takes comments. A `pingback.ping(source, target)` XML-RPC call goes
|
|
1408
|
-
through the same check, verification, spam checker and moderation queue as a
|
|
1409
|
-
webmention and is filed as the same comment, so a link pinged and a link
|
|
1410
|
-
mentioned are one entry. Because XML-RPC wants an answer, the source is read
|
|
1411
|
-
before the endpoint replies, and a failure is one of the spec's fault codes:
|
|
1412
|
-
`0x0010` for a source that does not exist, `0x0011` for one that does not link
|
|
1413
|
-
here, `0x0020` for a target that does not exist, `0x0021` for a target that
|
|
1414
|
-
takes no pingbacks, and `0x0030` for a ping sent again, which still rewrites
|
|
1415
|
-
the entry from the source as it now reads. A body over 16 KB, XML that is not
|
|
1416
|
-
well formed, and any document type declaration are refused with `-32700`
|
|
1417
|
-
before anything is resolved. The CMS does not send pingbacks.
|
|
1418
|
-
|
|
1419
|
-
### Syndication targets
|
|
1420
|
-
|
|
1421
|
-
Some services copy a post when it links to them and sends them a webmention,
|
|
1422
|
-
and answer with the address of the copy. IndieNews lists posts about the
|
|
1423
|
-
IndieWeb; Bridgy Publish posts to Mastodon, Bluesky, GitHub and Flickr. The
|
|
1424
|
-
CMS knows none of them by name. A site lists the ones it uses on the admin's
|
|
1425
|
-
**Posts > Syndication** screen (`/admin/syndication`), which reads
|
|
1426
|
-
and writes `content/_data/syndicationTargets.json`. The file stays the source of
|
|
1427
|
-
truth, so it can still be edited by hand, and it looks like this:
|
|
1428
|
-
|
|
1429
|
-
```json
|
|
1430
|
-
[
|
|
1431
|
-
{
|
|
1432
|
-
"id": "indienews",
|
|
1433
|
-
"name": "IndieNews",
|
|
1434
|
-
"url": "https://news.indieweb.org/en",
|
|
1435
|
-
"tag": "indienews"
|
|
1436
|
-
},
|
|
1437
|
-
{
|
|
1438
|
-
"id": "mastodon",
|
|
1439
|
-
"name": "Mastodon",
|
|
1440
|
-
"url": "https://brid.gy/publish/mastodon"
|
|
1441
|
-
}
|
|
1442
|
-
]
|
|
1443
|
-
```
|
|
1444
|
-
|
|
1445
|
-
| Key | What it is |
|
|
1446
|
-
| ----------- | --------------------------------------------------------------------------------------------- |
|
|
1447
|
-
| `id` | What a post's `syndicate-to` lists. Letters, digits, `.`, `-` and `_`. |
|
|
1448
|
-
| `name` | What the editor's checkbox and the post's link say. |
|
|
1449
|
-
| `url` | The page the post links to and sends its webmention to. May hold `{lang}`. |
|
|
1450
|
-
| `tag` | Optional. A post carrying this tag is sent to the target without listing it. |
|
|
1451
|
-
| `languages` | Optional. The language tags the target takes posts in. A post in any other language skips it. |
|
|
1452
|
-
|
|
1453
|
-
An entry missing any of the first three, repeating an id, or with a `languages`
|
|
1454
|
-
value that is not a list of language tags, is ignored. It is reported in the log
|
|
1455
|
-
when the site starts, and the Syndication screen shows it with its problem so it
|
|
1456
|
-
can be fixed or removed there. No file means no targets.
|
|
1457
|
-
|
|
1458
|
-
The screen has a panel for each entry, in file order, and an **Add a target**
|
|
1459
|
-
form. A save is checked by the same rules the file is read by: a field that
|
|
1460
|
-
breaks one is marked on the form and the file is left as it was. A save that
|
|
1461
|
-
passes is written atomically, and the editor's **Syndicate to** checkboxes and
|
|
1462
|
-
Micropub's `q=syndicate-to` offer the change on the next request. A save or
|
|
1463
|
-
remove is refused when the entry changed in the file since the page was drawn.
|
|
1464
|
-
A file that is not a JSON list is shown with its problem and never saved over.
|
|
1465
|
-
To keep the default theme's old IndieNews link, add a target with the id
|
|
1466
|
-
`indienews`, the url `https://news.indieweb.org/{lang}` and the tag `indienews`.
|
|
1467
|
-
|
|
1468
|
-
Some targets have a page per language. IndieNews has `news.indieweb.org/en`,
|
|
1469
|
-
`/de`, `/fr` and more. Declare one target and put `{lang}` where the language
|
|
1470
|
-
goes:
|
|
1471
|
-
|
|
1472
|
-
```json
|
|
1473
|
-
{
|
|
1474
|
-
"id": "indienews",
|
|
1475
|
-
"name": "IndieNews",
|
|
1476
|
-
"url": "https://news.indieweb.org/{lang}",
|
|
1477
|
-
"tag": "indienews",
|
|
1478
|
-
"languages": ["en", "de", "fr"]
|
|
1479
|
-
}
|
|
1480
|
-
```
|
|
1481
|
-
|
|
1482
|
-
The CMS fills `{lang}` with the post's language: its `lang` front matter, or the
|
|
1483
|
-
site's language when the post names none. With `languages`, a post in a
|
|
1484
|
-
language the list does not name neither links to the target nor notifies it. A
|
|
1485
|
-
regional tag counts as its language, so a `de-AT` post goes to
|
|
1486
|
-
`news.indieweb.org/de`, and `{lang}` takes the tag from the list. Without
|
|
1487
|
-
`languages`, `{lang}` takes the post's own tag as written, region and all.
|
|
1488
|
-
|
|
1489
|
-
A post selects targets in one of two ways:
|
|
1490
|
-
|
|
1491
|
-
- **By listing them.** The post editor has a **Syndicate to** checkbox for each
|
|
1492
|
-
target, and saving writes the ticked ids into the front matter as
|
|
1493
|
-
`syndicate-to: [indienews, mastodon]`. An id no target declares is kept as
|
|
1494
|
-
written.
|
|
1495
|
-
- **By its tags.** A post tagged `indienews` is sent to the target whose `tag`
|
|
1496
|
-
is `indienews`, as the WordPress IndieNews plugin did.
|
|
1497
|
-
|
|
1498
|
-
The default theme links to each selected target at the start of the Published
|
|
1499
|
-
line, inside the post's `h-entry`, as `<a class="u-syndication"
|
|
1500
|
-
href="https://news.indieweb.org/en">IndieNews</a>`. That link is what IndieNews
|
|
1501
|
-
and Bridgy Publish check for when they fetch the post. When the post is
|
|
1502
|
-
published or updated, the sender adds each selected target to the pages it
|
|
1503
|
-
notifies, the same way it adds the post a reply answers. Nothing is sent while a
|
|
1504
|
-
page is served.
|
|
1505
|
-
|
|
1506
|
-
A target that answers `201` or `202` with a `Location` header has made a copy.
|
|
1507
|
-
The address is kept in `content/_data/syndication.json`, keyed by the post's
|
|
1508
|
-
permalink (decision-26), and the theme prints it after the dates as **Also on**
|
|
1509
|
-
with a `u-syndication` link. A copy made by hand goes in the post's own front
|
|
1510
|
-
matter, and is printed the same way:
|
|
1511
|
-
|
|
1512
|
-
```yaml
|
|
1513
|
-
syndication:
|
|
1514
|
-
- https://mastodon.social/@me/113000000000000000
|
|
1515
|
-
```
|
|
1516
|
-
|
|
1517
|
-
Removing a target from `syndicate-to`, or removing its tag, removes the link.
|
|
1518
|
-
The sender then tells the target, as it tells any page a post stops linking to,
|
|
1519
|
-
and the copy is dropped from `syndication.json`. IndieNews takes the post down
|
|
1520
|
-
when it finds the link gone. Drafting or trashing the post does the same.
|
|
1521
|
-
|
|
1522
|
-
The posts table on `/admin/federation` lists each selected target with how its
|
|
1523
|
-
last webmention went and a link to its copy, and **Resend** sends to the targets
|
|
1524
|
-
again.
|
|
1525
|
-
|
|
1526
|
-
## Email
|
|
1527
|
-
|
|
1528
|
-
The CMS sends its mail through one service and one seam. Everything that will
|
|
1529
|
-
ever email — the Send test email button, and the password resets, moderation
|
|
1530
|
-
notices and contact messages built on it — calls `cms.mail.send()`, and what
|
|
1531
|
-
carries the message is decided in one place.
|
|
1532
|
-
|
|
1533
|
-
**A site with no mail configuration still works.** `send` writes a line in the
|
|
1534
|
-
log saying the message was not sent and resolves successfully, so nothing that
|
|
1535
|
-
emails is a feature that breaks without a mail account.
|
|
1536
|
-
|
|
1537
|
-
### Configuring it
|
|
1538
|
-
|
|
1539
|
-
Two halves, kept apart for the reason the Akismet key is kept out of
|
|
1540
|
-
`site.json`:
|
|
1541
|
-
|
|
1542
|
-
| Where | What |
|
|
1543
|
-
| ------------------------- | --------------------------------------------------------------------------------- |
|
|
1544
|
-
| `content/_data/site.json` | `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`, `contactEmail`. |
|
|
1545
|
-
| `data/mail.json` | The Brevo API key, and the SMTP host, port, TLS flag, user and password. |
|
|
1546
|
-
|
|
1547
|
-
`site.json` is public, in git and published with the site; `data/mail.json` is
|
|
1548
|
-
private, mode `0600`, and sits beside the password hashes and the users'
|
|
1549
|
-
private keys. No key or password is ever written to `site.json` and none is
|
|
1550
|
-
ever printed back into the settings screen — the panel shows the last four
|
|
1551
|
-
characters of an API key and the host and user of an SMTP connection, which is
|
|
1552
|
-
enough to tell what is stored and not enough to use it. Leaving a secret field
|
|
1553
|
-
blank keeps the one already stored, so the SMTP port can be corrected without
|
|
1554
|
-
retyping the password. **Remove credentials** forgets both.
|
|
1555
|
-
|
|
1556
|
-
The panel draws the boxes of the provider that is saved and no others: an API
|
|
1557
|
-
key on a Brevo site, a host, port, TLS flag, user and password on an SMTP one,
|
|
1558
|
-
and nothing at all on a site sending through neither. It follows what is in
|
|
1559
|
-
`site.json` rather than what the Provider select above it is showing, so
|
|
1560
|
-
choosing a different provider there takes a **Save settings** before its boxes
|
|
1561
|
-
appear — the panel says as much. A credential stored for the provider that is
|
|
1562
|
-
not the chosen one still gets a line of its own, because it is still on disk
|
|
1563
|
-
and **Remove credentials** is still what forgets it.
|
|
1564
|
-
|
|
1565
|
-
Both files are read on every send, so a credential pasted into the settings
|
|
1566
|
-
screen sends the next message and one removed stops the message after it.
|
|
1567
|
-
Neither needs a restart.
|
|
1568
|
-
|
|
1569
|
-
`mailFromAddress` should be an address the provider has verified; empty falls
|
|
1570
|
-
back to `no-reply@` at the site's host, which most providers will refuse.
|
|
1571
|
-
|
|
1572
|
-
### The two providers
|
|
1573
|
-
|
|
1574
|
-
| `mailProvider` | What it is |
|
|
1575
|
-
| -------------- | ------------------------------------------------------------------------------------- |
|
|
1576
|
-
| `none` | Nothing is sent. The default. |
|
|
1577
|
-
| `brevo` | `POST https://api.brevo.com/v3/smtp/email` with an `api-key` header. No open sockets. |
|
|
1578
|
-
| `smtp` | Any SMTP server, through nodemailer — including Brevo's own relay. |
|
|
1579
|
-
|
|
1580
|
-
### Send test email
|
|
1581
|
-
|
|
1582
|
-
The Email settings page has an address field and a button that sends the `test`
|
|
1583
|
-
message through the whole chain — the template, the From line, the provider and
|
|
1584
|
-
the retry — and reports what came back, the provider's own words and its
|
|
1585
|
-
message id included. It is the one thing that proves mail works before somebody
|
|
1586
|
-
needs it to.
|
|
1587
|
-
|
|
1588
|
-
It is on the page only where the site can actually send — a provider chosen and
|
|
1589
|
-
its credential stored. Without one the only thing the button could report is
|
|
1590
|
-
that it did nothing.
|
|
1591
|
-
|
|
1592
|
-
### Queue, retries and the log
|
|
1593
|
-
|
|
1594
|
-
Messages go on one queue and are sent one at a time, so a provider that hangs
|
|
1595
|
-
delays the next message and nothing else. A refused message is tried three
|
|
1596
|
-
times, waiting two seconds, then eight; every attempt is logged with its
|
|
1597
|
-
outcome and, when it went, the provider's message id, so a message somebody
|
|
1598
|
-
never received can be traced to the attempt that carried it. `send` never
|
|
1599
|
-
throws — a failure is a result with `ok: false` and the provider's own words in
|
|
1600
|
-
`error`.
|
|
1601
|
-
|
|
1602
|
-
### Messages
|
|
1603
|
-
|
|
1604
|
-
Messages are Nunjucks templates under `mail/` in the theme, resolved the way
|
|
1605
|
-
every other template is: the theme the site has chosen first, the packaged theme
|
|
1606
|
-
second, file by file. Each is up to three files — `mail/<name>.subject.njk`,
|
|
1607
|
-
`mail/<name>.txt.njk` (required) and `mail/<name>.html.njk` — so a site can
|
|
1608
|
-
replace the text of a message and keep the subject the package ships, or add a
|
|
1609
|
-
message the package never had. The full table is in
|
|
1610
|
-
[`themes/default/README.md`](./themes/default/README.md).
|
|
1611
|
-
|
|
1612
|
-
### From an entry file, and from a test
|
|
1613
|
-
|
|
1614
|
-
```ts
|
|
1615
|
-
const result = await cms.mail.send({
|
|
1616
|
-
to: 'ada@example.com',
|
|
1617
|
-
template: 'test',
|
|
1618
|
-
data: { name: 'Ada' },
|
|
1619
|
-
});
|
|
1620
|
-
result.ok; // true, whether it went or the site sends no mail
|
|
1621
|
-
result.skipped; // true when the site sends no mail
|
|
1622
|
-
```
|
|
1623
|
-
|
|
1624
|
-
`sendRaw({ to, subject, text, html })` is the same thing for a message the
|
|
1625
|
-
caller built itself. Both resolve when the message has been sent, given up on
|
|
1626
|
-
or skipped; a caller that does not care drops the promise with `void`.
|
|
1627
|
-
|
|
1628
|
-
A test names a provider in the config and reads back what would have gone out.
|
|
1629
|
-
It wins outright over the settings and `data/mail.json`, the way a
|
|
1630
|
-
`commentChecker` wins over the Akismet key:
|
|
1631
|
-
|
|
1632
|
-
```ts
|
|
1633
|
-
import { createCms, createMemoryMailProvider } from '@geekity/cms';
|
|
1634
|
-
|
|
1635
|
-
const provider = createMemoryMailProvider();
|
|
1636
|
-
const cms = createCms({
|
|
1637
|
-
mail: { provider, backoffMs: () => 0 },
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
// …
|
|
1641
|
-
provider.sent[0].subject;
|
|
1642
|
-
provider.failNext(2); // watch the retry without waiting for it
|
|
1643
|
-
```
|
|
1644
|
-
|
|
1645
|
-
`mail` also takes `attempts`, `backoffMs` and `logger`, which is how a test
|
|
1646
|
-
proves the retry without spending ten seconds on it.
|
|
1647
|
-
|
|
1648
|
-
## The archive page
|
|
1649
|
-
|
|
1650
|
-
A page carrying `archive: true` in its front matter lists every published post
|
|
1651
|
-
under its own words, grouped by the month it was published in, newest month
|
|
1652
|
-
first and newest post first inside a month. Nothing paginates it: an archive
|
|
1653
|
-
page is a way of finding one piece of writing rather than a listing to read
|
|
1654
|
-
through. Drafts and posts whose date has not arrived are absent, as they are
|
|
1655
|
-
everywhere else.
|
|
1656
|
-
|
|
1657
|
-
The default theme draws it from `partials/archive.njk`, and the CMS hands the
|
|
1658
|
-
layout `archiveMonths` — `{ month, posts }` per month, the month already read
|
|
1659
|
-
through the site's `timezone` so the heading and the dates agree. Eleventy
|
|
1660
|
-
ignores the key, and a static build of the same directory publishes the page as
|
|
1661
|
-
an ordinary page.
|
|
1662
|
-
|
|
1663
|
-
## The contact form
|
|
1664
|
-
|
|
1665
|
-
A page carrying `contact: true` in its front matter renders a contact form
|
|
1666
|
-
under its content — name, email, subject, message. The editor offers it as a
|
|
1667
|
-
**Contact form** checkbox on the pages editor, and the key is honoured wherever
|
|
1668
|
-
it is written, so a theme that includes `partials/contact-form.njk` in its post
|
|
1669
|
-
layout gets one there too.
|
|
1670
|
-
|
|
1671
|
-
It works with JavaScript switched off, and there is no script on it. The form
|
|
1672
|
-
posts to `/_geekity/contact`, and a message that is stored redirects back to
|
|
1673
|
-
the page with `?contact=sent`, which draws a thank-you where the form was — so
|
|
1674
|
-
a refresh sends nothing twice.
|
|
1675
|
-
|
|
1676
|
-
**Where the message goes never appears in the HTML.** The contact address is
|
|
1677
|
-
read when a submission arrives; it is not on any render context, so no theme
|
|
1678
|
-
can print it and no form carries it.
|
|
1679
|
-
|
|
1680
|
-
### Where a message goes
|
|
1681
|
-
|
|
1682
|
-
| Where | What |
|
|
1683
|
-
| ----------------------------------- | --------------------------------------------------------------------------- |
|
|
1684
|
-
| `data/contact/<id>.json`, mode 0600 | Every message, written **before** anything is emailed. |
|
|
1685
|
-
| The `contactEmail` setting | Where the `contact-message` email is sent, reply-to the sender. |
|
|
1686
|
-
| `/admin/messages` | The inbox: read, mark read, delete. The dashboard carries the unread count. |
|
|
1687
|
-
|
|
1688
|
-
`contactEmail` is on the Settings screen under Email. Empty falls back to the
|
|
1689
|
-
first admin with an email address, by username, so a fresh site with a mail
|
|
1690
|
-
credential takes messages without anybody visiting the field.
|
|
1691
|
-
|
|
1692
|
-
The file is written first and the mail goes out behind the redirect, which is
|
|
1693
|
-
the whole point of the design: a provider that is down, a key that has expired
|
|
1694
|
-
or an address that bounces costs a notification rather than the message. **With
|
|
1695
|
-
no mail configured at all, submissions are still stored and still shown on the
|
|
1696
|
-
Messages screen** — it is the notification, exactly as `/admin/comments` was
|
|
1697
|
-
before there was any email.
|
|
1698
|
-
|
|
1699
|
-
Messages live under `data/` rather than `content/` because they carry the
|
|
1700
|
-
sender's address and were never meant to be published, and there is no SQLite
|
|
1701
|
-
index over them: the screen reads the directory to sort it anyway, and a second
|
|
1702
|
-
copy of the truth would only be a second thing to keep true. One JSON file per
|
|
1703
|
-
message, named by an id that begins with the instant it arrived, so `ls` is the
|
|
1704
|
-
inbox in order. Deleting a message on the screen deletes its file.
|
|
1705
|
-
|
|
1706
|
-
### What is in a message file
|
|
1707
|
-
|
|
1708
|
-
```json
|
|
1709
|
-
{
|
|
1710
|
-
"id": "20260920T120000000Z-1a2b3c4d",
|
|
1711
|
-
"received": "2026-09-20T12:00:00.000Z",
|
|
1712
|
-
"status": "received",
|
|
1713
|
-
"read": false,
|
|
1714
|
-
"page": { "slug": "contact", "permalink": "/contact/", "title": "Say hello" },
|
|
1715
|
-
"from": { "name": "Ada Lovelace", "email": "ada@example.com" },
|
|
1716
|
-
"subject": "About the analytical engine",
|
|
1717
|
-
"message": "It is a lovely machine. Please write back.",
|
|
1718
|
-
"addressHash": "9f2c…"
|
|
1719
|
-
}
|
|
1720
|
-
```
|
|
1721
|
-
|
|
1722
|
-
`addressHash` is the same salted hash a comment keeps — the salt is
|
|
1723
|
-
`data/comment-salt` — so a run of submissions from one machine is visible
|
|
1724
|
-
across both without a reader's address being written down.
|
|
1725
|
-
|
|
1726
|
-
### The defences in front of it
|
|
1727
|
-
|
|
1728
|
-
The same four a comment goes through, out of the same modules, so the two
|
|
1729
|
-
public forms cannot drift into different rules:
|
|
1730
|
-
|
|
1731
|
-
| Defence | What it refuses |
|
|
1732
|
-
| ------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
1733
|
-
| The honeypot | A hidden field a person never fills in. A submission that filled it gets the thank-you and is dropped in silence. |
|
|
1734
|
-
| The form's age | Submitted under three seconds after the page loaded, or from a form rendered more than a day ago. |
|
|
1735
|
-
| The rate limit | Five messages per address per ten minutes, on the same limiter the login form uses. The sixth is a 429. |
|
|
1736
|
-
| The comment checker | The `commentChecker` seam, told `comment_type: contact-form`. With an Akismet key stored, that is Akismet. |
|
|
1737
|
-
|
|
1738
|
-
A checker's `discard` stores nothing at all. Its `spam` **stores the message**,
|
|
1739
|
-
on the Spam list of the Messages screen, and does not email it: a false
|
|
1740
|
-
positive on a contact form is somebody's message vanishing, which is a worse
|
|
1741
|
-
failure than a spam list to glance at. A checker that is down or throwing is no
|
|
1742
|
-
opinion, and the message is stored as it would have been before anybody had
|
|
1743
|
-
one.
|
|
1744
|
-
|
|
1745
|
-
## Personal data
|
|
1746
|
-
|
|
1747
|
-
This is every piece of personal data the CMS stores, where it is, and how long
|
|
1748
|
-
it stays. "Kept" means until somebody deletes it, unless a retention period
|
|
1749
|
-
says otherwise.
|
|
1750
|
-
|
|
1751
|
-
| What | Where | How long |
|
|
1752
|
-
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
1753
|
-
| A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git | Kept. Erasing on request replaces the name and drops the website. |
|
|
1754
|
-
| A commenter's email, and whether they asked to be told about replies | `data/comments/{slug}.json`, mode `0600`, keyed by comment id | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
|
|
1755
|
-
| A salted hash of the address a comment, webmention or contact message came from (the address itself is never stored) | The comment file, or the contact message file | `addressHashRetentionDays`: forever unless set, 30 on a new site. |
|
|
1756
|
-
| A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json` | Kept, as a copy of a page that is public already. |
|
|
1757
|
-
| A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600` | `contactMessageRetentionDays`: forever unless set, 365 on a new site. |
|
|
1758
|
-
| The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600` | Kept, so the site goes on not writing to them. Erasing on request removes one. |
|
|
1759
|
-
| Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
|
|
1760
|
-
| Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
|
|
1761
|
-
| What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
|
|
1762
|
-
| Other actors who liked, boosted, answered or quoted: handle, display name, avatar URL, profile URL | `data/geekity.db`, fetched from their actor document | A cache. Refetched weekly while the inbox log names them. |
|
|
1763
|
-
| Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
|
|
1764
|
-
| Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
|
|
1765
|
-
| The apps a user signed in to with IndieAuth: app, scopes, hashes of its tokens, when they expire | `data/indieauth-tokens.json`, mode `0600` | Until the user is deleted, or 60 days after the app last refreshed. |
|
|
1766
|
-
| Recent IndieAuth and Micropub requests: app, username, what it sent (no secrets, values cut short), the answer | `data/indieauth-activity.json`, mode `0600` | The last 100 requests, none older than 14 days. |
|
|
1767
|
-
| Plugins added, updated and removed on Admin > Plugins: the username, when, the package and its versions | `data/plugin-changes.json`, mode `0600` | The last 200 changes. |
|
|
1768
|
-
| An index of all of the above, and sessions, reset tokens and spent link tokens | `data/geekity.db` | A cache of the files. Sessions and tokens are pruned when they expire. |
|
|
1769
|
-
| Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
|
|
1770
|
-
| Where and when a photo or video was taken, and the camera or phone (EXIF, XMP, IPTC, a video's `©xyz`) | Removed from `content/uploads/` on upload | Never stored. Older uploads keep it until `geekity strip-metadata`; git history keeps the old bytes until rewritten. |
|
|
1771
|
-
| Where the author wrote a post or checked in: coordinates, accuracy, and the place's name, locality, region and country | `data/locations.json`, mode `0600`, keyed by permalink | Kept until the author clears it. Published only as far as **Settings > Privacy** allows, nothing by default. |
|
|
1772
|
-
| Micropub properties a post was sent that the site does not understand, such as an RSVP | `data/kept-properties.json`, mode `0600`, keyed by permalink | Kept until an update deletes them. Never published. |
|
|
1773
|
-
| Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
|
|
1774
|
-
|
|
1775
|
-
Two services outside the site see personal data when a site turns them on.
|
|
1776
|
-
Akismet is sent a commenter's or sender's address, user agent, referrer, name,
|
|
1777
|
-
email, website and words. The mail provider is sent every address a message
|
|
1778
|
-
goes to.
|
|
1779
|
-
|
|
1780
|
-
### The starter privacy page
|
|
1781
|
-
|
|
1782
|
-
`geekity init` writes `content/pages/privacy.md`, served at `/privacy/` and
|
|
1783
|
-
linked from the footer by a `Privacy | /privacy/` line in `menus.footer`. It
|
|
1784
|
-
says in plain language what the table above says, marks the contact form,
|
|
1785
|
-
reply emails and Akismet as optional, and quotes the retention periods a new
|
|
1786
|
-
site starts with. It is a starting point written from what the software does,
|
|
1787
|
-
not legal advice, and a note at its top says so to the owner: review it, add
|
|
1788
|
-
whatever the site does beyond the CMS (analytics, a host's logs, a theme that
|
|
1789
|
-
loads fonts from elsewhere), change the numbers when a retention period
|
|
1790
|
-
changes, and remove the menu line if the page is deleted.
|
|
1791
|
-
|
|
1792
|
-
The link is a menu line rather than something the theme prints, because the
|
|
1793
|
-
footer prints `menus.footer` and nothing of its own (TASK-105). A site that
|
|
1794
|
-
upgrades gets no page and no link; it can copy the file from
|
|
1795
|
-
`templates/site/content/pages/privacy.md` in the package.
|
|
1796
|
-
|
|
1797
|
-
### Retention
|
|
1798
|
-
|
|
1799
|
-
Three settings in `content/_data/site.json`, on **Settings > Discussion**,
|
|
1800
|
-
decide how long the site keeps what readers hand it. Each counts whole days from
|
|
1801
|
-
when the data arrived, and `0` keeps it forever:
|
|
1802
|
-
|
|
1803
|
-
| Setting | Recommended | What goes when it runs out |
|
|
1804
|
-
| ----------------------------- | ----------- | ----------------------------------------------------------------- |
|
|
1805
|
-
| `commentEmailRetentionDays` | `180` | The email on a comment, and with it the reply subscription. |
|
|
1806
|
-
| `addressHashRetentionDays` | `30` | The address hash on a comment, a webmention or a contact message. |
|
|
1807
|
-
| `contactMessageRetentionDays` | `365` | The whole contact message. |
|
|
1808
|
-
|
|
1809
|
-
**A site keeps everything until its owner sets a period.** A `site.json`
|
|
1810
|
-
without these keys reads as `0` for all three, so an existing site that
|
|
1811
|
-
upgrades loses nothing, and the Discussion screen says "Kept forever" beside
|
|
1812
|
-
each one. A new site from `geekity init` starts with the recommended periods
|
|
1813
|
-
written into its `site.json`. An existing site opts in by typing them on
|
|
1814
|
-
Settings > Discussion and saving once.
|
|
1815
|
-
|
|
1816
|
-
They are settings rather than config because how long a site keeps people's
|
|
1817
|
-
data is the owner's decision, and a privacy notice quotes it; `site.json` is
|
|
1818
|
-
public, which suits a policy. The recommended periods are long enough for a
|
|
1819
|
-
reply notice to arrive, a run of spam to be spotted and a year of
|
|
1820
|
-
correspondence to be answered, and no longer. The first sweep after a site
|
|
1821
|
-
sets a period removes whatever is already past it.
|
|
1822
|
-
|
|
1823
|
-
A sweep runs when the site starts serving and every six hours after that, and
|
|
1824
|
-
`cms.retention.sweep()` runs one now. It reads the files rather than the index,
|
|
1825
|
-
rewrites each comment file and message inside the same lock every other writer
|
|
1826
|
-
takes, and puts each changed comment back into the index in the same step. A
|
|
1827
|
-
second sweep finds nothing left to remove and writes nothing.
|
|
1828
|
-
|
|
1829
|
-
A comment that loses its email keeps its id, its place in the thread, its
|
|
1830
|
-
status, its words and its name. It gains `"redacted": ["email"]`, which is how
|
|
1831
|
-
the index tells an email removed from one never given: a name whose email was
|
|
1832
|
-
removed does not count toward auto-approval, so nobody can type that name and
|
|
1833
|
-
skip the queue. Its author is no longer told about replies, and their next
|
|
1834
|
-
comment waits for a moderator. A moderation link in an inbox names the comment
|
|
1835
|
-
by id, so it goes on working.
|
|
1836
|
-
|
|
1837
|
-
The sweep changes the files as they are now. The emails are in `data/`, which
|
|
1838
|
-
is not in git, so removing one removes it. Emails from before they moved out of
|
|
1839
|
-
the comment files (TASK-182) were committed with those files, and they stay in
|
|
1840
|
-
the repository's history until that history is rewritten, whatever the sweep or
|
|
1841
|
-
an erasure does to the files today.
|
|
1842
|
-
|
|
1843
|
-
### Erasing one person's data
|
|
1844
|
-
|
|
1845
|
-
**Tools > Personal data** erases what the site holds for one email address.
|
|
1846
|
-
It shows what it found first: how many comments carry the address, how many
|
|
1847
|
-
contact messages came from it, and whether it is on the opt-out list. Erasing
|
|
1848
|
-
then signs each of those comments `Anonymous`, removes its email, website,
|
|
1849
|
-
avatar and address hash, and marks it `"redacted": ["email", "addressHash",
|
|
1850
|
-
"author"]`; the comment keeps its place in the thread, its status and its words.
|
|
1851
|
-
The contact messages are deleted and the address comes off the opt-out list.
|
|
1852
|
-
|
|
1853
|
-
A comment is found by its email, so one whose email the sweep has already
|
|
1854
|
-
removed, a webmention and a fediverse reply are not found there. The Comments
|
|
1855
|
-
screen deletes those one at a time.
|
|
1856
|
-
|
|
1857
|
-
## Keeping the index in step
|
|
1858
|
-
|
|
1859
|
-
Booting scans `contentDir`, indexes every Markdown file under `posts/` and
|
|
1860
|
-
`pages/`, and drops index rows whose file has gone. After that a watcher keeps
|
|
1861
|
-
the two in step, so an edit on disk reaches the live site in about a second
|
|
1862
|
-
without a restart.
|
|
1863
|
-
|
|
1864
|
-
```ts
|
|
1865
|
-
await cms.sync(); // one full scan; what `serve()` runs on boot
|
|
1866
|
-
```
|
|
1867
|
-
|
|
1868
|
-
Set `watch: false` (or `GEEKITY_WATCH=false`) for a host whose content cannot
|
|
1869
|
-
change under the process. `geekity sync` sets it for you.
|
|
1870
|
-
|
|
1871
|
-
## Dates and the timezone setting
|
|
1872
|
-
|
|
1873
|
-
Every date the CMS writes into a file — `date`, `updated` and
|
|
1874
|
-
`activitypub.published` — is a UTC instant ending in `Z`. The `timezone`
|
|
1875
|
-
setting is not stored with the date; it is the lens the instant is read
|
|
1876
|
-
through.
|
|
1877
|
-
|
|
1878
|
-
That means one rule in three places:
|
|
1879
|
-
|
|
1880
|
-
- **The editor** shows a stored instant as the clock reads in the site's zone,
|
|
1881
|
-
with the zone named under the field (`Wall-clock time in America/Chicago
|
|
1882
|
-
(CDT). Stored as UTC.`), and reads what you type back in the same zone. Type
|
|
1883
|
-
`2026-09-04 09:00` in a site set to `America/Chicago` and the file gets
|
|
1884
|
-
`date: '2026-09-04T14:00:00Z'`. A value you write with an offset of its own —
|
|
1885
|
-
`2026-09-04T09:00:00+02:00` — already names an instant, so it is kept as one.
|
|
1886
|
-
- **The theme's `date` filter** renders `readable`, `html` and `year` in the
|
|
1887
|
-
site's zone; `iso` stays the instant, because that is what a `<time
|
|
1888
|
-
datetime>` and a feed want. Changing the setting changes what every page
|
|
1889
|
-
shows on the very next request, with no file touched and no rebuild.
|
|
1890
|
-
- **A new post's filename day and its `/{yyyy}/{mm}/` permalink** come from the
|
|
1891
|
-
calendar day the site's zone was on at that instant, so a post published at
|
|
1892
|
-
half past midnight on 1 October in Berlin is filed under October and not
|
|
1893
|
-
under the 30 September that UTC was still on.
|
|
1894
|
-
|
|
1895
|
-
Changing the setting later never moves a URL. The permalink is written
|
|
1896
|
-
explicitly into every file the CMS saves, and the day a post is filed under is
|
|
1897
|
-
the one in its filename — decided once, when it was written, where no setting
|
|
1898
|
-
can reach it.
|
|
1899
|
-
|
|
1900
|
-
Files written by hand keep working. A `date` with an offset, or one YAML parses
|
|
1901
|
-
into a timestamp by itself, is read for the instant it names, sorts and
|
|
1902
|
-
schedules by that instant, and is rewritten as UTC the next time the CMS saves
|
|
1903
|
-
that file — never by a mere scan. A hand-written file with no `permalink` still
|
|
1904
|
-
resolves to the URL Eleventy gives it, which is cut from the date as the file
|
|
1905
|
-
spells it.
|
|
1906
|
-
|
|
1907
|
-
Feeds, the sitemap and the ActivityStreams objects emit instants and are
|
|
1908
|
-
unaffected by the setting, and a scheduled post fires at its instant whatever
|
|
1909
|
-
the zone is set to.
|
|
1910
|
-
|
|
1911
|
-
## Scheduling
|
|
1912
|
-
|
|
1913
|
-
A post whose `date` is in the future is written now and published then, the way
|
|
1914
|
-
WordPress schedules one. Save it with Publish; the editor answers `Scheduled:
|
|
1915
|
-
{title}` and says when it goes out, in the site's `timezone` setting.
|
|
1916
|
-
|
|
1917
|
-
Until that moment the post is not public in any sense: it is off the home page,
|
|
1918
|
-
off every archive, out of all three feeds and out of the ActivityPub outbox,
|
|
1919
|
-
its permalink 404s in every representation, and no follower has been told about
|
|
1920
|
-
it. When the moment comes the running server publishes it — no restart, no
|
|
1921
|
-
build — and the same delivery that a live publish runs sends the
|
|
1922
|
-
`Create` and pings the notify server.
|
|
1923
|
-
|
|
1924
|
-
There is no `scheduled` key and no state to keep: a scheduled post is an
|
|
1925
|
-
ordinary published one whose date has not arrived, which is a question asked of
|
|
1926
|
-
the clock on every query. So moving the date moves the publication, ticking
|
|
1927
|
-
Draft cancels it, and a date pushed into the future withdraws a post that was
|
|
1928
|
-
already out — with a `Delete` to the followers, exactly as drafting it would.
|
|
1929
|
-
|
|
1930
|
-
A post whose date passed while the server was down is public the moment the
|
|
1931
|
-
next boot has scanned, and is federated once on that boot. The CMS remembers
|
|
1932
|
-
how far through the calendar it has got, so a restart never announces the same
|
|
1933
|
-
post twice and a deleted database never announces the archive.
|
|
1934
|
-
|
|
1935
|
-
The admin lists scheduled posts under their own Scheduled filter, with a
|
|
1936
|
-
Scheduled status beside them. The public permalink 404s for a signed-in admin
|
|
1937
|
-
too: the public site has no session, deliberately — every response it gives is
|
|
1938
|
-
cacheable, and a URL that answered differently for one viewer would be cached
|
|
1939
|
-
and served to the rest. Use the editor's Preview button, which renders the post
|
|
1940
|
-
through the theme's own layout at an admin URL.
|
|
1941
|
-
|
|
1942
|
-
## Unlisted posts
|
|
1943
|
-
|
|
1944
|
-
An unlisted post is published at its URL and left off every list. Choose
|
|
1945
|
-
Unlisted in the editor's Visibility field, send `visibility=unlisted` from a
|
|
1946
|
-
Micropub app, or write it into the front matter:
|
|
1947
|
-
|
|
1948
|
-
```yaml
|
|
1949
|
-
visibility: unlisted
|
|
1950
|
-
```
|
|
1951
|
-
|
|
1952
|
-
Public is the key's absence, and the editor removes the key when you choose
|
|
1953
|
-
Public again. The site has no private posts. A value other than `public` or
|
|
1954
|
-
`unlisted`, such as a hand-typed `visibility: private`, hides the post the way
|
|
1955
|
-
`draft: true` does: its URL answers 404 to anybody not signed in, it is on no
|
|
1956
|
-
list, and a post the followers already hold is withdrawn with a `Delete`. The
|
|
1957
|
-
editor shows the value as it is stored, marks the post Hidden in the list, and
|
|
1958
|
-
keeps the value through a save until you choose Public or Unlisted.
|
|
1959
|
-
|
|
1960
|
-
The rule works for pages too.
|
|
1961
|
-
|
|
1962
|
-
| Where | An unlisted post |
|
|
1963
|
-
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1964
|
-
| Its permalink, in every representation | 200, with `X-Robots-Tag: noindex`. The default theme also prints `<meta name="robots" content="noindex">`. |
|
|
1965
|
-
| The home page, tag, category and author archives, the `archive: true` page | Absent, and not counted in a pager or a tag count. |
|
|
1966
|
-
| Previous and next links under other posts, a front page's `newestPosts` | Absent. |
|
|
1967
|
-
| Every RSS, Atom and JSON feed, and the site-wide comments feed | Absent. Saving one pings no notify server. An unlisted reply post shown in a thread is still in that thread's comments feeds. |
|
|
1968
|
-
| The sitemap, search (HTML and JSON) and `llms.txt` | Absent. |
|
|
1969
|
-
| IndexNow | Not submitted. A public post that becomes unlisted is submitted once, so a search engine reads the `noindex`. |
|
|
1970
|
-
| The ActivityPub outbox and featured collection | Absent. |
|
|
1971
|
-
| Followers | Get the `Create` and every `Update`, addressed `to` the followers with Public in `cc`, which Mastodon shows as unlisted. Relays get nothing. |
|
|
1972
|
-
| Webmentions | Sent as for any post: the page is public. |
|
|
1973
|
-
|
|
1974
|
-
Changing a published post between public and unlisted sends its followers an
|
|
1975
|
-
`Update` with the new addressing.
|
|
1976
|
-
|
|
1977
|
-
In code, `isServed(document)` says whether the site serves a document at its
|
|
1978
|
-
URL and `isListed(document)` whether it also lists it. The index answers the
|
|
1979
|
-
same two rules in SQL. An Eleventy build with the example config reads the key
|
|
1980
|
-
too: an unlisted post gets its page, `noindex`, and no place in any collection,
|
|
1981
|
-
and an unrecognized value builds no page. See
|
|
1982
|
-
[Building the same content with Eleventy](#building-the-same-content-with-eleventy).
|
|
1983
|
-
|
|
1984
|
-
## Hooks
|
|
1985
|
-
|
|
1986
|
-
Two hooks let a site do something of its own when content changes. Both are
|
|
1987
|
-
config options, and both are also methods, for a site that would rather
|
|
1988
|
-
subscribe from its entry file:
|
|
1989
|
-
|
|
1990
|
-
```ts
|
|
1991
|
-
export default defineConfig({
|
|
1992
|
-
onPublish: (change) => {
|
|
1993
|
-
console.log(`${change.next?.title ?? ''} went live`);
|
|
1994
|
-
},
|
|
1995
|
-
});
|
|
1996
|
-
```
|
|
1997
|
-
|
|
1998
|
-
```ts
|
|
1999
|
-
const stop = cms.onDocumentChange((change) => {
|
|
2000
|
-
console.log(change.type, change.path, change.origin);
|
|
2001
|
-
});
|
|
2002
|
-
stop(); // the method returns the unsubscribe
|
|
2003
|
-
```
|
|
2004
|
-
|
|
2005
|
-
`onDocumentChange` runs for every `created`, `updated` and `deleted`.
|
|
2006
|
-
`onPublish` runs when a document becomes visible on the public site: created
|
|
2007
|
-
already published, a draft published, or a document restored from the trash. A
|
|
2008
|
-
hook may be `async`; the CMS never waits for it, and a hook that throws or
|
|
2009
|
-
rejects is reported and does not stop the scan or wedge the watcher.
|
|
2010
|
-
|
|
2011
|
-
**Mind the origin.** Every change carries `origin`: `watch` for a live edit,
|
|
2012
|
-
`admin` for a write the editor made, and `scan` for a full scan — _including
|
|
2013
|
-
the one on boot_. A cold index reports its whole first scan as `created`, and
|
|
2014
|
-
every published document as `published`, so a hook that must not re-fire when
|
|
2015
|
-
the index is rebuilt has to say so:
|
|
2016
|
-
|
|
2017
|
-
```ts
|
|
2018
|
-
cms.onPublish((change) => {
|
|
2019
|
-
if (change.origin === 'scan') return; // not a rebuilt index
|
|
2020
|
-
void announce(change.next);
|
|
2021
|
-
});
|
|
2022
|
-
```
|
|
2023
|
-
|
|
2024
|
-
Deleting `data/geekity.db` is a supported thing to do, so this is not a corner
|
|
2025
|
-
case: it is what happens on the next boot.
|
|
2026
|
-
|
|
2027
|
-
For anything the two hooks do not cover, subscribe to the events directly. An
|
|
2028
|
-
`async` listener registered this way _is_ waited for, unlike the two hooks, so
|
|
2029
|
-
it can finish writing before the change is called done — and can also hold up
|
|
2030
|
-
the watcher if it is slow.
|
|
2031
|
-
|
|
2032
|
-
```ts
|
|
2033
|
-
cms.events.on('unpublished', (change) => {
|
|
2034
|
-
console.log(`${change.previous?.title ?? ''} came down`);
|
|
2035
|
-
});
|
|
2036
|
-
```
|
|
2037
|
-
|
|
2038
|
-
| Event | When |
|
|
2039
|
-
| ------------- | -------------------------------------------------------------------------------------------------- |
|
|
2040
|
-
| `created` | A file was indexed for the first time. |
|
|
2041
|
-
| `updated` | An indexed file's content changed. |
|
|
2042
|
-
| `deleted` | An indexed file is gone. |
|
|
2043
|
-
| `published` | A document became visible: created live, a draft published, or a document restored from the trash. |
|
|
2044
|
-
| `unpublished` | The reverse: published to draft, trashed, or deleted while live. |
|
|
2045
|
-
| `change` | Every `created`, `updated` and `deleted`. |
|
|
2046
|
-
|
|
2047
|
-
Each one carries the document before and after the change (`previous` and
|
|
2048
|
-
`next`, either of which may be `undefined`), the content-relative `path`, and
|
|
2049
|
-
`origin`. `on()` returns the function that unsubscribes; there is also `once()`
|
|
2050
|
-
and `off()`.
|
|
2051
|
-
|
|
2052
|
-
## The admin
|
|
2053
|
-
|
|
2054
|
-
Booting also mounts the admin at `/admin`. It is server-rendered from templates
|
|
2055
|
-
that ship inside the package, deliberately outside the theme search path: a
|
|
2056
|
-
site's theme may override any public template, and must not be able to
|
|
2057
|
-
shadow the login form.
|
|
2058
|
-
|
|
2059
|
-
| Route | What it does |
|
|
2060
|
-
| ------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
2061
|
-
| `/admin` | The dashboard: counts, the five most recent posts, the follower count. |
|
|
2062
|
-
| `/admin/posts`, `/admin/pages` | The listings and the editors. |
|
|
2063
|
-
| `/admin/tags`, `/admin/categories` | Every term in use, with rename, merge and delete. |
|
|
2064
|
-
| `/admin/tags/rename`, `/admin/categories/rename` | `POST` only. Renames a term, or merges it into one that exists. |
|
|
2065
|
-
| `/admin/tags/delete`, `/admin/categories/delete` | `POST` only. Takes a term out of every file. |
|
|
2066
|
-
| `/admin/navigation` | Navigation > Menus: every menu the site holds. `POST` saves one menu's items. |
|
|
2067
|
-
| `/admin/navigation/add` | `POST` only. Adds an empty menu under the name the form gives. |
|
|
2068
|
-
| `/admin/navigation/delete` | `POST` only. Deletes one menu the active theme renders nowhere. |
|
|
2069
|
-
| `/admin/media` | Everything under `content/uploads`, with the URL, the Markdown and what uses it. |
|
|
2070
|
-
| `/admin/media/upload` | `POST` only. Stores one file by the rules the editor's upload enforces. |
|
|
2071
|
-
| `/admin/media/delete` | `POST` only. Deletes one upload, asking first when a document points at it. |
|
|
2072
|
-
| `/admin/media/alt` | `POST` only. Sets a picture's alt text, or marks it decorative, in `media.json`. |
|
|
2073
|
-
| `/admin/comments` | Pending, approved and spam, with approve, spam, delete and reply. |
|
|
2074
|
-
| `/admin/comments/moderate` | `POST` only. Approves one comment, files it as spam, or deletes it. |
|
|
2075
|
-
| `/admin/comments/reply` | `POST` only. Posts an approved reply under the comment it answers. |
|
|
2076
|
-
| `/admin/messages` | The contact form's inbox, with a Spam list beside it. |
|
|
2077
|
-
| `/admin/messages/read` | `POST` only. Marks one message read, or unread again. |
|
|
2078
|
-
| `/admin/messages/delete` | `POST` only. Deletes one message, and its file with it. |
|
|
2079
|
-
| `/admin/appearance/themes` | Appearance > Themes: the themes on disk. `POST` activates the one named. |
|
|
2080
|
-
| `/admin/plugins` | Plugins: every installed plugin, and Enable and Disable. |
|
|
2081
|
-
| `/admin/plugins/<package name>` | A running plugin's own screen, such as `/admin/plugins/@geekity/plugin-wordpress`. |
|
|
2082
|
-
| `/admin/tools` | Tools > Content index: what the index holds, and the button that rebuilds it. |
|
|
2083
|
-
| `/admin/tools/rebuild-index` | `POST` only. Offers the rebuild, then reads every file again on the live site. |
|
|
2084
|
-
| `/admin/settings` | Settings > General: title, tagline, author, base URL, time zone, language, locale, site icon. |
|
|
2085
|
-
| `/admin/settings/reading` | What the homepage displays, posts per page, the notify server. |
|
|
2086
|
-
| `/admin/settings/permalinks` | The tag and category bases, and the archive redirects already recorded. |
|
|
2087
|
-
| `/admin/settings/discussion` | Comments and the closing window, webmentions, and the Akismet key. |
|
|
2088
|
-
| `/admin/settings/akismet` | `POST` only. Saves the Akismet key, or forgets it. |
|
|
2089
|
-
| `/admin/settings/email` | The mail provider, the From line, the reply-to and the contact address. |
|
|
2090
|
-
| `/admin/settings/mail` | `POST` only. Saves a mail credential, or forgets every one of them. |
|
|
2091
|
-
| `/admin/settings/mail/test` | `POST` only. Sends the theme's test message through the whole chain. |
|
|
2092
|
-
| `/admin/users` | Who may sign in: a row each, with Edit and Delete. Nothing on it edits anybody. |
|
|
2093
|
-
| `/admin/users/new` | Users > Add new: the add form. `POST` adds one. |
|
|
2094
|
-
| `/admin/users/<id>` | One user: the account, the profile, the notices, your password, and the delete. |
|
|
2095
|
-
| `/admin/users/password` | `POST` only. Changes the signed-in admin's own password. |
|
|
2096
|
-
| `/admin/users/email` | `POST` only. Sets or clears the email address on the user the form names. |
|
|
2097
|
-
| `/admin/users/profile` | `POST` only. Saves the whole public profile of the user the form names. |
|
|
2098
|
-
| `/admin/users/notifications` | `POST` only. Turns one notice on or off for the user the form names. |
|
|
2099
|
-
| `/admin/users/notifications/mode` | `POST` only. Sets how often that notice arrives: as they arrive, hourly or daily. |
|
|
2100
|
-
| `/admin/users/delete` | `POST` only. Deletes the user the form names. |
|
|
2101
|
-
| `/admin/federation` | The actors, their followers, the inbox log, and per-post delivery. |
|
|
2102
|
-
| `/admin/federation/settings` | Federation > Settings: the relays the site subscribes to. |
|
|
2103
|
-
| `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
|
|
2104
|
-
| `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
|
|
2105
|
-
| `/admin/login` | Username and password. |
|
|
2106
|
-
| `/admin/logout` | `POST` only. Deletes the session row and sends `Clear-Site-Data`. |
|
|
2107
|
-
| `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
|
|
2108
|
-
|
|
2109
|
-
The screens behind the login share one layout: a bar across the top with the
|
|
2110
|
-
site name and a link to the public site, the menu down the left, and a place for
|
|
2111
|
-
flash messages. A message queued with `flash(c, 'notice', '…')` is kept on the
|
|
2112
|
-
session row, shown on the next page the browser asks for, and cleared as it is
|
|
2113
|
-
read, so it survives exactly one redirect.
|
|
2114
|
-
|
|
2115
|
-
### The admin's look
|
|
2116
|
-
|
|
2117
|
-
The admin is drawn in DaisyUI 5 on Tailwind v4 (decision-30). Its stylesheet,
|
|
2118
|
-
`admin/static/admin.css`, is compiled at build time from `admin/src/admin.css`
|
|
2119
|
-
and ships in the package like the default theme's. It carries every built-in
|
|
2120
|
-
DaisyUI theme, with `light` as the default and `dark` when the system prefers
|
|
2121
|
-
dark. A site cannot override it or add a theme of its own.
|
|
2122
|
-
|
|
2123
|
-
The admin bar is the same component on the admin and across the top of every
|
|
2124
|
-
public page a signed-in user reads: a `<geekity-admin-bar>` element holding a
|
|
2125
|
-
declarative shadow root, so neither the site theme nor the admin's theme
|
|
2126
|
-
reaches inside it. It draws in a light or a dark palette of its own, following
|
|
2127
|
-
the user's admin theme. On a public page it pushes the page down by
|
|
2128
|
-
`--geekity-admin-bar-height` and adds the `geekity-admin-bar` class to
|
|
2129
|
-
`<html>` for a theme that needs to make room of its own.
|
|
2130
|
-
|
|
2131
|
-
Each user picks the theme the admin is drawn in for themselves, on their own
|
|
2132
|
-
screen under Users: any built-in DaisyUI theme, or Follow the system. The
|
|
2133
|
-
choice is stored as `adminTheme` on the user in `data/users.json`, and every
|
|
2134
|
-
admin page drawn for that user carries it as `data-theme` on `<html>`. Following
|
|
2135
|
-
the system stores nothing and puts no `data-theme` on the page. The login,
|
|
2136
|
-
setup, forgot-password and reset screens never carry one.
|
|
2137
|
-
|
|
2138
|
-
### The menu
|
|
2139
|
-
|
|
2140
|
-
The menu is WordPress classic: twelve sections — Dashboard, Posts, Pages,
|
|
2141
|
-
Navigation, Media, Comments, Messages, Appearance, Users, Tools, Settings,
|
|
2142
|
-
Federation — each a heading over one or more children. Clicking a heading opens the section and lands on its first
|
|
2143
|
-
child; the open section shows its children and the one you are on carries
|
|
2144
|
-
`aria-current="page"`, so Users tells you that you are on Users > All users.
|
|
2145
|
-
Tags and categories are children of Posts, because a tag with no post on it is
|
|
2146
|
-
nothing. Every section has at least one child even when it has one screen, so
|
|
2147
|
-
the rule never needs an exception and a second child can appear later without
|
|
2148
|
-
the menu changing shape. There is no JavaScript in it: the server knows which
|
|
2149
|
-
section is open, so expanding one is a page rather than a script.
|
|
2150
|
-
|
|
2151
|
-
The whole menu is `ADMIN_SECTIONS` in `src/admin/menu.ts`. A site's own screen
|
|
2152
|
-
joins it in two steps:
|
|
2153
|
-
|
|
2154
|
-
```ts
|
|
2155
|
-
// 1. One entry in the section's children, naming the screen and its URL.
|
|
2156
|
-
{ child: 'imports', label: 'Imports', url: '/admin/imports' }
|
|
2157
|
-
|
|
2158
|
-
// 2. The same pair on whatever that screen renders.
|
|
2159
|
-
render(c, 'layouts/imports.njk', { section: 'posts', child: 'imports' });
|
|
2160
|
-
```
|
|
2161
|
-
|
|
2162
|
-
Nothing else changes: the heading, the landing URL and the marking all follow.
|
|
2163
|
-
A screen naming a section or a child the registry does not hold throws
|
|
2164
|
-
`UnknownAdminScreenError` rather than rendering a menu expanded around nothing,
|
|
2165
|
-
so the mistake is a failed test instead of a menu that quietly marks nothing.
|
|
2166
|
-
|
|
2167
|
-
Who may sign in is `data/users.json`: one entry per user with an id, a
|
|
2168
|
-
username, an argon2 hash and a created time — and, for a user who has them, an
|
|
2169
|
-
email address, notification preferences, a public profile and the
|
|
2170
|
-
[stored actor id](#a-users-stored-actor-id) they were published under
|
|
2171
|
-
elsewhere. Written atomically with `0600` permissions and serialised against
|
|
2172
|
-
itself, so two admins adding the same name at once cannot both succeed. It is in `data/` rather than `content/` because
|
|
2173
|
-
`content/` is published with the site, and it is one of the files under
|
|
2174
|
-
`dataDir` that must be backed up.
|
|
2175
|
-
|
|
2176
|
-
Sessions stay in SQLite, where the rest of the cache lives. They name a user by
|
|
2177
|
-
id and are joined against the file on every admin request, so a session whose
|
|
2178
|
-
user is no longer there is not a login: deleting `data/geekity.db` signs
|
|
2179
|
-
everybody out and loses nothing else, and restoring an old one cannot bring
|
|
2180
|
-
back an account that was deleted or a password that was changed.
|
|
2181
|
-
|
|
2182
|
-
Passwords are hashed with argon2id through `node:crypto`, so there is no native
|
|
2183
|
-
module to build. The cost parameters travel with each hash, which means raising
|
|
2184
|
-
them later leaves every password already stored verifiable.
|
|
2185
|
-
|
|
2186
|
-
A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/` cookie.
|
|
2187
|
-
When `baseUrl` is an `https` URL the cookie is `Secure` and named
|
|
2188
|
-
`__Host-geekity_session`; over plain http it is `geekity_session`, as
|
|
2189
|
-
[Session hardening](#session-hardening) explains. It lasts `sessionLifetime`;
|
|
2190
|
-
an expired session is deleted rather than merely ignored. Every mutating admin
|
|
2191
|
-
form carries a per-session CSRF token, and a `POST` without a valid one is
|
|
2192
|
-
refused with 403 — including the login and setup forms,
|
|
2193
|
-
which get the token from a short anonymous session created when the form is
|
|
2194
|
-
first rendered. Logging in throws that session away and starts a new one, so a
|
|
2195
|
-
planted session id cannot become a logged-in one.
|
|
2196
|
-
|
|
2197
|
-
The first admin comes from `/admin/setup` or from
|
|
2198
|
-
[`geekity user add`](#creating-an-admin-from-the-command-line). Every one after
|
|
2199
|
-
that comes from `/admin/users`, which lists who may sign in and adds a user
|
|
2200
|
-
with a password you supply or one it generates and shows once. The list is a
|
|
2201
|
-
listing and nothing more: a username with the archive URL under it, the email
|
|
2202
|
-
address, when the account was made, and Edit and Delete. Every field lives on
|
|
2203
|
-
that person's own screen at `/admin/users/<id>`, where each box has a label
|
|
2204
|
-
beside it — the account and its email address, the public profile, what this
|
|
2205
|
-
person is emailed about, your own password on your own page, and the delete or
|
|
2206
|
-
the sentence saying why this account cannot go. Each Save lands back on that
|
|
2207
|
-
screen, so it shows its own result. The last remaining user cannot be deleted,
|
|
2208
|
-
and nobody may delete their own account.
|
|
2209
|
-
|
|
2210
|
-
An email address is optional on a user and can be set on anybody's screen,
|
|
2211
|
-
since with one role every user already has every power there is. It buys
|
|
2212
|
-
[password recovery](#forgotten-passwords) and the notices
|
|
2213
|
-
[a comment sets off](#being-told-about-one), and it never appears on the public
|
|
2214
|
-
site. Beside it is which of those notices go to it: one switch per event, on by
|
|
2215
|
-
default, stored in `data/users.json` only when somebody turns one off.
|
|
2216
|
-
|
|
2217
|
-
A site whose database was written by a version that kept the accounts in a
|
|
2218
|
-
`users` table has those rows written into `data/users.json` on the first boot
|
|
2219
|
-
of this one — ids and hashes as they stand, so nobody has to log in again — and
|
|
2220
|
-
the table is dropped. A `users.json` that is already there wins and is left
|
|
2221
|
-
alone.
|
|
2222
|
-
|
|
2223
|
-
Creating one from your own code, which is what the CLI does:
|
|
2224
|
-
|
|
2225
|
-
```ts
|
|
2226
|
-
import { createUser } from '@geekity/cms';
|
|
2227
|
-
|
|
2228
|
-
await createUser({
|
|
2229
|
-
dataDir: 'data',
|
|
2230
|
-
username: 'ada',
|
|
2231
|
-
password: process.env.PASSWORD ?? '',
|
|
2232
|
-
});
|
|
2233
|
-
```
|
|
2234
|
-
|
|
2235
|
-
### Settings
|
|
2236
|
-
|
|
2237
|
-
`content/_data/site.json` is the source of truth for a site's settings. The
|
|
2238
|
-
pages under `/admin/settings` — General, Reading, Permalinks, Discussion and
|
|
2239
|
-
Email — each read that file, validate what was typed and write it back; nothing
|
|
2240
|
-
else remembers a setting, and `data/geekity.db` holds none of them. Federation's
|
|
2241
|
-
page is a sixth one of exactly the same kind, filed under its own section at
|
|
2242
|
-
`/admin/federation/settings` rather than under Settings, because the relays are
|
|
2243
|
-
about the section that holds the followers.
|
|
2244
|
-
|
|
2245
|
-
Each page saves its own fields and no others, onto the file as re-read inside
|
|
2246
|
-
the write, so two people saving two different pages at the same moment both
|
|
2247
|
-
land, and each page validates only what it shows: a refused save comes back on
|
|
2248
|
-
the page it was sent from and writes nothing at all.
|
|
2249
|
-
|
|
2250
|
-
The write is atomic and serialised: the bytes go to a temporary file beside the
|
|
2251
|
-
real one and are renamed over it, and the read of what the file already held
|
|
2252
|
-
and the write of what it says next are one step nothing else writing that file
|
|
2253
|
-
can get between. So a reader — an Eleventy build, the CMS's own site data
|
|
2254
|
-
source, another process entirely — always sees one whole version, and two saves
|
|
2255
|
-
at once cannot each keep half of what the other kept.
|
|
2256
|
-
|
|
2257
|
-
The file carries `title`, `tagline`, `url`, `author` (a username, when the
|
|
2258
|
-
site has one author), `postsPerPage`, `homepage`, `postsPage`,
|
|
2259
|
-
`timezone`, `language`, `locale` (when it is set), `icon` (when it is set),
|
|
2260
|
-
`license` and `licenseName` (when a license is chosen; see below), `tagBase`,
|
|
2261
|
-
`categoryBase`, `notifyServer`, `feedUpdatePeriod`, `feedUpdateFrequency`,
|
|
2262
|
-
`webmentionsSend`, `webmentionsReceive`,
|
|
2263
|
-
`mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
|
|
2264
|
-
`contactEmail`, `securityContacts`, `securityPolicy`, `securityLanguages`,
|
|
2265
|
-
`relays`, `menus` and `taxonomyRedirects`,
|
|
2266
|
-
and every other key it already had is kept, `feedSize` and anything a site put
|
|
2267
|
-
there included. A key it does not carry is the default, and a key of the wrong
|
|
2268
|
-
type is the default too: a hand-edited `site.json` cannot take the site down.
|
|
2269
|
-
|
|
2270
|
-
`homepage` and `postsPage` are the only two written just when they have a
|
|
2271
|
-
value: the slug of the page served at `/` and the slug of the page whose own
|
|
2272
|
-
URL carries the post listing, absent altogether on a site that shows its latest
|
|
2273
|
-
posts at `/`. A `postsPage` without a `homepage` is
|
|
2274
|
-
ignored, the listing being at `/` already, and a slug naming no published page
|
|
2275
|
-
is a site back on its latest posts. See [The front page](#the-front-page).
|
|
2276
|
-
|
|
2277
|
-
Because it is the source rather than a copy, editing it by hand while the
|
|
2278
|
-
server runs is picked up on the next request — on the public site, in the
|
|
2279
|
-
feeds and on the settings screen alike — with nothing
|
|
2280
|
-
to restart and nothing to tell. Put the file in git and a checkout of it is the
|
|
2281
|
-
site's settings.
|
|
2282
|
-
|
|
2283
|
-
The one exception is `url`. A base URL decides the absolute URLs in the feeds,
|
|
2284
|
-
the ActivityStreams ids and whether the session cookie is `Secure`, so
|
|
2285
|
-
`GEEKITY_BASE_URL` and a `baseUrl` in the config file both win over the file's,
|
|
2286
|
-
and the General settings page renders the field read-only and says which value
|
|
2287
|
-
is in effect and why. When neither names one, the file's `url` becomes the base URL
|
|
2288
|
-
at boot — at boot rather than on save, so an `https` base URL cannot log out
|
|
2289
|
-
the admin who submitted it over `http`.
|
|
2290
|
-
|
|
2291
|
-
Two things on that screen are not settings and are not in that file: the
|
|
2292
|
-
Akismet key and the mail credential. Both are credentials, both live under
|
|
2293
|
-
`data/` at mode `0600`, and each is its own pair of forms — save and remove —
|
|
2294
|
-
so a key typed wrong cannot lose an edit to the title. See
|
|
2295
|
-
[Akismet](#akismet) and [Email](#email).
|
|
2296
|
-
|
|
2297
|
-
A site whose database was written by a version that kept the settings in SQLite
|
|
2298
|
-
has those rows written into `site.json` on the first boot of this one, and the
|
|
2299
|
-
table is dropped. If the file was written after the rows were — a hand edit, or
|
|
2300
|
-
a content directory restored from git — the file wins outright.
|
|
2301
|
-
|
|
2302
|
-
### The front page
|
|
2303
|
-
|
|
2304
|
-
`/` is the site's latest posts until the Reading settings say otherwise. The
|
|
2305
|
-
choice is WordPress's own, and so are its two answers:
|
|
2306
|
-
|
|
2307
|
-
- **Your latest posts.** The archive at `/`, paginated at `/page/N/`. This is
|
|
2308
|
-
the default, and it is what an empty `homepage` means.
|
|
2309
|
-
- **A static page.** The page is served at `/`, its own permalink answers `301`
|
|
2310
|
-
to `/` so the front page has one URL, and the menu links it at `/`. The
|
|
2311
|
-
default theme draws it with `layouts/front-page.njk`, which prints the
|
|
2312
|
-
page's own title and words and nothing else (decision-44). It lists no
|
|
2313
|
-
posts, prints no links and prints no bio.
|
|
2314
|
-
|
|
2315
|
-
A site that wants more on its front page, such as its newest posts or links
|
|
2316
|
-
to its archives, overrides `layouts/front-page.njk` in its own theme. The
|
|
2317
|
-
front page's context carries `newestPosts(count)`, a function that returns the
|
|
2318
|
-
newest `count` published posts as the same entries a listing prints. Drafts,
|
|
2319
|
-
future-dated, unlisted and trashed posts are left out. The theme chooses the
|
|
2320
|
-
count, and the query runs only when the theme calls it. For example, this
|
|
2321
|
-
override lists the five newest posts under the page's words:
|
|
2322
|
-
|
|
2323
|
-
```njk
|
|
2324
|
-
{% extends "layouts/base.njk" %}
|
|
2325
|
-
|
|
2326
|
-
{% block content %}
|
|
2327
|
-
<div class="page-body e-content">{{ content | safe }}</div>
|
|
2328
|
-
|
|
2329
|
-
<h2 class="section-title">Recent Posts</h2>
|
|
2330
|
-
{% set posts = newestPosts(5) %}
|
|
2331
|
-
{% set feedHeading = 3 %}
|
|
2332
|
-
{% include "partials/post-list.njk" %}
|
|
2333
|
-
{% endblock %}
|
|
2334
|
-
```
|
|
2335
|
-
|
|
2336
|
-
On a solo author site the default theme's base layout puts the author's
|
|
2337
|
-
`rel="me"` claims in the head of `/` as `<link rel="me">` elements, one for
|
|
2338
|
-
their author archive and one for each profile link, whatever the front page
|
|
2339
|
-
prints (decision-45). A static front page prints no h-card. A site that wants
|
|
2340
|
-
its homepage to carry a representative h-card writes one in the homepage's
|
|
2341
|
-
words, or includes `partials/bio.njk` with `bioHome` set in its override, as
|
|
2342
|
-
the [theme README](./themes/default/README.md#the-front-page) shows.
|
|
2343
|
-
|
|
2344
|
-
With a homepage set, a second pick gives the listing a page of its own: the
|
|
2345
|
-
**posts page**. Its permalink carries the listing, with the page's own title
|
|
2346
|
-
and words above the posts, paginated beneath it at `{permalink}page/N/`;
|
|
2347
|
-
`/page/N/` at the root redirects there, and the theme's `layouts/posts-page.njk` is
|
|
2348
|
-
the theme's override, falling back to the listing layout. A posts page with no
|
|
2349
|
-
homepage is refused, as WordPress refuses it, and so is one page picked as
|
|
2350
|
-
both. With a homepage and no posts page the listing has no page of its own,
|
|
2351
|
-
which is WordPress's answer too.
|
|
2352
|
-
|
|
2353
|
-
The feeds do not move: `/feed/`, `/feed/atom/` and `/feed/json/` syndicate the
|
|
2354
|
-
site's posts wherever the listing is read, and every page advertises them. The
|
|
2355
|
-
sitemap follows the site — `/` once, and the listing's pages under the posts
|
|
2356
|
-
page — and the pages list marks the two rows **Front Page** and **Posts Page**.
|
|
2357
|
-
|
|
2358
|
-
Both settings are page slugs in `content/_data/site.json`, so an Eleventy build
|
|
2359
|
-
of the same directory shows the same front page:
|
|
2360
|
-
`docs/eleventy.config.example.js` puts the homepage at `/` and flags the posts
|
|
2361
|
-
page on the context as `isPostsPage`. A slug whose page is drafted, trashed or
|
|
2362
|
-
deleted names nothing published, and the site is back to its latest posts with
|
|
2363
|
-
the pick kept: publishing the page again puts the front page back.
|
|
2364
|
-
|
|
2365
|
-
### The media library
|
|
2366
|
-
|
|
2367
|
-
`/admin/media` lists every file under `content/uploads`, newest first by
|
|
2368
|
-
modification time, with a thumbnail for a picture and its extension at the same
|
|
2369
|
-
size for anything else, the size in bytes, the date, and — for a library that
|
|
2370
|
-
has grown — a pager, twenty-four files to a page.
|
|
2371
|
-
|
|
2372
|
-
The list is a walk of the directory on every request rather than a query.
|
|
2373
|
-
Nothing records that an upload exists: the filesystem is the truth
|
|
2374
|
-
(decision-1, decision-9), so a file copied in over ssh, pulled in by git, or
|
|
2375
|
-
written by an Eleventy build is on the screen without a restart, and one
|
|
2376
|
-
removed the same way is off it. The only question the index is asked is which
|
|
2377
|
-
documents mention a URL, which is the one thing a directory cannot answer.
|
|
2378
|
-
|
|
2379
|
-
Each row carries the public URL and the ready-made Markdown — ``
|
|
2380
|
-
for a picture, with its alt text from the library or empty when it has none or
|
|
2381
|
-
is decorative, and `[name](url)` for anything else, from the same function the
|
|
2382
|
-
editor's upload control pastes from — as readonly text fields, which select and
|
|
2383
|
-
copy on their own. `admin/static/copy.js` reveals a Copy button beside each and
|
|
2384
|
-
does nothing else, so the screen is complete before it loads and a browser
|
|
2385
|
-
without the clipboard API is not shown a button that would fail.
|
|
2386
|
-
|
|
2387
|
-
The upload form is `storeUpload`, the same function behind `POST /admin/uploads`
|
|
2388
|
-
alike: one allowlist, one signature check, one
|
|
2389
|
-
`{yyyy}/{mm}/{slug}{ext}` naming rule, and one set of refusals, shown as a flash
|
|
2390
|
-
on the next page rather than as the editor's JSON.
|
|
2391
|
-
|
|
2392
|
-
Delete asks when it should. A file nothing points at is deleted on the first
|
|
2393
|
-
click. A file a document still mentions — anywhere in its body, as Markdown, as
|
|
2394
|
-
HTML or in prose — brings back a confirmation naming every document that does,
|
|
2395
|
-
linked to its editor and marked when it is in the trash, and only a form
|
|
2396
|
-
carrying `confirm` goes through. The trash counts because a trashed post can be
|
|
2397
|
-
restored, and restoring one whose picture went in the meantime is a broken post
|
|
2398
|
-
nobody was warned about. The submitted path is resolved against
|
|
2399
|
-
`content/uploads` and refused when it lands outside, so a form field cannot
|
|
2400
|
-
name a file elsewhere in the content directory.
|
|
2401
|
-
|
|
2402
|
-
`deleteUpload` takes a `removeDerived` hook, called with the same
|
|
2403
|
-
content-relative path once the original is gone. The original under
|
|
2404
|
-
`content/uploads` is the only source of truth (decision-10) and anything
|
|
2405
|
-
generated from it is derived state that must not outlive it; the hook defaults
|
|
2406
|
-
to `removeImageVariants`, which takes the file's derived images with it. The
|
|
2407
|
-
file's entry in `content/_data/media.json` goes too.
|
|
2408
|
-
|
|
2409
|
-
A picture's row also carries its alt text: a text field, a Decorative checkbox
|
|
2410
|
-
that wins over the text, and a line saying which of the two the library holds
|
|
2411
|
-
or that it holds neither. The form posts to `/admin/media/alt`, which writes
|
|
2412
|
-
`content/_data/media.json` atomically, keeping every other entry. The file is
|
|
2413
|
-
the truth (decision-9) and is read on each request, so an entry written by hand
|
|
2414
|
-
shows up without a restart. `readAltTexts(contentDir)` and
|
|
2415
|
-
`undescribedImages(html, library)` in `src/images/alt-text.ts` are what the
|
|
2416
|
-
editor's publish check and the federated `Image` attachments read.
|
|
2417
|
-
|
|
2418
|
-
#### Location and camera metadata
|
|
2419
|
-
|
|
2420
|
-
A phone photo carries where it was taken, when, and on what camera, and the
|
|
2421
|
-
original under `content/uploads` is what the site serves at `/uploads/…`, puts
|
|
2422
|
-
in every `<picture>` as the fallback, and federates as a photo post's
|
|
2423
|
-
attachment. So `storeUpload` removes that metadata before the file is written,
|
|
2424
|
-
for every upload: the editor's control, the media library, the Micropub media
|
|
2425
|
-
endpoint and Micropub photos. It is not a setting.
|
|
2426
|
-
|
|
2427
|
-
| Format | What goes | What stays |
|
|
2428
|
-
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
2429
|
-
| JPEG | EXIF (GPS, camera, lens, serial, time), XMP, IPTC, comments, other APPn segments, data after the image | JFIF, the ICC profile, Adobe's colour flag, the orientation |
|
|
2430
|
-
| PNG | `eXIf`, `tEXt`, `zTXt`, `iTXt` (XMP), `tIME` and private chunks | Image and colour chunks, APNG animation, the orientation |
|
|
2431
|
-
| WebP | `EXIF`, `XMP ` and unknown chunks | The bitstream, alpha, animation, ICC profile, the orientation |
|
|
2432
|
-
| AVIF | The contents of the Exif and XMP items, overwritten with an empty EXIF block and an empty XMP packet | The picture, and its `irot`/`imir` orientation |
|
|
2433
|
-
| GIF | Comment extensions and application extensions other than looping and ICC (XMP lives in one) | Frames, timing, looping |
|
|
2434
|
-
| MP4, M4V | `udta` (the `©xyz` location, camera), `meta` (the QuickTime location key) and `uuid` (XMP) boxes, in the movie, its tracks and at the top level | Every other box |
|
|
2435
|
-
|
|
2436
|
-
Nothing is re-encoded. Segments, chunks and blocks are cut out and the pixel
|
|
2437
|
-
data is copied byte for byte, so a stored JPEG or PNG decodes to the same
|
|
2438
|
-
pixels as the upload. A picture taken sideways keeps a minimal EXIF block
|
|
2439
|
-
holding its orientation and nothing else, so it still shows the right way up.
|
|
2440
|
-
AVIF and MP4 keep their layout exactly: the metadata is overwritten in place
|
|
2441
|
-
and a removed box becomes a zero-filled `free` box of the same size, so no
|
|
2442
|
-
offset inside the file moves. A file whose structure cannot be followed far
|
|
2443
|
-
enough to find its metadata is refused with a 415 rather than stored as it is.
|
|
2444
|
-
|
|
2445
|
-
Audio (MP3, M4A, Ogg), WebM, PDF and the text formats are stored as they
|
|
2446
|
-
arrive. An episode's tags are its title and artwork, not where it was
|
|
2447
|
-
recorded.
|
|
2448
|
-
|
|
2449
|
-
Files uploaded before this version still carry their metadata. Strip them
|
|
2450
|
-
with
|
|
2451
|
-
|
|
2452
|
-
```sh
|
|
2453
|
-
geekity strip-metadata
|
|
2454
|
-
```
|
|
2455
|
-
|
|
2456
|
-
which rewrites each file under `content/uploads` that has something to remove,
|
|
2457
|
-
prints what it removed, keeps the file's modification time (the library sorts
|
|
2458
|
-
by it), and leaves alone, and names, a file it cannot read, exiting `1`. It is
|
|
2459
|
-
safe to run again: a clean file is left as it is, and a second run reports
|
|
2460
|
-
every file already clean. The derived variants under `data/images` never
|
|
2461
|
-
carried metadata.
|
|
2462
|
-
|
|
2463
|
-
A site that keeps `content/` in git still has the old bytes in its history,
|
|
2464
|
-
and so does every clone and fork of it. Removing them means rewriting that
|
|
2465
|
-
history. One way: run `geekity strip-metadata`, copy `content/uploads`
|
|
2466
|
-
somewhere outside the repository, remove it from every commit with
|
|
2467
|
-
[`git filter-repo`](https://github.com/newren/git-filter-repo)
|
|
2468
|
-
`--path content/uploads --invert-paths`, copy the stripped files back, commit
|
|
2469
|
-
them, and force-push. That changes every commit hash, so anybody else with a
|
|
2470
|
-
clone has to clone again.
|
|
2471
|
-
|
|
2472
|
-
### Image variants
|
|
2473
|
-
|
|
2474
|
-
`generateImageVariants(config, uploadPath)` derives one upload's copies with
|
|
2475
|
-
sharp and writes them, plus an `image.json` sidecar, into
|
|
2476
|
-
`<dataDir>/images/<the upload's path>/`. It runs inside `storeUpload`, so the
|
|
2477
|
-
editor's control and the media library both produce the same
|
|
2478
|
-
variants for the same file and the encoding is paid for by whoever uploaded it.
|
|
2479
|
-
It answers `undefined`, having written nothing, for a GIF, an animation, a PDF,
|
|
2480
|
-
a text file, an upload that is not there, and a site with `imageOptimization`
|
|
2481
|
-
off — every one of which means "serve the original", which is what the site
|
|
2482
|
-
would have done anyway.
|
|
2483
|
-
|
|
2484
|
-
| Function | What it is for |
|
|
2485
|
-
| ---------------------------------- | ------------------------------------------------------------------------------------ |
|
|
2486
|
-
| `generateImageVariants` | Derive the whole set and write the sidecar. Two callers at once share one encode. |
|
|
2487
|
-
| `describeImage` | The record, synchronously and cached, for a renderer that must not open a file. |
|
|
2488
|
-
| `findImageVariant` | One derived file by request path, generating it when it is missing. |
|
|
2489
|
-
| `removeImageVariants` | Take a source's derived directory away. |
|
|
2490
|
-
| `responsiveImages(html, describe)` | Rewrite `<img src="/uploads/…">` as `<picture>`. Pure: hand it any lookup. |
|
|
2491
|
-
| `siteImageMarkup(config, html)` | The same, over one site's own records, deriving in the background for a record miss. |
|
|
2492
|
-
| `siteIcons(config, setting)` | The head's icon links for a site's icon setting: `{ rel, sizes, href }`, or none. |
|
|
2493
|
-
|
|
2494
|
-
`describeImage` is synchronous and cached because it is called once per image
|
|
2495
|
-
while a page renders, and decision-10 forbids probing an image file at render
|
|
2496
|
-
time. Its cache entry outlives the sidecar on purpose: `data/images/` is
|
|
2497
|
-
disposable, so a page whose variants have been swept away keeps rendering the
|
|
2498
|
-
markup that asks for them back, and the first request for each one rebuilds it.
|
|
2499
|
-
|
|
2500
|
-
The site's icons are derived copies of the same kind, and `siteIcons` is how a
|
|
2501
|
-
head gets at them: three square PNGs, `icon` at 32 and 16 pixels and
|
|
2502
|
-
`apple-touch-icon` at 180, cropped from the middle of the site's icon (the
|
|
2503
|
-
`icon` that Settings > General writes, else a hand-set `avatar`) and
|
|
2504
|
-
encoded the first time a browser asks for one. It answers an empty list — and
|
|
2505
|
-
the packaged theme then links no icon at all — when the site has no icon, when
|
|
2506
|
-
its icon is a file no icon can be made of, or when `imageOptimization` is off,
|
|
2507
|
-
so a head never advertises a URL this server would answer 404 for.
|
|
2508
|
-
|
|
2509
|
-
`documentContext(document, images)` is where the rewrite is applied. Passing the
|
|
2510
|
-
config is what turns the page's `content` into `<picture>` markup; the feeds,
|
|
2511
|
-
`documentJson`, the Markdown representation and `postObject` all read
|
|
2512
|
-
`document.html` directly and so keep the plain `<img>` by construction.
|
|
2513
|
-
|
|
2514
|
-
### Managing tags and categories
|
|
2515
|
-
|
|
2516
|
-
`/admin/tags` and `/admin/categories` list every term in use with two counts and
|
|
2517
|
-
a link to its archive: **Posts**, what the public site lists under it, and
|
|
2518
|
-
**Files**, every file carrying it — drafts, scheduled posts and the trash
|
|
2519
|
-
included. The second is the one to look at before acting, because it is what an
|
|
2520
|
-
action is about to rewrite.
|
|
2521
|
-
|
|
2522
|
-
Three things can be done to a term, and all three are the same operation. Typing
|
|
2523
|
-
a new name into a row's field and pressing Rename rewrites the front matter of
|
|
2524
|
-
every file carrying it. Renaming onto a term that already exists is a **merge**:
|
|
2525
|
-
the screen comes back with a question naming both counts, and only a confirmed
|
|
2526
|
-
form goes through, because a merge cannot be undone by renaming back. A file
|
|
2527
|
-
that carried both terms keeps the target once, in the place it already had.
|
|
2528
|
-
Delete takes the term out of every file and leaves everything else alone.
|
|
2529
|
-
|
|
2530
|
-
Each of them says how many files it wrote. The files are the truth (doc-1), so
|
|
2531
|
-
each file is re-read and re-parsed from disk before it is rewritten rather than
|
|
2532
|
-
being taken from the index: the index can be a moment behind a hand edit, and a
|
|
2533
|
-
bulk rewrite that trusted it would put that edit back. A file that has gone or
|
|
2534
|
-
will not parse is skipped, counted, and named in the message.
|
|
2535
|
-
|
|
2536
|
-
Every rewrite is announced the way an editor save is, so the index, the feeds,
|
|
2537
|
-
the notify server and the fediverse all follow — one `Update` per
|
|
2538
|
-
affected published post, because the hashtags on those posts have just changed.
|
|
2539
|
-
A term on many published posts is therefore many deliveries; that is the point,
|
|
2540
|
-
but it is worth knowing before renaming a tag that half the archive carries.
|
|
2541
|
-
|
|
2542
|
-
A rename moves an archive, and an archive is a URL somebody may have linked to,
|
|
2543
|
-
so the site records the move in a `taxonomyRedirects` list — `{ taxonomy, from,
|
|
2544
|
-
to }` — in `content/_data/site.json` with the rest of the settings. The
|
|
2545
|
-
old archive URL and its feed then answer `301` to the new ones. Chains are
|
|
2546
|
-
collapsed as they are recorded, so `a → b` followed by `b → c` is stored as
|
|
2547
|
-
`a → c` and answered in one hop; a term that comes back into use is served
|
|
2548
|
-
rather than redirected; and deleting a term drops every record pointing at it,
|
|
2549
|
-
because a redirect to a 404 is worse than the 404.
|
|
2550
|
-
|
|
2551
|
-
### Login hardening
|
|
2552
|
-
|
|
2553
|
-
`/admin/login` counts failed sign-ins against two keys: the username that was
|
|
2554
|
-
typed, and the address the request came from. Once either has failed
|
|
2555
|
-
`loginAttempts` times — five by default — the admin refuses further attempts
|
|
2556
|
-
from that key for `loginLockout` seconds, fifteen minutes by default, and
|
|
2557
|
-
answers `429` with a `Retry-After` header and a message saying how long is
|
|
2558
|
-
left.
|
|
2559
|
-
|
|
2560
|
-
Three things about that are deliberate:
|
|
2561
|
-
|
|
2562
|
-
- **The lockout is checked before the password is.** A correct password during
|
|
2563
|
-
a lockout is refused too. Verifying first would hand the site to whoever
|
|
2564
|
-
guessed right on the attempt that tripped the limit.
|
|
2565
|
-
- **The wait doubles each time the lockout is tripped again**, up to sixteen
|
|
2566
|
-
times the first one — four hours at the default. There is no artificial pause
|
|
2567
|
-
on an individual attempt: a `sleep` in the handler holds a connection open for
|
|
2568
|
-
as long as it lasts, so it would cost the server more than the attacker.
|
|
2569
|
-
- **The message says nothing about who exists.** A username nobody has is
|
|
2570
|
-
counted and locked out exactly as a real one is, so the lockout cannot be used
|
|
2571
|
-
to enumerate accounts, and a wrong password and an unknown username still read
|
|
2572
|
-
the same.
|
|
2573
|
-
|
|
2574
|
-
A sign-in that works clears both keys, and so does time: a key that stops
|
|
2575
|
-
failing is forgotten. The counts live in memory, so a restart clears them.
|
|
2576
|
-
That is the point — a failed login writes nothing an anonymous caller could
|
|
2577
|
-
grow, and the run of guesses this defends against happens over minutes, which
|
|
2578
|
-
no restart interrupts. Both refusals are logged with the username and the
|
|
2579
|
-
address.
|
|
2580
|
-
|
|
2581
|
-
`trustProxy` decides where the address comes from. It is `false` by default and
|
|
2582
|
-
the address is the socket's own, because anybody may send `X-Forwarded-For`: on
|
|
2583
|
-
a site reached directly, believing it would let one attacker put every guess on
|
|
2584
|
-
a different make-believe address and never be locked out by address at all. Turn
|
|
2585
|
-
it on — and only on — when a reverse proxy in front of the site sets that
|
|
2586
|
-
header; the leftmost entry is then used. With neither available, which is what
|
|
2587
|
-
happens when the app is driven in process rather than served, the username is
|
|
2588
|
-
the only key.
|
|
2589
|
-
|
|
2590
|
-
### Session hardening
|
|
2591
|
-
|
|
2592
|
-
Three things protect a signed-in session beyond the cookie flags and the CSRF
|
|
2593
|
-
token.
|
|
2594
|
-
|
|
2595
|
-
**The cookie is `__Host-geekity_session` under https.** A browser accepts a
|
|
2596
|
-
cookie with the `__Host-` prefix only when it is `Secure`, has `Path=/` and
|
|
2597
|
-
names no `Domain`. That binds it to the exact host, so a page on a sibling
|
|
2598
|
-
subdomain cannot set or overwrite it to plant a session. The prefix needs
|
|
2599
|
-
`Secure`, and a browser drops a `Secure` cookie on a plain-http origin, so a
|
|
2600
|
-
site whose `baseUrl` is `http://localhost:3000` keeps the name
|
|
2601
|
-
`geekity_session` and local development works as before.
|
|
2602
|
-
|
|
2603
|
-
**The upgrade signs people out once.** A session made before this change is in
|
|
2604
|
-
a cookie named `geekity_session`. Under https that name is never accepted as a
|
|
2605
|
-
login again, because accepting it would give a sibling subdomain the way in
|
|
2606
|
-
that the prefix closes. The first admin request that carries only the old
|
|
2607
|
-
cookie deletes its session, expires the old cookie, and sends the browser to
|
|
2608
|
-
the login form with a message that the sign-in cookie was renamed and asks the
|
|
2609
|
-
person to sign in again. On the public site the old cookie is ignored, so a
|
|
2610
|
-
signed-in commenter sees the stranger's form until they sign in again.
|
|
2611
|
-
|
|
2612
|
-
**Fetch Metadata refuses cross-site writes.** A browser sends
|
|
2613
|
-
`Sec-Fetch-Site` with every request, and a page cannot change it. Every
|
|
2614
|
-
request under `/admin` other than `GET`, `HEAD` and `OPTIONS`, including the
|
|
2615
|
-
login and setup forms, is refused with 403 when that header says `cross-site`
|
|
2616
|
-
or `same-site`, before the session is read or a handler runs. A comment posted
|
|
2617
|
-
by somebody signed in gets the same check. `same-origin` and `none` pass. A
|
|
2618
|
-
request with no `Sec-Fetch-Site`, from an older browser or a script, falls back
|
|
2619
|
-
to the CSRF token, which every such request still has to carry. Nothing else
|
|
2620
|
-
is checked: the ActivityPub inboxes, the WordPress-compatible inbox,
|
|
2621
|
-
webmentions, the contact form, a stranger's comment, and the one-click
|
|
2622
|
-
moderate and unsubscribe links in an email take cross-site `POST`s by design,
|
|
2623
|
-
and they act on no session.
|
|
2624
|
-
|
|
2625
|
-
**Logout clears the site's data in the browser.** The logout response sends
|
|
2626
|
-
`Clear-Site-Data: "cache", "cookies", "storage"`, so a shared computer does not
|
|
2627
|
-
keep admin pages in its cache or anything the site stored. Browsers honour it
|
|
2628
|
-
only on a secure origin, which includes `http://localhost`. The `cookies`
|
|
2629
|
-
directive clears cookies for the whole registrable domain, so signing out of
|
|
2630
|
-
`blog.example.com` also signs the browser out of other sites under
|
|
2631
|
-
`example.com`.
|
|
2632
|
-
|
|
2633
|
-
### Forgotten passwords
|
|
2634
|
-
|
|
2635
|
-
`/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
|
|
2636
|
-
which takes a username _or_ an email address and always answers with the same
|
|
2637
|
-
sentence — whether the name matched, did not match, or matched somebody who has
|
|
2638
|
-
no address on their account. That is the point of the screen: an answer that
|
|
2639
|
-
varied with what it found would be a list of which accounts the site has,
|
|
2640
|
-
handed to anybody who asked for it.
|
|
2641
|
-
|
|
2642
|
-
A match with an address is sent the theme's `password-reset` message, carrying
|
|
2643
|
-
a link to `/admin/reset?token=…`. The token is 256 random bits; the database
|
|
2644
|
-
keeps only its SHA-256, in a table beside the sessions, so a copy of
|
|
2645
|
-
`data/geekity.db` is not a stack of working links, and the token is never
|
|
2646
|
-
rendered into the page of the browser that asked for it. It lasts an hour and
|
|
2647
|
-
works once.
|
|
2648
|
-
|
|
2649
|
-
Setting a password through the link holds it to the same rules every other door
|
|
2650
|
-
does, then closes everything else: the link that was used, every other reset
|
|
2651
|
-
that user had outstanding, and every session they were signed in on. The
|
|
2652
|
-
theme's `password-changed` message goes out afterwards, with no link back in,
|
|
2653
|
-
so a reset somebody did not ask for is something they hear about.
|
|
2654
|
-
|
|
2655
|
-
Requests are rate limited by username and by address exactly as sign-ins are —
|
|
2656
|
-
same limits, same growing lockout, same `429` and `Retry-After` — on a throttle
|
|
2657
|
-
of its own. Sharing the login one would let a stranger lock somebody out of
|
|
2658
|
-
signing in by asking for their password to be reset over and over.
|
|
2659
|
-
|
|
2660
|
-
Reset tokens live in the database, which is a cache: a restart, a
|
|
2661
|
-
`geekity rebuild` or a deleted `data/geekity.db` invalidates every link in
|
|
2662
|
-
flight, and the cost of that is a second click on Forgot password.
|
|
2663
|
-
|
|
2664
|
-
With no mail provider or credential, `/admin/forgot` offers no form at all. It
|
|
2665
|
-
says the site cannot send email and points at
|
|
2666
|
-
[`geekity user add`](#creating-an-admin-from-the-command-line), which is how a
|
|
2667
|
-
site nobody can sign in to gets a fresh admin from a shell. Saving a credential
|
|
2668
|
-
under [Settings → Email](#email) turns the form on for the next visitor, with
|
|
2669
|
-
no restart.
|
|
2670
|
-
|
|
2671
|
-
### Security headers
|
|
2672
|
-
|
|
2673
|
-
Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
|
|
2674
|
-
`Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
|
|
2675
|
-
is an `https` URL — the same test that decides whether the session cookie is
|
|
2676
|
-
`Secure`, so the two cannot disagree. Neither can be turned off.
|
|
2677
|
-
|
|
2678
|
-
Every response also carries these, public pages, feeds, uploads, ActivityPub
|
|
2679
|
-
JSON, redirects, 404s, the maintenance 503 and the 500 page alike:
|
|
2680
|
-
|
|
2681
|
-
| Header | Default |
|
|
2682
|
-
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2683
|
-
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
|
2684
|
-
| `Content-Security-Policy` | `frame-ancestors 'self'` |
|
|
2685
|
-
| `X-Frame-Options` | `SAMEORIGIN` |
|
|
2686
|
-
| `Permissions-Policy` | `browsing-topics=(), camera=(), display-capture=(), geolocation=(), hid=(), microphone=(), midi=(), payment=(), serial=(), usb=(), xr-spatial-tracking=()` |
|
|
2687
|
-
| `Cross-Origin-Opener-Policy` | `same-origin` |
|
|
2688
|
-
|
|
2689
|
-
None of them limits what a page may load, and that is the line the CMS holds
|
|
2690
|
-
on the public site. There is no content policy, no `default-src`, `script-src`
|
|
2691
|
-
or `img-src`, because a theme is somebody else's HTML and the CMS has no
|
|
2692
|
-
business deciding what it may reference. `frame-ancestors` is the only
|
|
2693
|
-
directive in the policy, and it is about who may put the page in a frame, not
|
|
2694
|
-
what is in it. Each header does something else:
|
|
2695
|
-
|
|
2696
|
-
- `Referrer-Policy` sends only the origin, not the full URL, when a reader
|
|
2697
|
-
follows a link to another site, so the path and query of the page they were
|
|
2698
|
-
on stay on the site.
|
|
2699
|
-
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` stop another site
|
|
2700
|
-
from framing a page. That matters most for a signed-in user, whose pages carry
|
|
2701
|
-
a comment form that posts as them, but it covers every page because the CMS
|
|
2702
|
-
cannot tell a framed reader from a framed user.
|
|
2703
|
-
- `Permissions-Policy` turns off browser features a blog has no use for. It
|
|
2704
|
-
leaves alone what an embedded video player asks for: autoplay, fullscreen,
|
|
2705
|
-
`encrypted-media`, picture-in-picture, and the motion sensors behind a
|
|
2706
|
-
360-degree video.
|
|
2707
|
-
The post editor is the one response that allows more: it sends the site's
|
|
2708
|
-
policy with `geolocation=()` changed to `geolocation=(self)`, so its Use my
|
|
2709
|
-
location button can ask the browser where it is.
|
|
2710
|
-
- `Cross-Origin-Opener-Policy: same-origin` keeps a window the site opens, or a
|
|
2711
|
-
window that opens the site, from reaching into it through `window.opener`.
|
|
2712
|
-
|
|
2713
|
-
Two of them can get in the way of something a site does on purpose. A site that
|
|
2714
|
-
is meant to be framed elsewhere, such as in a portfolio or a slide deck, has to
|
|
2715
|
-
name that host in `frame-ancestors` and remove `X-Frame-Options`, which cannot
|
|
2716
|
-
name another host. A theme that signs readers in or takes payment through a
|
|
2717
|
-
popup on another origin needs `same-origin-allow-popups` for COOP. The CMS
|
|
2718
|
-
itself does neither.
|
|
2719
|
-
|
|
2720
|
-
One response is framed elsewhere on purpose: the embed view at
|
|
2721
|
-
`/_geekity/embed?url=…`, the card that the oEmbed `html` frames so that
|
|
2722
|
-
WordPress keeps it. It sends a complete policy of its own instead of the
|
|
2723
|
-
default: `frame-ancestors *`, `default-src 'none'`, and its one inline style and
|
|
2724
|
-
one inline script by hash. It never sends `X-Frame-Options`, even one set in
|
|
2725
|
-
`securityHeaders`. It shows nothing from a session, so a signed-in user who is
|
|
2726
|
-
framed sees the same card as anyone else. Every other response keeps the
|
|
2727
|
-
framing headers above.
|
|
2728
|
-
|
|
2729
|
-
A site changes them with `securityHeaders` in its config. Names are matched
|
|
2730
|
-
without regard to case. A string replaces a default or adds a header of the
|
|
2731
|
-
site's own, and `false` removes a default:
|
|
2732
|
-
|
|
2733
|
-
```ts
|
|
2734
|
-
export default defineConfig({
|
|
2735
|
-
securityHeaders: {
|
|
2736
|
-
'Content-Security-Policy':
|
|
2737
|
-
"frame-ancestors 'self' https://portfolio.example",
|
|
2738
|
-
'X-Frame-Options': false,
|
|
2739
|
-
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
|
|
2740
|
-
},
|
|
2741
|
-
});
|
|
2742
|
-
```
|
|
2743
|
-
|
|
2744
|
-
A name that is not a header name, or a value that is empty or has a line break
|
|
2745
|
-
in it, stops the site at boot. There is no environment variable for these.
|
|
2746
|
-
|
|
2747
|
-
A policy that limits scripts has to allow whatever the site's theme adds to
|
|
2748
|
-
every page. For the Umami and Google Analytics snippets, the theme README's
|
|
2749
|
-
[Adding to every page](./themes/default/README.md#adding-to-every-page) lists
|
|
2750
|
-
the directives each one needs.
|
|
2751
|
-
|
|
2752
|
-
Every response under `/admin`, static files and redirects included, gets three
|
|
2753
|
-
stricter values in their place, whatever `securityHeaders` says:
|
|
2754
|
-
|
|
2755
|
-
| Header | Value |
|
|
2756
|
-
| ------------------------- | --------------------------------------------- |
|
|
2757
|
-
| `Referrer-Policy` | `same-origin` |
|
|
2758
|
-
| `X-Frame-Options` | `SAMEORIGIN` |
|
|
2759
|
-
| `Content-Security-Policy` | `default-src 'self'` and the directives below |
|
|
2760
|
-
|
|
2761
|
-
The policy is `'self'` almost everywhere: the stylesheet and the editor bundle
|
|
2762
|
-
come from `/admin/_static/`, the editor's preview and upload calls are
|
|
2763
|
-
same-origin `fetch`, and the preview frame renders the site's own theme and
|
|
2764
|
-
uploads. Four directives say more than that:
|
|
2765
|
-
|
|
2766
|
-
- `style-src 'self' 'nonce-…'` — a fresh nonce on every response. CodeMirror 6
|
|
2767
|
-
mounts its themes as `<style>` elements at runtime, which is an inline style
|
|
2768
|
-
however it is written. The nonce is put on the editor bundle's `<script>` tag
|
|
2769
|
-
and read back through the element's `nonce` property, which a browser keeps
|
|
2770
|
-
after blanking the attribute, and handed to CodeMirror through
|
|
2771
|
-
`EditorView.cspNonce`. So the admin never says `'unsafe-inline'`.
|
|
2772
|
-
- `script-src 'self'` — no nonce and no `'unsafe-inline'`, because there is no
|
|
2773
|
-
inline script in the admin at all. The slug and permalink auto-fill lives in
|
|
2774
|
-
`/admin/_static/slug.js`.
|
|
2775
|
-
- `img-src 'self' data:` — a preview of a post somebody is writing may hold a
|
|
2776
|
-
data URI image, and refusing it would make the preview lie about what
|
|
2777
|
-
publishing would show.
|
|
2778
|
-
- `frame-ancestors 'self'` rather than `'none'` — the editor's preview is a
|
|
2779
|
-
sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
|
|
2780
|
-
the admin page itself.
|
|
2781
|
-
|
|
2782
|
-
### Compression
|
|
2783
|
-
|
|
2784
|
-
Text responses are compressed on the way out: HTML, CSS, JavaScript, feeds,
|
|
2785
|
-
JSON, Markdown, SVG, sitemaps and anything else whose media type is `text/*`,
|
|
2786
|
-
`+json` or `+xml`. A client that accepts brotli (`br`) gets brotli, at quality
|
|
2787
|
-
5 because every page is built per request; one that accepts only gzip gets
|
|
2788
|
-
gzip; one that accepts neither gets the plain bytes.
|
|
2789
|
-
|
|
2790
|
-
Some responses go out as they are:
|
|
2791
|
-
|
|
2792
|
-
- Images, video, WOFF and WOFF2 fonts and other formats that carry their own
|
|
2793
|
-
compression. Encoding them again costs CPU and can make them bigger.
|
|
2794
|
-
- Bodies under 1024 bytes, where the saving is smaller than the cost.
|
|
2795
|
-
- A response that already has a `Content-Encoding`, or whose `Cache-Control`
|
|
2796
|
-
says `no-transform`.
|
|
2797
|
-
- A response that may hold a secret: anything whose `Cache-Control` says
|
|
2798
|
-
`private` or `no-store`, which every page drawn for somebody signed in does,
|
|
2799
|
-
and every admin screen under `/admin`, the setup and login forms included.
|
|
2800
|
-
These carry CSRF tokens next to text a visitor can influence, and compressing
|
|
2801
|
-
them would let an attacker who watches response sizes recover the token a
|
|
2802
|
-
byte at a time (the BREACH attack). Their `Vary` is left as it was. The
|
|
2803
|
-
admin's static files under `/admin/_static/` hold no secret and are
|
|
2804
|
-
compressed. Error pages say `no-store`, so they go out plain too.
|
|
2805
|
-
|
|
2806
|
-
Every other text response carries `Vary: Accept-Encoding`, whether it was
|
|
2807
|
-
compressed or not, and so does every `304`. A cache in front of the site
|
|
2808
|
-
therefore keeps a copy per encoding and never hands brotli to a client that
|
|
2809
|
-
cannot decode it.
|
|
2810
|
-
|
|
2811
|
-
A compressed response's `ETag` is the plain response's with `W/` in front: the
|
|
2812
|
-
bytes differ, but the representation is the same. `If-None-Match` compares
|
|
2813
|
-
weakly, so a browser holding the brotli copy and a feed reader holding the gzip
|
|
2814
|
-
copy both revalidate to `304`, and each gets back the validator it sent.
|
|
2815
|
-
|
|
2816
|
-
`compression: false`, or `GEEKITY_COMPRESSION=false`, turns all of this off.
|
|
2817
|
-
Turn it off when a reverse proxy or CDN in front of the site compresses
|
|
2818
|
-
responses itself (Caddy's `encode`, nginx's `gzip on`, Cloudflare), so the work
|
|
2819
|
-
is done once and the proxy's settings apply. Leave it on when nothing in front
|
|
2820
|
-
compresses, as in the Docker deployment the repository documents.
|
|
2821
|
-
|
|
2822
|
-
### security.txt and change-password
|
|
2823
|
-
|
|
2824
|
-
`/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))
|
|
2825
|
-
tells somebody who finds a vulnerability in the site where to report it. It is
|
|
2826
|
-
built from three settings on the Email settings page, under Security contact,
|
|
2827
|
-
which `content/_data/site.json` holds:
|
|
2828
|
-
|
|
2829
|
-
| Setting | `site.json` key | Default | What it becomes |
|
|
2830
|
-
| ------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
2831
|
-
| Contacts | `securityContacts` | `[]` | One `Contact:` line each, in order. An email address, stored as `mailto:…`, an `https://` URL or a `tel:` number. |
|
|
2832
|
-
| Disclosure policy | `securityPolicy` | `""` | A `Policy:` line. An `https://` URL, or empty for no line. |
|
|
2833
|
-
| Preferred languages | `securityLanguages` | `""` | A `Preferred-Languages:` line. Language tags separated by commas, or empty for no line. |
|
|
2834
|
-
|
|
2835
|
-
With no contact the path is a 404: the RFC requires at least one. The
|
|
2836
|
-
`contactEmail` setting is never used in its place, because that address is
|
|
2837
|
-
promised never to appear on the public site.
|
|
2838
|
-
|
|
2839
|
-
The file is built on every request and served as `text/plain; charset=utf-8`.
|
|
2840
|
-
`Expires` is 30 days after the request, so it never goes stale however long the
|
|
2841
|
-
site runs, and `Canonical` is the path under the base URL in effect. There is no
|
|
2842
|
-
config-file key or environment variable for any of these: like `contactEmail`,
|
|
2843
|
-
they are site settings a person edits on the settings screen.
|
|
2844
|
-
|
|
2845
|
-
`/.well-known/change-password` is where a password manager sends somebody who
|
|
2846
|
-
wants to change a saved password. It redirects with a `302` and
|
|
2847
|
-
`Cache-Control: no-store`: to `/admin/users/{id}#change-password`, the form on
|
|
2848
|
-
the signed-in user's own page, or to the login form when nobody is signed in.
|
|
2849
|
-
|
|
2850
|
-
Both paths answer as usual in maintenance mode. A researcher may need the
|
|
2851
|
-
security contact most while the site is down, and change-password only points
|
|
2852
|
-
into the admin, which maintenance mode leaves open.
|
|
2853
|
-
|
|
2854
|
-
## The public site
|
|
2855
|
-
|
|
2856
|
-
Booting mounts the public site on the app. The routes are:
|
|
2857
|
-
|
|
2858
|
-
| Route | What it serves |
|
|
2859
|
-
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
2860
|
-
| `/` | Published posts, newest first. |
|
|
2861
|
-
| `/page/2/` and up | Later pages of the same archive. |
|
|
2862
|
-
| a document's permalink | The post or the page, through the theme. |
|
|
2863
|
-
| `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
|
|
2864
|
-
| `/category/{name}/` | The second taxonomy, paginated the same way. |
|
|
2865
|
-
| `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
|
|
2866
|
-
| `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
|
|
2867
|
-
| `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
|
|
2868
|
-
| `/author/{username}/feed/` and siblings | The same, for one person. |
|
|
2869
|
-
| `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
|
|
2870
|
-
| `{permalink}feed/` | One post's replies, the same way. |
|
|
2871
|
-
| `/comment/{key}/` | One comment left through the form, on a page of its own: see [Comments](#comments). Noindex and on no list. |
|
|
2872
|
-
| `/sitemap.xml` | Every public URL, for a search engine. |
|
|
2873
|
-
| `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
|
|
2874
|
-
| `/robots.txt` | What a crawler may have, and where the sitemap is. |
|
|
2875
|
-
| `/opensearch.xml` | The OpenSearch description, so a browser can offer the site's search from its address bar. |
|
|
2876
|
-
| `/_geekity/oembed?url=…` | The oEmbed card of the post or page at `url`, as JSON or with `format=xml`; a 404 for anything else. |
|
|
2877
|
-
| `/_geekity/embed?url=…` | The same card as a page any site may frame, which the oEmbed `html` puts in an iframe; a 404 for anything else. |
|
|
2878
|
-
| `/llms.txt` | The site's pages and recent posts for a language model, each linked to its Markdown. See [below](#llmstxt). |
|
|
2879
|
-
| `/{key}.txt` | The IndexNow key, while the site has IndexNow on. See [below](#indexnow). |
|
|
2880
|
-
| `/.well-known/security.txt` | Where to report a vulnerability; a 404 until a security contact is set. See [above](#securitytxt-and-change-password). |
|
|
2881
|
-
| `/.well-known/change-password` | A redirect to the signed-in user's change-password form, or to the login form. |
|
|
2882
|
-
| `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
|
|
2883
|
-
| `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
|
|
2884
|
-
| `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
|
|
2885
|
-
| `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
|
|
2886
|
-
| `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
|
|
2887
|
-
| `/theme/…` | The theme's own files, from its `static/` directory. |
|
|
2888
|
-
| `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
|
|
2889
|
-
| `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
|
|
2890
|
-
| anything else | The theme's 404. |
|
|
2891
|
-
|
|
2892
|
-
The federation routes go on before it, and answer only their own paths:
|
|
2893
|
-
|
|
2894
|
-
| Route | What it serves |
|
|
2895
|
-
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
|
2896
|
-
| `/author/{username}/` | The `Person` on an ActivityStreams request; the archive above to anything else. |
|
|
2897
|
-
| `/author/{username}/inbox/` | `POST` only. That user's inbox, signature-verified by Fedify. |
|
|
2898
|
-
| `/author/{username}/outbox/` | Their published posts as `Create` activities, paged 20 at a time. |
|
|
2899
|
-
| `/author/{username}/followers/` | Who follows them, paged 20 at a time. |
|
|
2900
|
-
| `/author/{username}/following/` | Always empty: a relay is a subscription rather than a relationship. |
|
|
2901
|
-
| `/inbox/` | `POST` only. The instance-wide shared inbox; the addressee is read out of the body. |
|
|
2902
|
-
| `/@{username}` | A 301 to their archive; a 404 for a handle nobody answers to. |
|
|
2903
|
-
| `/.well-known/webfinger` | The handle, the author URL or `/@{username}`, all resolving to the same actor. |
|
|
2904
|
-
| `/.well-known/nodeinfo` | A link to the NodeInfo document. |
|
|
2905
|
-
| `/nodeinfo/2.1` | What software this is, how many users and how many posts. |
|
|
2906
|
-
| a post's permalink | The `Article` on an ActivityStreams request (decision-13); the page to anything else. |
|
|
2907
|
-
|
|
2908
|
-
Drafts, documents in the trash and posts whose date has not arrived are not on
|
|
2909
|
-
the public site: their URLs 404, and they are in no listing.
|
|
2910
|
-
|
|
2911
|
-
Trailing slashes are canonical. A request that arrives without one redirects
|
|
2912
|
-
301 to the URL with it, but only when that URL resolves — a genuinely missing
|
|
2913
|
-
address 404s straight away rather than bouncing first. `/page/1/` redirects to
|
|
2914
|
-
`/`, because the first page of a listing lives at the listing's own URL.
|
|
2915
|
-
|
|
2916
|
-
A document is resolved after every registered route has failed to match, so
|
|
2917
|
-
routes a site adds always win over a permalink that happens to collide with
|
|
2918
|
-
them. The taxonomy archives are resolved there too, ahead of the documents: a
|
|
2919
|
-
route table is fixed when the app is built and the archive bases are a setting,
|
|
2920
|
-
so `/{tagBase}/{tag}/` is matched against whatever the site holds at the moment
|
|
2921
|
-
of the request.
|
|
2922
|
-
|
|
2923
|
-
`tag` and `category` are the bases a site has until it says otherwise —
|
|
2924
|
-
WordPress's own, so a site imported from it keeps every archive URL it
|
|
2925
|
-
published. `tagBase` and `categoryBase` on the Permalinks settings page move
|
|
2926
|
-
them, and
|
|
2927
|
-
the routes, the paging, the canonical redirects, the tag feeds, the theme's
|
|
2928
|
-
links and the ActivityStreams hashtags all follow on the next request. A base
|
|
2929
|
-
is one URL-safe path segment: no slashes, and not a path the site already
|
|
2930
|
-
answers on (`page`, `feed`, `comments`, `admin`, `theme`, `uploads`,
|
|
2931
|
-
`nodeinfo`, `author`, `inbox`), and
|
|
2932
|
-
the two may not be the same word. Both are `tagBase` and `categoryBase` in
|
|
2933
|
-
`content/_data/site.json`, so an Eleventy build of the same content directory
|
|
2934
|
-
can put its archives at the same URLs.
|
|
2935
|
-
|
|
2936
|
-
### Navigation
|
|
2937
|
-
|
|
2938
|
-
A menu is a named thing the site stores and the theme asks for.
|
|
2939
|
-
|
|
2940
|
-
**The site stores menus by name.** `content/_data/site.json` holds a `menus`
|
|
2941
|
-
object keyed by the name of each menu, each an ordered list of items:
|
|
2942
|
-
|
|
2943
|
-
```json
|
|
2944
|
-
{
|
|
2945
|
-
"menus": {
|
|
2946
|
-
"primary": [{ "label": "About", "url": "/about/" }],
|
|
2947
|
-
"footer": [
|
|
2948
|
-
{ "label": "Colophon", "url": "/colophon/" },
|
|
2949
|
-
{ "label": "Mastodon", "url": "https://example.social/@me", "rel": "me" }
|
|
2950
|
-
]
|
|
2951
|
-
}
|
|
2952
|
-
}
|
|
2953
|
-
```
|
|
2954
|
-
|
|
2955
|
-
An item is a label, a URL and the `rel` values it carries. The URL is a
|
|
2956
|
-
site-root path or an absolute `http(s)` URL; anything else is refused. `rel` is
|
|
2957
|
-
one string holding every value — `"me"`, `"nofollow noopener"` — present only
|
|
2958
|
-
when there is one, lower case and each value said once, and a theme prints it
|
|
2959
|
-
as the whole of the link's `rel` attribute. The values are whatever HTML link
|
|
2960
|
-
types the site wants; `me` is the one most menus have, because it is how
|
|
2961
|
-
Mastodon and the rest of the IndieWeb verify that the site and the profile it
|
|
2962
|
-
links are the same person. An item written `"me": true`, the spelling before
|
|
2963
|
-
`rel` held a list, is still read as `"rel": "me"`.
|
|
2964
|
-
|
|
2965
|
-
**The theme declares the areas it renders.** `theme.json` carries an `areas`
|
|
2966
|
-
list, each entry a `name` — the key under `menus` — and a `label` a person
|
|
2967
|
-
reads:
|
|
2968
|
-
|
|
2969
|
-
```json
|
|
2970
|
-
{ "areas": [{ "name": "primary", "label": "Site menu" }] }
|
|
2971
|
-
```
|
|
2972
|
-
|
|
2973
|
-
The packaged theme declares `primary` ("Site menu") and `footer` ("Footer
|
|
2974
|
-
links"). A theme that wants a third declares it; an `areas` that is missing or
|
|
2975
|
-
unreadable leaves that theme declaring none rather than failing to load it, and
|
|
2976
|
-
the Navigation screen then offers the packaged theme's — a site theme is laid
|
|
2977
|
-
over the packaged one a file at a time, so a theme that has declared nothing has
|
|
2978
|
-
not overridden the declaration, and its inherited `layouts/base.njk` really is
|
|
2979
|
-
still rendering `menus.primary` and `menus.footer`.
|
|
2980
|
-
|
|
2981
|
-
**A theme renders a menu by name.** Every template gets `menus`, keyed by the
|
|
2982
|
-
same names, each item already marked `current` for the path being rendered.
|
|
2983
|
-
`{% for item in menus.footer %}` is the whole interface, so a site theme can
|
|
2984
|
-
render a menu the packaged theme has never heard of. Every stored menu is on
|
|
2985
|
-
the context, not only the declared ones: the declaration is what the screen
|
|
2986
|
-
that edits menus reads, and a menu nothing loops over is rendered nowhere and
|
|
2987
|
-
kept — which is how somebody writes the menu a theme will use before switching
|
|
2988
|
-
to that theme.
|
|
2989
|
-
|
|
2990
|
-
**The menus are managed on `/admin/navigation`.** It is a top-level section
|
|
2991
|
-
after Pages rather than a settings page: a menu is content a site arranges, the
|
|
2992
|
-
way its pages are, and the shape of the screen comes from the active theme.
|
|
2993
|
-
The screen is two lists. First the areas that theme declares, in the theme's
|
|
2994
|
-
order and under its labels — an area the site has never filled in is an empty
|
|
2995
|
-
box, not a missing one. Then every other stored menu, under a heading saying
|
|
2996
|
-
this theme renders them nowhere; those are still editable, which is how the
|
|
2997
|
-
menu a theme will use is written before the site switches to it, and each has
|
|
2998
|
-
the Delete that is the only way a menu is removed. An area the theme declares
|
|
2999
|
-
is emptied rather than deleted: the theme goes on asking for the name, and the
|
|
3000
|
-
box has to stay for the next thing typed into it.
|
|
3001
|
-
|
|
3002
|
-
Each menu is one box of `Label | URL` lines, in the order they are typed, and a
|
|
3003
|
-
line may end in the `rel` values the link carries, a word each — `Mastodon |
|
|
3004
|
-
https://example.social/@me | me`, `A source | https://example.com/thing |
|
|
3005
|
-
nofollow noopener`. The values come off the end of the line only while the last
|
|
3006
|
-
bar-separated part reads as a list of them, so a URL holding a bar survives:
|
|
3007
|
-
`Odd | /odd/?a=1|2` keeps its query string, because `2` is not a word. A line
|
|
3008
|
-
that is not an item is refused by name, with the box still holding every line of
|
|
3009
|
-
what was typed, and nothing written. It is the same line the Links box on a
|
|
3010
|
-
profile takes.
|
|
3011
|
-
|
|
3012
|
-
**A menu name** is lower-case ASCII letters, digits and underscores, starting
|
|
3013
|
-
with a letter, at most 32 characters. It is the word a theme writes after the
|
|
3014
|
-
dot — `{% for item in menus.footer %}` — so a dash is refused: after a dot it
|
|
3015
|
-
is a minus sign, and `menus.top-bar` renders nothing and raises nothing. Lower
|
|
3016
|
-
case is the rest of the rule and it is what refuses the confusable spellings:
|
|
3017
|
-
`Footer` is refused rather than quietly lowered, because `footer` and `Footer`
|
|
3018
|
-
would be two menus on one screen that nothing could tell apart. A name the site
|
|
3019
|
-
already holds is refused too, and the message says its box is already on the
|
|
3020
|
-
page. The rule governs a name somebody types; a name a `theme.json` declares is
|
|
3021
|
-
a menu name by declaration and is shown as the theme spells it, because a
|
|
3022
|
-
screen that hid a declared area over a spelling would leave somebody unable to
|
|
3023
|
-
fill in a menu their site renders.
|
|
3024
|
-
|
|
3025
|
-
There is no second source. A page cannot put itself in a menu, so there is one
|
|
3026
|
-
screen to edit it on, one order, and no way for the same link to appear twice.
|
|
3027
|
-
A page that should be linked is linked by typing a line for it — including the
|
|
3028
|
-
page a site serves as its front page, which is typed `Home | /`, the URL a
|
|
3029
|
-
reader lands on, rather than at the permalink that redirects there.
|
|
3030
|
-
|
|
3031
|
-
Because the menus live in `site.json`, an Eleventy build renders the same ones;
|
|
3032
|
-
the example config assembles them as `collections.menus`. A `menus` in a
|
|
3033
|
-
hand-edited `site.json` that is not an object of lists of items yields no menu
|
|
3034
|
-
rather than an error, exactly as a bad archive base falls back rather than
|
|
3035
|
-
taking the site down.
|
|
3036
|
-
|
|
3037
|
-
### Moved URLs
|
|
3038
|
-
|
|
3039
|
-
Changing the slug or the permalink of a published post or page in the editor
|
|
3040
|
-
moves it, and the URL it leaves is written into its file:
|
|
3041
|
-
|
|
3042
|
-
```yaml
|
|
3043
|
-
permalink: /2026/01/new-name/
|
|
3044
|
-
redirect_from:
|
|
3045
|
-
- /2026/01/first-name/
|
|
3046
|
-
- /2026/01/second-name/
|
|
3047
|
-
```
|
|
3048
|
-
|
|
3049
|
-
Every URL in `redirect_from` answers `301 Moved Permanently` with the current
|
|
3050
|
-
permalink, and so do its `.md` and `.json` spellings. Each entry points at the
|
|
3051
|
-
document itself, not at the next rename, so no chain of redirects builds up.
|
|
3052
|
-
The list is in the file, so deleting `data/geekity.db` keeps it working.
|
|
3053
|
-
|
|
3054
|
-
A URL in the list only redirects while nothing else lives there: a new document
|
|
3055
|
-
given that URL takes it over. Moving a document back to a URL in its list takes
|
|
3056
|
-
that URL off the list. A draft that was never published and has no comments
|
|
3057
|
-
moves without leaving anything behind; one with comments was read at its URL,
|
|
3058
|
-
so it keeps a `redirect_from` like a published one. Correcting a published
|
|
3059
|
-
post's date files it under the new day but keeps its URL.
|
|
3060
|
-
|
|
3061
|
-
A moved document keeps its conversation. Its comments and stored webmentions
|
|
3062
|
-
move to its new slug, a reply post answering any URL in its `redirect_from`
|
|
3063
|
-
stays in its thread, and its feeds keep their guid: a post keeps its object id
|
|
3064
|
-
as its `activitypub.id`, and a page keeps the URL it left as its `guid`, so the
|
|
3065
|
-
`/replies/` feed a reader subscribed to keeps answering. The record of the
|
|
3066
|
-
webmentions a post has sent moves with it too. A move made by hand is followed
|
|
3067
|
-
the same way when the file names the old URL in `redirect_from`; a permalink
|
|
3068
|
-
changed by hand with no `redirect_from` leaves the old URL answering 404 and its
|
|
3069
|
-
comments where they were, until the file names it.
|
|
3070
|
-
|
|
3071
|
-
### Declared redirects
|
|
3072
|
-
|
|
3073
|
-
A site that moves to this CMS, or reorganises, lists the old URLs that no
|
|
3074
|
-
document owns in `content/_data/redirects.json`. The file is a JSON list:
|
|
3075
|
-
|
|
3076
|
-
```json
|
|
3077
|
-
[
|
|
3078
|
-
{ "from": "/?p=123", "to": "/2026/01/hello/" },
|
|
3079
|
-
{ "from": "/old-section/", "to": "/new-section/", "status": 301 },
|
|
3080
|
-
{ "from": "/sale/", "to": "/shop/", "status": 302 },
|
|
3081
|
-
{ "from": "/forum/", "to": "https://forum.example.com/", "status": 308 }
|
|
3082
|
-
]
|
|
3083
|
-
```
|
|
3084
|
-
|
|
3085
|
-
- `from` is a path on this site. It starts with `/` and can carry a query
|
|
3086
|
-
string.
|
|
3087
|
-
- `to` is a path on this site, or an absolute `http` or `https` URL.
|
|
3088
|
-
- `status` is `301` or `308` for a permanent redirect, and `302` or `307` for a
|
|
3089
|
-
temporary one. It defaults to `301`.
|
|
3090
|
-
|
|
3091
|
-
A `from` without a query string matches that path with any query, or none. The
|
|
3092
|
-
request's query goes on to a `to` that has no query of its own, so
|
|
3093
|
-
`/old-section/?page=2` lands on `/new-section/?page=2`. The path must match
|
|
3094
|
-
exactly, except that a `from` ending in `/` also answers the same path without
|
|
3095
|
-
the slash, in one hop.
|
|
3096
|
-
|
|
3097
|
-
A `from` with a query string matches only that query. The order of the
|
|
3098
|
-
parameters does not matter, so `/archives/?cat=4&paged=2` also matches
|
|
3099
|
-
`/archives/?paged=2&cat=4`. Nothing else in the request's query may differ:
|
|
3100
|
-
`/?p=123` does not match `/?p=123&replytocom=9`. This is how old WordPress
|
|
3101
|
-
`?p=` links reach their posts.
|
|
3102
|
-
|
|
3103
|
-
A declared redirect never hides something real. A path-only `from` is answered
|
|
3104
|
-
only after every document, archive, feed and moved URL has had its turn, and
|
|
3105
|
-
just before the 404 page, so a document given that URL later takes it over. A
|
|
3106
|
-
`from` with a query string is answered first, because no document is addressed
|
|
3107
|
-
by its query. The admin, the federation endpoints and the health check answer
|
|
3108
|
-
before any declared redirect.
|
|
3109
|
-
|
|
3110
|
-
The file is read on the next request after it changes, with no restart. An
|
|
3111
|
-
entry that cannot be served is logged as a warning at boot, and again on the
|
|
3112
|
-
first request after the file changes, and it is skipped. The following entries are skipped:
|
|
3113
|
-
|
|
3114
|
-
- An entry with a missing or malformed `from`, `to` or `status`.
|
|
3115
|
-
- A second entry with the same `from`. The first entry wins.
|
|
3116
|
-
- Every entry that leads into a loop, such as `/a/` to `/b/` and `/b/` back to
|
|
3117
|
-
`/a/`.
|
|
3118
|
-
|
|
3119
|
-
A file that is not a JSON list serves no redirects and is reported the same
|
|
3120
|
-
way.
|
|
3121
|
-
|
|
3122
|
-
#### More than one file
|
|
3123
|
-
|
|
3124
|
-
Every `*.json` file in `content/_data/redirects/` is read after
|
|
3125
|
-
`redirects.json`, in name order, and served the same way. A tool that generates
|
|
3126
|
-
redirects, such as an import, writes a file of its own there and never touches
|
|
3127
|
-
the list a person keeps. A file there, or `redirects.json` itself, may also be
|
|
3128
|
-
an object that maps each `from` to its `to`, every one a `301`:
|
|
3129
|
-
|
|
3130
|
-
```json
|
|
3131
|
-
{
|
|
3132
|
-
"/?p=123": "/2026/01/hello/",
|
|
3133
|
-
"/old-section/": "/new-section/"
|
|
3134
|
-
}
|
|
3135
|
-
```
|
|
3136
|
-
|
|
3137
|
-
Where two files declare the same `from`, the one read first wins, so
|
|
3138
|
-
`redirects.json` wins over every file in the folder, and the other declaration
|
|
3139
|
-
is reported. A loop across files is found and skipped as within one.
|
|
3140
|
-
|
|
3141
|
-
Every redirect the CMS sends carries `X-Redirect-By: Geekity CMS`. This
|
|
3142
|
-
includes the declared redirects, moved URLs, trailing slashes, feed spellings
|
|
3143
|
-
and the admin's redirects. Someone who traces a chain of redirects through a
|
|
3144
|
-
proxy can see which layer sent each one.
|
|
3145
|
-
|
|
3146
|
-
## Content negotiation
|
|
3147
|
-
|
|
3148
|
-
Every public URL is one resource with more than one body. A post or a page has
|
|
3149
|
-
three; a listing has two.
|
|
3150
|
-
|
|
3151
|
-
| Media type | Extension | Body |
|
|
3152
|
-
| ------------------ | --------- | -------------------------------------------------------- |
|
|
3153
|
-
| `text/html` | — | The document through the theme. The default. |
|
|
3154
|
-
| `text/markdown` | `.md` | The file exactly as it is stored, front matter included. |
|
|
3155
|
-
| `application/json` | `.json` | The document as data, see below. |
|
|
3156
|
-
|
|
3157
|
-
Listings — `/`, `/page/N/`, `/tag/{tag}/` and `/tag/{tag}/page/N/` — offer
|
|
3158
|
-
HTML and JSON only. There is no Markdown file behind a listing to serve.
|
|
3159
|
-
|
|
3160
|
-
The representation is chosen like this:
|
|
3161
|
-
|
|
3162
|
-
1. A `.md` or `.json` suffix on the path wins outright and `Accept` is ignored.
|
|
3163
|
-
Both spellings work: `/2026/09/hello/index.md` (what the `Link` header
|
|
3164
|
-
advertises) and the shorter `/2026/09/hello.md`.
|
|
3165
|
-
2. Otherwise `Accept` is matched with q-values. The highest q wins; a tie goes
|
|
3166
|
-
to the more specific range; what is still tied goes to HTML.
|
|
3167
|
-
3. No `Accept` header, an empty one, or `*/*` means HTML, so a browser and a
|
|
3168
|
-
`curl` with no arguments both get a page.
|
|
3169
|
-
4. A request that accepts none of the representations gets `406` with a JSON
|
|
3170
|
-
body listing what it could have asked for.
|
|
3171
|
-
|
|
3172
|
-
The feeds are fixed routes and are not negotiated: feed readers do not send
|
|
3173
|
-
useful `Accept` headers. See [Feeds](#feeds).
|
|
3174
|
-
|
|
3175
|
-
```sh
|
|
3176
|
-
curl -H 'Accept: text/markdown' https://example.com/2026/09/hello/
|
|
3177
|
-
curl https://example.com/2026/09/hello/index.json
|
|
3178
|
-
curl 'https://example.com/index.json?full=1'
|
|
3179
|
-
```
|
|
3180
|
-
|
|
3181
|
-
### The JSON shape
|
|
3182
|
-
|
|
3183
|
-
```json
|
|
3184
|
-
{
|
|
3185
|
-
"schema": 1,
|
|
3186
|
-
"url": "https://example.com/2026/09/hello/",
|
|
3187
|
-
"frontMatter": {
|
|
3188
|
-
"title": "Hello, World!",
|
|
3189
|
-
"date": "2026-09-02T09:00:00Z",
|
|
3190
|
-
"permalink": "/2026/09/hello/",
|
|
3191
|
-
"tags": ["introductions"]
|
|
3192
|
-
},
|
|
3193
|
-
"markdown": "A *file-first* CMS.",
|
|
3194
|
-
"html": "<p>A <em>file-first</em> CMS.</p>\n"
|
|
3195
|
-
}
|
|
3196
|
-
```
|
|
3197
|
-
|
|
3198
|
-
`schema` is the version of this shape; keys are only added within a version.
|
|
3199
|
-
`url` is absolute, built on the configured `baseUrl`. `frontMatter` is the
|
|
3200
|
-
document's front matter exactly as the `.md` representation carries it — the
|
|
3201
|
-
same block the writer emits, unknown keys included — so the two cannot drift.
|
|
3202
|
-
`markdown` is the body without the front matter, and `html` is that body
|
|
3203
|
-
rendered.
|
|
3204
|
-
|
|
3205
|
-
A listing's JSON is a plain array of the same objects with `markdown` and
|
|
3206
|
-
`html` left out, because an archive page should not carry every post in full.
|
|
3207
|
-
`?full=1` puts them back.
|
|
3208
|
-
|
|
3209
|
-
```json
|
|
3210
|
-
[
|
|
3211
|
-
{
|
|
3212
|
-
"schema": 1,
|
|
3213
|
-
"url": "https://example.com/one/",
|
|
3214
|
-
"frontMatter": { "title": "One", "…": "…" }
|
|
3215
|
-
},
|
|
3216
|
-
{
|
|
3217
|
-
"schema": 1,
|
|
3218
|
-
"url": "https://example.com/two/",
|
|
3219
|
-
"frontMatter": { "title": "Two", "…": "…" }
|
|
3220
|
-
}
|
|
3221
|
-
]
|
|
3222
|
-
```
|
|
3223
|
-
|
|
3224
|
-
A 406 body names the alternates:
|
|
3225
|
-
|
|
3226
|
-
```json
|
|
3227
|
-
{
|
|
3228
|
-
"error": "not_acceptable",
|
|
3229
|
-
"message": "This URL is not available in any of the media types you accept.",
|
|
3230
|
-
"alternates": [
|
|
3231
|
-
{ "type": "text/html", "url": "/2026/09/hello/" },
|
|
3232
|
-
{ "type": "text/markdown", "url": "/2026/09/hello/index.md" },
|
|
3233
|
-
{ "type": "application/json", "url": "/2026/09/hello/index.json" }
|
|
3234
|
-
]
|
|
3235
|
-
}
|
|
3236
|
-
```
|
|
3237
|
-
|
|
3238
|
-
### Headers
|
|
3239
|
-
|
|
3240
|
-
Every negotiated response carries `Vary: Accept` and a `Link` header pointing
|
|
3241
|
-
at the representations it is not:
|
|
3242
|
-
|
|
3243
|
-
```
|
|
3244
|
-
Link: </2026/09/hello/index.md>; rel="alternate"; type="text/markdown",
|
|
3245
|
-
</2026/09/hello/index.json>; rel="alternate"; type="application/json"
|
|
3246
|
-
```
|
|
3247
|
-
|
|
3248
|
-
It also carries `ETag` — derived from the document's content hash and the
|
|
3249
|
-
representation, so a client holding the JSON is never told its Markdown is
|
|
3250
|
-
unchanged — plus `Last-Modified` from the document's `updated`, falling back to
|
|
3251
|
-
its `date`, and `Cache-Control: no-cache` so a client revalidates rather than
|
|
3252
|
-
guessing how long the page stays fresh. `If-None-Match` and `If-Modified-Since`
|
|
3253
|
-
are honoured and answered with `304`.
|
|
3254
|
-
|
|
3255
|
-
One exception: while `watch` is on, the HTML representation is served without
|
|
3256
|
-
validators. Only the document is hashed, and a template edit changes the page
|
|
3257
|
-
without changing the document, so a development server would otherwise answer
|
|
3258
|
-
`304` with a page that had already moved on. The `.md` and `.json`
|
|
3259
|
-
representations are validated either way.
|
|
3260
|
-
|
|
3261
|
-
Every representation but the HTML carries `X-Robots-Tag: noindex`, whether it
|
|
3262
|
-
was asked for by extension or by `Accept`. They say what the page says, and a
|
|
3263
|
-
search engine that indexed them would list one page two or three times.
|
|
3264
|
-
|
|
3265
|
-
Adding a representation — an ActivityStreams object, say — means adding it to
|
|
3266
|
-
`Representation` in `src/web/negotiate.ts` with its media type and, if it wants
|
|
3267
|
-
one, its extension. The selection, the `Link` alternates, the `ETag` and the
|
|
3268
|
-
406 body all follow from those two tables. ActivityPub types are claimed by the
|
|
3269
|
-
federation middleware before the negotiator sees them.
|
|
3270
|
-
|
|
3271
|
-
## Feeds
|
|
3272
|
-
|
|
3273
|
-
The recent posts are syndicated in three formats, at the URLs WordPress uses,
|
|
3274
|
-
so a site moving off it keeps every subscriber it had:
|
|
3275
|
-
|
|
3276
|
-
| URL | Format |
|
|
3277
|
-
| ------------------------ | ------------------------------------------------ |
|
|
3278
|
-
| `/feed/` | RSS 2.0 (`application/rss+xml`) |
|
|
3279
|
-
| `/feed/atom/` | Atom 1.0 (`application/atom+xml`) |
|
|
3280
|
-
| `/feed/json/` | JSON Feed 1.1 (`application/feed+json`) |
|
|
3281
|
-
| `/tag/{tag}/feed/` | One tag archive, in RSS; `atom/` and `json/` too |
|
|
3282
|
-
| `/category/{name}/feed/` | One category archive, the same three |
|
|
3283
|
-
| `/comments/feed/` | Every reply the site has been sent, in RSS |
|
|
3284
|
-
| `{permalink}feed/` | One post's replies, in RSS |
|
|
3285
|
-
| `/replies/{key}/` | One item's direct replies, in RSS |
|
|
3286
|
-
|
|
3287
|
-
`/feed/` is RSS because that is the format nearly every existing subscriber
|
|
3288
|
-
holds. The two archive bases are settings (`tagBase`, `categoryBase`), so the
|
|
3289
|
-
per-archive feeds follow wherever the archives live.
|
|
3290
|
-
|
|
3291
|
-
WordPress's older spellings redirect `301` rather than 404: `/feed/rss/` to
|
|
3292
|
-
`/feed/`, and the query forms `?feed=rss2`, `?feed=rss`, `?feed=atom` and
|
|
3293
|
-
`?feed=json` on any listing to that listing's feed, and `?feed=rss2` or
|
|
3294
|
-
`?feed=rss` on a post's permalink to that post's comments feed. A feed URL that
|
|
3295
|
-
arrives without its trailing slash redirects to the canonical one in a single
|
|
3296
|
-
hop.
|
|
3297
|
-
|
|
3298
|
-
A feed holds the newest published posts, newest first. Drafts, documents in the
|
|
3299
|
-
trash and pages are never in one, and a term nothing published carries 404s
|
|
3300
|
-
rather than serving an empty feed. `feedSize` in `content/_data/site.json` sets
|
|
3301
|
-
how many entries a feed holds; without it a feed holds 20, which is deliberately
|
|
3302
|
-
more than an archive page, so a reader that polls once a day does not miss a
|
|
3303
|
-
post on a site that publishes several.
|
|
3304
|
-
|
|
3305
|
-
```json
|
|
3306
|
-
{ "title": "My Site", "tagline": "Notes", "author": "me", "feedSize": 20 }
|
|
3307
|
-
```
|
|
3308
|
-
|
|
3309
|
-
Every RSS feed, the comments feeds included, tells a reader how often to poll
|
|
3310
|
-
it with the Syndication module's `sy:updatePeriod` and `sy:updateFrequency`.
|
|
3311
|
-
Settings → Reading sets both, as `feedUpdatePeriod` (`hourly`, `daily`,
|
|
3312
|
-
`weekly`, `monthly` or `yearly`) and `feedUpdateFrequency` (a whole number, 1 or
|
|
3313
|
-
more) in `site.json`. The default is `hourly` and `1`, which is what WordPress
|
|
3314
|
-
declares, so the subscribers of a migrated site keep polling as they did. A
|
|
3315
|
-
reader may back off from a feed that stays quiet much longer than it says, so
|
|
3316
|
-
a site that posts weekly does better to say `weekly`. Atom and JSON Feed have no
|
|
3317
|
-
element for it.
|
|
3318
|
-
|
|
3319
|
-
### What every format says about a post
|
|
3320
|
-
|
|
3321
|
-
The three formats render one **feed item**, derived once per post, rather than
|
|
3322
|
-
each reading the file for itself. So they agree about the three things a reader
|
|
3323
|
-
actually keys on.
|
|
3324
|
-
|
|
3325
|
-
**Identity.** A post is named by the first of three things it has: the `guid`
|
|
3326
|
-
its front matter sets, when that parses as a URL; else the `activitypub.id`
|
|
3327
|
-
the front matter stores for a post migrated from elsewhere; else its permalink
|
|
3328
|
-
(see [Federation](#federation)). That name is RSS's `guid`, Atom's `id` and
|
|
3329
|
-
JSON Feed's `id`. The permalink is always the link: RSS's `link`, Atom's
|
|
3330
|
-
`link rel="alternate"` and JSON Feed's `url`. The two are the same URL for
|
|
3331
|
-
every post born on this CMS, and differ only for one carrying a stored id or a
|
|
3332
|
-
`guid` — which is what lets a migrated post keep the name its WordPress
|
|
3333
|
-
subscribers already hold. RSS's `isPermaLink` says which of the two a `guid`
|
|
3334
|
-
is: `true` only when it equals the permalink, `false` for anything else, such
|
|
3335
|
-
as a stored `?p=813`.
|
|
3336
|
-
|
|
3337
|
-
**Terms.** Every format lists the post's categories and then its tags, in file
|
|
3338
|
-
order, as one flat list: RSS's `category` elements, Atom's `category term`
|
|
3339
|
-
attributes, JSON Feed's `tags`. None of the three can say which vocabulary a
|
|
3340
|
-
term came from, which is how WordPress publishes both too.
|
|
3341
|
-
|
|
3342
|
-
**Summary.** Every format summarises a post the same way: the `description`
|
|
3343
|
-
front matter when the post has one — it is what the author wrote for exactly
|
|
3344
|
-
this — and otherwise the first paragraph of the rendered body, stripped to
|
|
3345
|
-
plain text and cut at 55 words, WordPress's own excerpt length. It is RSS's
|
|
3346
|
-
`description`, Atom's `summary` and JSON Feed's `summary`, and it is left out
|
|
3347
|
-
only when there is nothing to summarise at all. The whole post goes in the
|
|
3348
|
-
content element beside it, so a reader that shows both has something to choose
|
|
3349
|
-
between.
|
|
3350
|
-
|
|
3351
|
-
**Replies.** A post whose `in-reply-to` front matter is an absolute `http` or
|
|
3352
|
-
`https` URL is a reply, and Atom and JSON Feed name the URL it answers. An Atom
|
|
3353
|
-
entry carries RFC 4685's `<thr:in-reply-to ref="…" href="…"/>`, with the target
|
|
3354
|
-
URL as both its identity and its location, and every Atom feed declares
|
|
3355
|
-
`xmlns:thr="http://purl.org/syndication/thread/1.0"`. JSON Feed 1.1 has no reply
|
|
3356
|
-
field, so a JSON Feed item carries the target in an extension object, which the
|
|
3357
|
-
spec allows under any key that starts with an underscore and tells readers that
|
|
3358
|
-
do not know it to ignore:
|
|
3359
|
-
|
|
3360
|
-
```json
|
|
3361
|
-
{
|
|
3362
|
-
"id": "…",
|
|
3363
|
-
"url": "…",
|
|
3364
|
-
"content_html": "…",
|
|
3365
|
-
"_geekity": { "in_reply_to": "https://example.net/notes/1" }
|
|
3366
|
-
}
|
|
3367
|
-
```
|
|
3368
|
-
|
|
3369
|
-
The key is `_geekity` because an extension is named after its publisher, and
|
|
3370
|
-
`in_reply_to` spells the microformats `in-reply-to` property the JSON way. A
|
|
3371
|
-
post that is not a reply, or whose `in-reply-to` is not such a URL, carries
|
|
3372
|
-
neither. RSS 2.0 has no equivalent element, so an RSS item carries the target
|
|
3373
|
-
as the [source namespace][source-ns]'s `<source:inReplyTo>`.
|
|
3374
|
-
|
|
3375
|
-
**Languages.** Every feed declares the site's `language` once: RSS as the
|
|
3376
|
-
channel's `language`, Atom as the feed's `xml:lang` and JSON Feed as its
|
|
3377
|
-
`language`. A post whose `lang` front matter names another language says so on
|
|
3378
|
-
its own item: RSS as `dc:language`, Atom as `xml:lang` on the `entry` and JSON
|
|
3379
|
-
Feed as the item's `language`. A post in the site's language, or whose `lang`
|
|
3380
|
-
is not a language tag, adds nothing.
|
|
3381
|
-
|
|
3382
|
-
**Photos.** A post's `photo` front matter prints in every format's content,
|
|
3383
|
-
before the body, which is where the page prints it too. Each photo is a plain
|
|
3384
|
-
`<figure><img src alt></figure>` of the original upload, never the responsive
|
|
3385
|
-
variants, which a reader cannot resolve (decision-10). Its `src` is absolute on
|
|
3386
|
-
the site's base URL. Its `alt` is the post's own text, else the media library's,
|
|
3387
|
-
else empty. So a photo-only post reads in a feed reader as its photos. JSON Feed
|
|
3388
|
-
also names the item's main image as `image`: the post's `image` front matter,
|
|
3389
|
-
else its first photo. A photo is never an RSS `enclosure`, an Atom
|
|
3390
|
-
`rel="enclosure"` link or a JSON Feed attachment. Those carry a recording only.
|
|
3391
|
-
|
|
3392
|
-
### RSS 2.0
|
|
3393
|
-
|
|
3394
|
-
The channel carries `title`, `link`, `description` (the tagline), `language`
|
|
3395
|
-
(the `language` setting, `en` by default), `lastBuildDate`, `generator`, an
|
|
3396
|
-
`atom:link rel="self"`, the three notify-server elements described under
|
|
3397
|
-
[Real-time notification](#real-time-notification), and an `image` built from the
|
|
3398
|
-
avatar when the site has one.
|
|
3399
|
-
|
|
3400
|
-
An item carries `title`, `link`, `guid`, `pubDate` in RFC 822, `dc:creator` from
|
|
3401
|
-
the post's author or the site's, one `category` per term, `description` holding
|
|
3402
|
-
the summary, `content:encoded` holding the whole rendered post, and
|
|
3403
|
-
`source:markdown` holding the Markdown the post was written from. A reply also
|
|
3404
|
-
carries `source:inReplyTo` holding the URL it answers. The `guid`, the terms
|
|
3405
|
-
and the summary are the ones described above.
|
|
3406
|
-
|
|
3407
|
-
`source:markdown` is Dave Winer's [source namespace][source-ns]: a reader that
|
|
3408
|
-
understands Markdown should render from it rather than from `content:encoded`.
|
|
3409
|
-
It is the same text the ActivityStreams `Article` carries as its `source`.
|
|
3410
|
-
|
|
3411
|
-
Every item also says where its comments are: `comments` is the page to read
|
|
3412
|
-
them on and `wfw:commentRss` (the [Well-Formed Web][wfw-ns] comment API) is the
|
|
3413
|
-
feed of the whole thread to poll. A post with replies also carries
|
|
3414
|
-
`source:comments`, whose `count` is how many replies answer the post directly
|
|
3415
|
-
and whose `feedUrl` is its `/replies/{key}/` feed holding only those (see
|
|
3416
|
-
[Walking a thread](#walking-a-thread)). A post nobody answered carries no
|
|
3417
|
-
`source:comments`.
|
|
3418
|
-
|
|
3419
|
-
When the site has a license (Settings > General, or `license` in `site.json`),
|
|
3420
|
-
the channel carries a `creativeCommons:license` holding its URL, and every item
|
|
3421
|
-
carries one for its own license: the post's `license` front matter, else the
|
|
3422
|
-
site's. A post whose front matter says `license: none` carries none. The
|
|
3423
|
-
element is the [Creative Commons RSS module][cc-rss], declared as
|
|
3424
|
-
`xmlns:creativeCommons` only when some license is printed. It names a license
|
|
3425
|
-
by URL, Creative Commons or not, which a reader can act on; `dc:rights` would be
|
|
3426
|
-
free text. Nothing about a license is printed on a site and posts without one.
|
|
3427
|
-
RSS has no element that says "no license", so a post saying `license: none`
|
|
3428
|
-
on a licensed site carries no item license while the channel still names the
|
|
3429
|
-
site's; a reader that applies the channel's license to every item reads that
|
|
3430
|
-
post as under it.
|
|
3431
|
-
|
|
3432
|
-
[source-ns]: https://source.scripting.com/
|
|
3433
|
-
[wfw-ns]: http://wellformedweb.org/CommentAPI/
|
|
3434
|
-
[cc-rss]: https://cyber.harvard.edu/rss/creativeCommonsRssModule.html
|
|
3435
|
-
|
|
3436
|
-
### Comments
|
|
3437
|
-
|
|
3438
|
-
The comments feeds publish a document's conversation, at the URLs WordPress
|
|
3439
|
-
publishes its own at: `{permalink}feed/` for one post or page, and
|
|
3440
|
-
`/comments/feed/` for the whole site. Both are read from the same conversation
|
|
3441
|
-
the page draws under the post, so they carry what a reader sees there:
|
|
3442
|
-
|
|
3443
|
-
- native comments left through the form, once a moderator approves them;
|
|
3444
|
-
- webmention replies and mentions;
|
|
3445
|
-
- fediverse replies, a `Create` of a `Note` whose `inReplyTo` names the post
|
|
3446
|
-
or a reply under it (see [Federation](#federation)), and the quotes of a
|
|
3447
|
-
post its author approved;
|
|
3448
|
-
- reply posts written on this site that answer the post or a reply under it.
|
|
3449
|
-
|
|
3450
|
-
That is replies and mentions. Likes, boosts and reposts say nothing, so they
|
|
3451
|
-
are shown and counted on the page and are in no feed, and no `source:comments`
|
|
3452
|
-
count includes them: a post answered only with reactions advertises no replies
|
|
3453
|
-
feed. An RSVP to an event is shown with the people answering, not in the
|
|
3454
|
-
thread, so it is in no feed either, words or not; an RSVP sent to a post that
|
|
3455
|
-
is no event is a reply like any other. Neither feed carries a
|
|
3456
|
-
[migrated comment](#migrated-comments).
|
|
3457
|
-
|
|
3458
|
-
Both are RSS 2.0 and nothing else: a comments feed has no Atom or JSON spelling
|
|
3459
|
-
here, so `{permalink}feed/atom/` 404s. A post has a comments feed whether or
|
|
3460
|
-
not it still takes comments. A page has one while it takes comments
|
|
3461
|
-
(`comments: true` in its front matter, and comments on for the site), which is
|
|
3462
|
-
when it shows its conversation; any other page's `{permalink}feed/` 404s, and
|
|
3463
|
-
its comments are left out of `/comments/feed/` too. A document with no replies
|
|
3464
|
-
answers an empty feed rather than a 404, because it exists and a reader that
|
|
3465
|
-
subscribed before anybody answered should keep polling. A permalink that is no
|
|
3466
|
-
published document 404s like any other. The page advertises its own feed with
|
|
3467
|
-
a `<link rel="alternate" type="application/rss+xml">` in its `<head>`.
|
|
3468
|
-
|
|
3469
|
-
A channel carries the usual `title` (`Comments on: {post}`), `link`,
|
|
3470
|
-
`description`, `language`, `lastBuildDate`, `generator`, `atom:link rel="self"`
|
|
3471
|
-
and the same notify-server elements every other feed carries. An item carries the author's name as its `title` and `dc:creator`,
|
|
3472
|
-
the reply's `url` as its `link` (a comment left through the form links to its
|
|
3473
|
-
own page at `/comment/{key}/`), a `guid`, the `published` time the reply gave (or
|
|
3474
|
-
when it arrived, if it gave none), `description` holding a plain-text excerpt
|
|
3475
|
-
and `content:encoded` holding the reply. The `guid` of a comment left through the
|
|
3476
|
-
form is its page, `isPermaLink="true"`; a fediverse note's is its id, a
|
|
3477
|
-
webmention's its source URL, a reply post's its object id. A migrated comment
|
|
3478
|
-
is in neither feed, so its old `guid` never has to survive the move.
|
|
3479
|
-
`isPermaLink` is `true` exactly when the `guid` is the `link`.
|
|
3480
|
-
|
|
3481
|
-
Each item also carries, from the [source namespace][source-ns]:
|
|
3482
|
-
|
|
3483
|
-
- `source:inReplyTo`, the `guid` of what it answers in these feeds: the post's
|
|
3484
|
-
feed `guid` for an answer to the post, else the `guid` of the reply it
|
|
3485
|
-
answers. A reply under a comment a reader may not see (one waiting for a
|
|
3486
|
-
moderator, spam or deleted) names the nearest reply above it a reader can
|
|
3487
|
-
see, or the post, and is counted as that one's reply.
|
|
3488
|
-
- `source:comments`, only on an item with replies: `count` is how many answer
|
|
3489
|
-
it directly and `feedUrl` its `/replies/` feed.
|
|
3490
|
-
- `<source url="…">Name</source>`, the core RSS element, when the site knows a
|
|
3491
|
-
feed for the author: a webmention author's `h-card` URL or site, or a site
|
|
3492
|
-
user's author feed `/author/{username}/feed/`. A comment left through the
|
|
3493
|
-
form and a fediverse reply carry none.
|
|
3494
|
-
|
|
3495
|
-
On the site-wide feed the title names the post or page as well: `{author} on
|
|
3496
|
-
{post}`, or `{author} replying to {parent author} on {post}` for an answer to
|
|
3497
|
-
another reply a reader can see.
|
|
3498
|
-
|
|
3499
|
-
A fediverse reply's author is named by the profile the site stored when that
|
|
3500
|
-
actor followed it, and otherwise the `@user@host` the actor's own URL implies. Naming them
|
|
3501
|
-
properly would mean dereferencing the actor, which is a network round trip per
|
|
3502
|
-
comment shown.
|
|
3503
|
-
|
|
3504
|
-
**A reply's HTML is sanitised before it is published.** It is markup a
|
|
3505
|
-
stranger wrote, so it is tokenised and rebuilt from an allowlist rather than
|
|
3506
|
-
passed through: `p`, `br`, `a`, `em`, `strong`, `del`, `code`, `pre`,
|
|
3507
|
-
`blockquote` and the list elements survive; `script` and `style` are dropped
|
|
3508
|
-
along with their contents; every other element is unwrapped, keeping its text;
|
|
3509
|
-
every attribute goes except an `a`'s `href`, which must be `http`, `https` or
|
|
3510
|
-
`mailto` and is marked `rel="nofollow noopener noreferrer"`. `sanitizeCommentHtml`
|
|
3511
|
-
is exported for a site that shows comments in its own templates.
|
|
3512
|
-
|
|
3513
|
-
A reply to a document that has since been unpublished or moved to the trash
|
|
3514
|
-
disappears from `/comments/feed/`, and that document's own feed 404s with it.
|
|
3515
|
-
`feedSize` caps both feeds.
|
|
3516
|
-
|
|
3517
|
-
### Walking a thread
|
|
3518
|
-
|
|
3519
|
-
`{permalink}feed/` is the whole thread, flat, which is what WordPress
|
|
3520
|
-
publishes and `wfw:commentRss` points at. A program that wants the tree
|
|
3521
|
-
(Dave Winer's [rss.chat][rss-chat] reads conversations this way) walks it one
|
|
3522
|
-
level at a time instead: from a post item's `source:comments` to a feed of the
|
|
3523
|
-
replies that answer the post directly, and from each of those items' own
|
|
3524
|
-
`source:comments` to the replies that answer it, until an item has none.
|
|
3525
|
-
Every reply a reader can see is reached once.
|
|
3526
|
-
|
|
3527
|
-
Each of those feeds is `/replies/{key}/`, RSS 2.0, newest first, its items in
|
|
3528
|
-
the same shape as the comments feeds. It carries replies only: a mention
|
|
3529
|
-
answers nothing in the thread, and a like, a boost, a repost or an event's RSVP
|
|
3530
|
-
is in no feed. A comment's key is its id (see
|
|
3531
|
-
[Keys](#keys)); a post's, a page's, a fediverse reply's or a reply post's is
|
|
3532
|
-
the first 16 hex digits of the SHA-256 of its feed `guid`, since a document has
|
|
3533
|
-
no id of its own and slugs are not unique across posts and pages. An item
|
|
3534
|
-
nobody answered answers an empty feed; a segment that is no key, or a key that
|
|
3535
|
-
names nothing a reader can see, 404s. A migrated comment's feed still answers,
|
|
3536
|
-
with the native replies under it, though no feed links to it.
|
|
3537
|
-
|
|
3538
|
-
[rss-chat]: https://github.com/scripting/rss.chat
|
|
3539
|
-
|
|
3540
|
-
### Atom and JSON Feed
|
|
3541
|
-
|
|
3542
|
-
An Atom entry carries `id` (the post's object id), `title`, `updated`,
|
|
3543
|
-
`published`, `link rel="alternate"` (the permalink), an `author` when the front
|
|
3544
|
-
matter names one, a `category` per term, a `summary`, and the whole rendered
|
|
3545
|
-
post as `content type="html"`. The feed itself carries `id`, `title`,
|
|
3546
|
-
`subtitle` from the site's tagline, `updated`, `link rel="self"`,
|
|
3547
|
-
`link rel="alternate"` to the HTML page, a `generator`, an
|
|
3548
|
-
`xml:lang` from the `language` setting, and — when the site names a notify
|
|
3549
|
-
server — a `source:cloud` and a `link rel="hub"`.
|
|
3550
|
-
|
|
3551
|
-
With a license, the feed carries an [RFC 4946][rfc4946] `link rel="license"`
|
|
3552
|
-
to the site's license, its name as the `title`, and every entry carries one for
|
|
3553
|
-
its own license, the post's `license` front matter else the site's. An entry
|
|
3554
|
-
states its own because RFC 4946 does not let an entry inherit the feed's. JSON
|
|
3555
|
-
Feed has no field for a license and says nothing about one.
|
|
3556
|
-
|
|
3557
|
-
[rfc4946]: https://www.rfc-editor.org/rfc/rfc4946
|
|
3558
|
-
|
|
3559
|
-
A JSON Feed item carries `id` (the object id), `url` (the permalink), `title`,
|
|
3560
|
-
`content_html`, `image` (see Photos above), `summary`, `date_published`, `date_modified`, `tags` (the
|
|
3561
|
-
terms) and `authors`; the feed carries `version`, `title`, `home_page_url`,
|
|
3562
|
-
`feed_url`, `description`, `authors` and `hubs`. Keys with nothing behind them
|
|
3563
|
-
are left out rather than sent empty.
|
|
3564
|
-
|
|
3565
|
-
The XML is written by this package rather than by a library. Text is escaped;
|
|
3566
|
-
`content:encoded` and `source:markdown` are CDATA sections, with any `]]>` in
|
|
3567
|
-
the text split across two sections so it cannot end one early.
|
|
3568
|
-
|
|
3569
|
-
### A recording on a post
|
|
3570
|
-
|
|
3571
|
-
A post can carry one recording: a spoken version of an article, or an episode
|
|
3572
|
-
now and then. The site is a blog that sometimes has audio, not a podcast host,
|
|
3573
|
-
so the feeds carry what a podcast app needs to play, describe and caption an
|
|
3574
|
-
item, and leave the podcast directory tags out. The front matter spells it the
|
|
3575
|
-
way RSS does:
|
|
3576
|
-
|
|
3577
|
-
```yaml
|
|
3578
|
-
enclosure:
|
|
3579
|
-
url: /uploads/2026/10/episode-12.mp3 # an upload; written by the editor
|
|
3580
|
-
type: audio/mpeg # from the upload table; written by the editor
|
|
3581
|
-
length: 23456789 # bytes, from the file on disk; written by the editor
|
|
3582
|
-
duration: 1834 # seconds, optional
|
|
3583
|
-
transcript: # optional
|
|
3584
|
-
url: /uploads/2026/10/episode-12.vtt # an upload or an http(s) URL
|
|
3585
|
-
type: text/vtt # text/vtt, application/x-subrip, text/html or text/plain
|
|
3586
|
-
alternates: # optional, any number
|
|
3587
|
-
- url: /uploads/2026/10/episode-12.mp4
|
|
3588
|
-
type: video/mp4
|
|
3589
|
-
length: 98765432
|
|
3590
|
-
title: Video # optional, at most 32 characters
|
|
3591
|
-
height: 720 # optional, for video
|
|
3592
|
-
lang: en # optional
|
|
3593
|
-
- url: https://cdn.example.com/episode-12-low.mp3
|
|
3594
|
-
type: audio/mpeg # required for a link
|
|
3595
|
-
title: Low bandwidth # a link's length is optional
|
|
3596
|
-
```
|
|
3597
|
-
|
|
3598
|
-
**The main file is always an upload** under `/uploads/`, because RSS requires
|
|
3599
|
-
its `url`, `length` and `type`, and an upload is the only file whose length the
|
|
3600
|
-
site knows. A main file without all three is no recording at all. A bad
|
|
3601
|
-
alternate version or transcript is dropped on its own, so one wrong line in a
|
|
3602
|
-
hand-edited file costs that line and not the episode. `enclosureOf(extra)` is
|
|
3603
|
-
the one reading of the key, and the feeds, the theme and the federation read
|
|
3604
|
-
only what it returns.
|
|
3605
|
-
|
|
3606
|
-
**The post editor** has a Recording section on posts. The main file is a select
|
|
3607
|
-
of the audio and video in the media library, and None removes the whole key,
|
|
3608
|
-
transcript and alternate versions included. The duration takes seconds, `30:34`
|
|
3609
|
-
or `1:02:03`. The transcript is an upload (`.vtt`, `.srt` or `.txt`) or an
|
|
3610
|
-
`https` address with its type. Alternate versions are numbered rows with one
|
|
3611
|
-
blank row at the end, so a plain form can add, edit and remove them; emptying a
|
|
3612
|
-
row's address removes it. On save, the type and length of every upload are read
|
|
3613
|
-
from the upload table and from the file on disk, never from the form. A main
|
|
3614
|
-
file that is not an audio or video upload, a linked version without an `http`
|
|
3615
|
-
or `https` address or a media type, or a title over 32 characters is refused
|
|
3616
|
-
with a message, and nothing is written.
|
|
3617
|
-
|
|
3618
|
-
**What the feeds print, and when.** A post with no recording prints none of
|
|
3619
|
-
this, and a feed with no recording in it declares neither namespace, so its
|
|
3620
|
-
bytes and its ETag are what they were before recordings existed.
|
|
3621
|
-
|
|
3622
|
-
| Element | Format | Printed when |
|
|
3623
|
-
| ----------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
3624
|
-
| `<enclosure url length type/>` | RSS item | The post has a main file. `url` is absolute. |
|
|
3625
|
-
| `<podcast:alternateEnclosure>` with `<podcast:source uri/>` | RSS item | Once per alternate version, with `type`, and `length`, `title`, `height` and `lang` when known. |
|
|
3626
|
-
| `<podcast:transcript url type/>` | RSS item | The post has a transcript, with `rel="captions"` for WebVTT or SubRip. |
|
|
3627
|
-
| `<itunes:duration>` | RSS item | The duration is known, in whole seconds. |
|
|
3628
|
-
| `<podcast:medium>blog</podcast:medium>` | RSS channel | Any item in the feed has a recording. Without it an app assumes `podcast`; `blog` is the spec's word for articles that sometimes have audio. |
|
|
3629
|
-
| `<link rel="enclosure" type length href/>` | Atom entry | The post has a main file. Atom has no way to say the others are versions of it. |
|
|
3630
|
-
| `attachments` | JSON Feed | The main file and then each alternate version, with `url`, `mime_type`, `size_in_bytes` and `title` when known, and `duration_in_seconds` on audio or video when the duration is known. |
|
|
3631
|
-
|
|
3632
|
-
`xmlns:podcast="https://podcastindex.org/namespace/1.0"` is declared only on a
|
|
3633
|
-
feed that prints a podcast element, and
|
|
3634
|
-
`xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"` only on one that
|
|
3635
|
-
prints `itunes:duration`.
|
|
3636
|
-
|
|
3637
|
-
**Left out on purpose:** the Apple directory tags (`itunes:image`,
|
|
3638
|
-
`itunes:category`, `itunes:explicit`, `itunes:owner`), and `podcast:guid`,
|
|
3639
|
-
`podcast:locked`, `podcast:person`, `podcast:season`, `podcast:episode`,
|
|
3640
|
-
`podcast:value` and `podcast:chapters`. Each one matters only to a podcast
|
|
3641
|
-
directory or a dedicated show. Adding them is a follow-up for a site that wants
|
|
3642
|
-
to be listed in Apple Podcasts, and so is `podcast:license`, which could reuse
|
|
3643
|
-
the [content license](#rss-20).
|
|
3644
|
-
|
|
3645
|
-
**The fediverse** gets the main file as an `Audio` or `Video` attachment with
|
|
3646
|
-
its absolute `url`, its `mediaType`, and the post's title, or a note's first
|
|
3647
|
-
words, as its `name`. It goes before the post's `Image` attachments, because
|
|
3648
|
-
Mastodon shows a player or a gallery by the type of the first attachment and
|
|
3649
|
-
keeps only four. Alternate versions and linked files stay out: a remote server
|
|
3650
|
-
fetches and re-encodes what it is given, and a link to another host is a type
|
|
3651
|
-
this site cannot check. `playsAsVideo(type)` decides `Video` against `Audio`,
|
|
3652
|
-
and the default theme's `<video>` against `<audio>`, so the two never disagree.
|
|
3653
|
-
|
|
3654
|
-
The default theme plays the main file on the post page; see its README.
|
|
3655
|
-
|
|
3656
|
-
### Real-time notification
|
|
3657
|
-
|
|
3658
|
-
A feed reader that polls hears about a post when it next polls. An [rssCloud][]
|
|
3659
|
-
or [WebSub][] server turns that around: the reader registers with the server,
|
|
3660
|
-
the site tells the server when a feed changes, and the server tells every
|
|
3661
|
-
subscriber at once.
|
|
3662
|
-
|
|
3663
|
-
One setting, `notifyServer`, is the whole of it. It defaults to
|
|
3664
|
-
`https://rpc.rsscloud.io`, which speaks both protocols, and everything else is
|
|
3665
|
-
derived from it: `{notifyServer}/pleaseNotify` is where a subscriber registers,
|
|
3666
|
-
`{notifyServer}/websub` is the hub, and `{notifyServer}/ping` is where this site
|
|
3667
|
-
says a feed changed. Emptying the setting removes every element, every header
|
|
3668
|
-
and every ping; a different URL moves all of them.
|
|
3669
|
-
|
|
3670
|
-
**Advertising.** An RSS channel carries all three spellings, so a reader of any
|
|
3671
|
-
age finds one it understands:
|
|
3672
|
-
|
|
3673
|
-
```xml
|
|
3674
|
-
<cloud domain="rpc.rsscloud.io" port="80" path="/pleaseNotify"
|
|
3675
|
-
registerProcedure="" protocol="http-post"/>
|
|
3676
|
-
<source:cloud>https://rpc.rsscloud.io/pleaseNotify</source:cloud>
|
|
3677
|
-
<atom:link rel="hub" href="https://rpc.rsscloud.io/websub"/>
|
|
3678
|
-
```
|
|
3679
|
-
|
|
3680
|
-
The port and protocol of the legacy `<cloud>` are `80` and `http-post` whatever
|
|
3681
|
-
scheme the server is actually reached over, because that is what
|
|
3682
|
-
[rpc.rsscloud.io's quick start][quick-start] prescribes and what a 2001
|
|
3683
|
-
aggregator expects to read. An Atom feed carries the `source:cloud` and the
|
|
3684
|
-
`rel="hub"` link but no `<cloud>`, which is an RSS 2.0 element with no namespace.
|
|
3685
|
-
A JSON Feed carries JSON Feed 1.1's `hubs`:
|
|
3686
|
-
|
|
3687
|
-
```json
|
|
3688
|
-
{ "hubs": [{ "type": "WebSub", "url": "https://rpc.rsscloud.io/websub" }] }
|
|
3689
|
-
```
|
|
3690
|
-
|
|
3691
|
-
And every feed response in every format — the site's, each archive's and both
|
|
3692
|
-
comments feeds — carries WebSub's discovery header, on the `304` as well as on
|
|
3693
|
-
the body, because a poller mostly gets the `304`:
|
|
3694
|
-
|
|
3695
|
-
```
|
|
3696
|
-
Link: <https://rpc.rsscloud.io/websub>; rel="hub", <https://example.com/feed/>; rel="self"
|
|
3697
|
-
```
|
|
3698
|
-
|
|
3699
|
-
**Pinging.** The server notifies nobody until it hears the feed changed. So when
|
|
3700
|
-
a post is published, edited or withdrawn — the same index changes that drive
|
|
3701
|
-
ActivityPub delivery, and never a full scan — the site posts
|
|
3702
|
-
`url={feed}` to `{notifyServer}/ping`, once for each feed whose contents moved:
|
|
3703
|
-
the three site feeds, and the three feeds of every tag and category the post
|
|
3704
|
-
carried before _and_ after the change, because a post that left a tag changed
|
|
3705
|
-
that tag's feed as much as the one it joined.
|
|
3706
|
-
|
|
3707
|
-
Pings are best effort. They run one after another on a queue of their own, a
|
|
3708
|
-
repeated URL is sent once, each is given ten seconds, and a refusal is logged
|
|
3709
|
-
rather than thrown: a notify server that is down costs a line in the log, never
|
|
3710
|
-
a save. A site can ping a feed of its own:
|
|
3711
|
-
|
|
3712
|
-
```js
|
|
3713
|
-
await cms.notifyFeeds(['https://example.com/podcast/feed/']);
|
|
3714
|
-
```
|
|
3715
|
-
|
|
3716
|
-
`cms.notifier` is the service behind it — `notify(urls)`, `feedsFor(change)`,
|
|
3717
|
-
`handle(change)` and `settled()` — already subscribed to the index.
|
|
3718
|
-
|
|
3719
|
-
[rsscloud]: https://rpc.rsscloud.io/docs
|
|
3720
|
-
[websub]: https://www.w3.org/TR/websub/
|
|
3721
|
-
[quick-start]: https://rpc.rsscloud.io/docs/quick-start
|
|
3722
|
-
|
|
3723
|
-
### Caching and discovery
|
|
3724
|
-
|
|
3725
|
-
Every feed carries `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and
|
|
3726
|
-
answers `If-None-Match` and `If-Modified-Since` with `304`. The validator covers
|
|
3727
|
-
the feed's metadata as well as its entries, and each format and each scope gets
|
|
3728
|
-
its own, so a reader holding the RSS feed is never told the Atom one is
|
|
3729
|
-
unchanged. Feeds are validated even while `watch` is on, because a feed is not
|
|
3730
|
-
rendered through the theme.
|
|
3731
|
-
|
|
3732
|
-
The validator also covers the revision of the item format itself, so a release
|
|
3733
|
-
that changes what a feed says about a post moves every post feed's `ETag` once
|
|
3734
|
-
rather than leaving a polling reader with a `304` that hides the new bytes.
|
|
3735
|
-
|
|
3736
|
-
```sh
|
|
3737
|
-
curl -i https://example.com/feed/
|
|
3738
|
-
curl -i https://example.com/tag/releases/feed/json/
|
|
3739
|
-
```
|
|
3740
|
-
|
|
3741
|
-
Every page of the default theme advertises all three feeds in its `<head>`, RSS
|
|
3742
|
-
first, then the site's comments feed; an archive advertises that archive's three
|
|
3743
|
-
as well, and a post its own comments feed (the renderer puts that URL in
|
|
3744
|
-
`commentsFeed`, so a layout that overrides `post.njk` keeps it). A theme that
|
|
3745
|
-
does not extend `layouts/base.njk` should emit them itself:
|
|
3746
|
-
|
|
3747
|
-
```html
|
|
3748
|
-
<link
|
|
3749
|
-
rel="alternate"
|
|
3750
|
-
type="application/rss+xml"
|
|
3751
|
-
title="My Site"
|
|
3752
|
-
href="/feed/"
|
|
3753
|
-
/>
|
|
3754
|
-
<link
|
|
3755
|
-
rel="alternate"
|
|
3756
|
-
type="application/atom+xml"
|
|
3757
|
-
title="My Site"
|
|
3758
|
-
href="/feed/atom/"
|
|
3759
|
-
/>
|
|
3760
|
-
<link
|
|
3761
|
-
rel="alternate"
|
|
3762
|
-
type="application/feed+json"
|
|
3763
|
-
title="My Site"
|
|
3764
|
-
href="/feed/json/"
|
|
3765
|
-
/>
|
|
3766
|
-
<link
|
|
3767
|
-
rel="alternate"
|
|
3768
|
-
type="application/rss+xml"
|
|
3769
|
-
title="My Site comments"
|
|
3770
|
-
href="/comments/feed/"
|
|
3771
|
-
/>
|
|
3772
|
-
```
|
|
3773
|
-
|
|
3774
|
-
The builders are exported, so a site can write a feed of its own — one
|
|
3775
|
-
category, one author — without reimplementing any of the three formats:
|
|
3776
|
-
|
|
3777
|
-
```ts
|
|
3778
|
-
import { atomFeed, createRenderer, jsonFeed, rssFeed } from '@geekity/cms';
|
|
3779
|
-
|
|
3780
|
-
const site = createRenderer({ config: cms.config }).site();
|
|
3781
|
-
|
|
3782
|
-
const source = {
|
|
3783
|
-
site,
|
|
3784
|
-
documents: cms.store.listByTag('releases', { limit: 20 }),
|
|
3785
|
-
title: `${site.title}: releases`,
|
|
3786
|
-
href: '/tag/releases/',
|
|
3787
|
-
feedHref: '/tag/releases/feed/',
|
|
3788
|
-
baseUrl: cms.config.baseUrl,
|
|
3789
|
-
};
|
|
3790
|
-
|
|
3791
|
-
rssFeed(source); // a string
|
|
3792
|
-
atomFeed(source); // a string
|
|
3793
|
-
jsonFeed(source); // a JSON Feed object
|
|
3794
|
-
```
|
|
3795
|
-
|
|
3796
|
-
`commentsRssFeed` is the same for comments, and what goes in it comes from the
|
|
3797
|
-
conversation reader — the one thing that reads the inbox log and the comment
|
|
3798
|
-
index to show somebody what has been said, so a feed and the page under the
|
|
3799
|
-
post carry the same entries:
|
|
3800
|
-
|
|
3801
|
-
```ts
|
|
3802
|
-
import {
|
|
3803
|
-
commentsRssFeed,
|
|
3804
|
-
createConversation,
|
|
3805
|
-
feedComments,
|
|
3806
|
-
} from '@geekity/cms';
|
|
3807
|
-
|
|
3808
|
-
const conversation = createConversation({
|
|
3809
|
-
admin: cms.admin,
|
|
3810
|
-
store: cms.store,
|
|
3811
|
-
baseUrl: cms.config.baseUrl,
|
|
3812
|
-
});
|
|
3813
|
-
|
|
3814
|
-
commentsRssFeed({
|
|
3815
|
-
site,
|
|
3816
|
-
comments: feedComments(conversation.latest(20), {
|
|
3817
|
-
baseUrl: cms.config.baseUrl,
|
|
3818
|
-
limit: 20,
|
|
3819
|
-
}),
|
|
3820
|
-
title: `${site.title}: comments`,
|
|
3821
|
-
href: '/',
|
|
3822
|
-
feedHref: '/comments/feed/',
|
|
3823
|
-
baseUrl: cms.config.baseUrl,
|
|
3824
|
-
});
|
|
3825
|
-
```
|
|
3826
|
-
|
|
3827
|
-
`conversation.thread(document)` is the same reading for one post — the shape the
|
|
3828
|
-
theme's `conversation.njk` is handed — `spokenIn` flattens it into the entries a
|
|
3829
|
-
feed carries, each with the reply it sits under, `conversation.counts(documents)`
|
|
3830
|
-
is the number of direct replies `source:comments` puts beside each item of a
|
|
3831
|
-
post feed, and `conversation.repliesTo(segment)` is what one `/replies/` feed
|
|
3832
|
-
holds.
|
|
3833
|
-
|
|
3834
|
-
## Sitemap and robots.txt
|
|
3835
|
-
|
|
3836
|
-
Two more fixed routes, at the two paths a crawler goes looking for on its own.
|
|
3837
|
-
|
|
3838
|
-
`/sitemap.xml` is every URL the public site publishes, in the
|
|
3839
|
-
[sitemap protocol's](https://www.sitemaps.org/protocol.html) `<urlset>`:
|
|
3840
|
-
|
|
3841
|
-
- the home archive and each of its pages, `/`, `/page/2/` and so on;
|
|
3842
|
-
- every published post and every page, at its permalink;
|
|
3843
|
-
- every tag archive and every category archive, with each of their pages,
|
|
3844
|
-
under whatever bases the site is configured with.
|
|
3845
|
-
|
|
3846
|
-
Each `<loc>` is absolute, built on `baseUrl`. `<lastmod>` is a document's
|
|
3847
|
-
`updated` when it has one and its `date` otherwise, and for a listing page it
|
|
3848
|
-
is the newest of those among the documents on that page. Something nothing
|
|
3849
|
-
dates — a page with no date in its front matter — carries no `<lastmod>`
|
|
3850
|
-
rather than an invented one.
|
|
3851
|
-
|
|
3852
|
-
Nothing hidden reaches it. The sitemap is drawn from the same queries the
|
|
3853
|
-
listings are and checked against the same `isListed`, so drafts, the trash and
|
|
3854
|
-
posts whose date has not arrived are absent for the same reason their
|
|
3855
|
-
permalinks 404, and an [unlisted post](#unlisted-posts) is absent for the same
|
|
3856
|
-
reason it is off the home page.
|
|
3857
|
-
|
|
3858
|
-
The protocol caps one file at 50,000 URLs. Past that, `/sitemap.xml` becomes a
|
|
3859
|
-
`<sitemapindex>` naming `/sitemap-1.xml`, `/sitemap-2.xml` and so on, each
|
|
3860
|
-
holding its own slice and dated by the newest URL in it. The address a search
|
|
3861
|
-
engine holds does not change, which is the point of the index living there.
|
|
3862
|
-
While the whole sitemap fits in one file the children name nothing and 404.
|
|
3863
|
-
|
|
3864
|
-
`/robots.txt` starts short:
|
|
3865
|
-
|
|
3866
|
-
```
|
|
3867
|
-
User-agent: *
|
|
3868
|
-
Disallow: /admin/
|
|
3869
|
-
|
|
3870
|
-
Sitemap: https://example.com/sitemap.xml
|
|
3871
|
-
```
|
|
3872
|
-
|
|
3873
|
-
Everything public is crawlable; only the admin is not. The actors and their
|
|
3874
|
-
collections are deliberately left open — an actor and an object exist to be
|
|
3875
|
-
fetched, they carry the same
|
|
3876
|
-
content as the pages that link to them, and a crawler that follows one gets
|
|
3877
|
-
JSON it will ignore.
|
|
3878
|
-
|
|
3879
|
-
The Crawlers section of Settings → Reading adds to it, and writes what it sets
|
|
3880
|
-
to `site.json`:
|
|
3881
|
-
|
|
3882
|
-
| Setting | `site.json` key | What the file gains |
|
|
3883
|
-
| --------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
3884
|
-
| AI crawlers | `aiCrawlers` | `allow` (the default) adds nothing. `block-training` adds a `Disallow: /` group for each training crawler: GPTBot, ClaudeBot, anthropic-ai, Google-Extended, Applebot-Extended, Bytespider, CCBot and meta-externalagent. `block-all` also blocks the retrieval crawlers: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, meta-webindexer and meta-externalfetcher. |
|
|
3885
|
-
| Content signals | `contentSignalSearch`, `contentSignalAiInput`, `contentSignalAiTrain` | Each `yes`, `no` or empty. The ones set become one line, such as `Content-Signal: search=yes, ai-input=yes, ai-train=no`. |
|
|
3886
|
-
| More robots.txt rules | `robotsRules` | Groups of the site's own, one line per array entry: `User-agent`, then `Allow`, `Disallow`, `Crawl-delay`, `Content-Signal` or `Sitemap` lines. |
|
|
3887
|
-
|
|
3888
|
-
A crawler obeys only the one group that names it (RFC 9309), so the CMS puts
|
|
3889
|
-
`Disallow: /admin/` and the content signal into every group, its own and the
|
|
3890
|
-
site's, unless the group already disallows everything or carries a signal of
|
|
3891
|
-
its own. The form refuses a line that is not a robots.txt field, a rule with no
|
|
3892
|
-
`User-agent` above it, and an `Allow` under `/admin/`; a hand edit of
|
|
3893
|
-
`site.json` that does any of these loses that line. The site's own `Sitemap:`
|
|
3894
|
-
line is always there.
|
|
3895
|
-
|
|
3896
|
-
For example, `block-training`, `ai-train` set to `no` and a rule of
|
|
3897
|
-
`User-agent: SlowBot` / `Crawl-delay: 10` serve:
|
|
3898
|
-
|
|
3899
|
-
```
|
|
3900
|
-
User-agent: *
|
|
3901
|
-
Disallow: /admin/
|
|
3902
|
-
Content-Signal: ai-train=no
|
|
3903
|
-
|
|
3904
|
-
User-agent: GPTBot
|
|
3905
|
-
Disallow: /
|
|
3906
|
-
|
|
3907
|
-
…one group per training crawler…
|
|
3908
|
-
|
|
3909
|
-
User-agent: SlowBot
|
|
3910
|
-
Disallow: /admin/
|
|
3911
|
-
Crawl-delay: 10
|
|
3912
|
-
Content-Signal: ai-train=no
|
|
3913
|
-
|
|
3914
|
-
Sitemap: https://example.com/sitemap.xml
|
|
3915
|
-
```
|
|
3916
|
-
|
|
3917
|
-
Both are registered routes rather than anything resolved from the content
|
|
3918
|
-
index, so a document permalinked at `/sitemap.xml` cannot take the URL a
|
|
3919
|
-
search engine polls, and both carry an `ETag` and answer a conditional request
|
|
3920
|
-
with 304 the way the feeds do — the sitemap with a `Last-Modified` as well,
|
|
3921
|
-
robots without one, because nothing dates it.
|
|
3922
|
-
|
|
3923
|
-
Nothing links the sitemap from a page: `robots.txt` names it, which is where a
|
|
3924
|
-
crawler looks.
|
|
3925
|
-
|
|
3926
|
-
## llms.txt
|
|
3927
|
-
|
|
3928
|
-
`/llms.txt` describes the site for a language model, in the
|
|
3929
|
-
[llms.txt](https://llmstxt.org/) format. It holds the site's title as a heading
|
|
3930
|
-
and its tagline as a quote. Then it lists every public page by title and the
|
|
3931
|
-
posts the feeds carry, newest first. Each entry links to the document's
|
|
3932
|
-
Markdown, which is what a model reads best:
|
|
3933
|
-
|
|
3934
|
-
```markdown
|
|
3935
|
-
# Field Notes
|
|
3936
|
-
|
|
3937
|
-
> Notes from the field.
|
|
3938
|
-
|
|
3939
|
-
## Pages
|
|
3940
|
-
|
|
3941
|
-
- [About](https://example.com/about/index.md): Who writes this.
|
|
3942
|
-
|
|
3943
|
-
## Recent posts
|
|
3944
|
-
|
|
3945
|
-
- [Third post](https://example.com/third/index.md)
|
|
3946
|
-
```
|
|
3947
|
-
|
|
3948
|
-
A document's `description` follows its link. A static homepage is listed at
|
|
3949
|
-
`/index.md`, because `/` is where it is published. The file is served as
|
|
3950
|
-
`text/markdown` and carries an `ETag` and a `Last-Modified`, the newest date
|
|
3951
|
-
among its entries. It answers a conditional request with 304, as the sitemap
|
|
3952
|
-
does.
|
|
3953
|
-
|
|
3954
|
-
The home page advertises the file twice. The response carries
|
|
3955
|
-
`Link: </llms.txt>; rel="describedby"; type="text/markdown"`, for a tool that
|
|
3956
|
-
reads no HTML. The default theme's head carries
|
|
3957
|
-
`<link rel="describedby" type="text/markdown" href="/llms.txt">`. A theme
|
|
3958
|
-
gets the path as `llmsTxt` while the site serves the file.
|
|
3959
|
-
|
|
3960
|
-
To write the file yourself, put it at `content/llms.txt`. The CMS serves it
|
|
3961
|
-
exactly as written in place of the generated one, dated by the file. To serve
|
|
3962
|
-
no file at all, clear **Serve /llms.txt** under Settings → Reading. This
|
|
3963
|
-
writes `"llmsTxt": false` to `site.json`. `/llms.txt` then answers 404, even
|
|
3964
|
-
when `content/llms.txt` exists, and the home page stops advertising it.
|
|
3965
|
-
|
|
3966
|
-
## IndexNow
|
|
3967
|
-
|
|
3968
|
-
[IndexNow](https://www.indexnow.org/) gets a changed URL recrawled by Bing,
|
|
3969
|
-
Yandex, Naver, Seznam and the other engines that share it within minutes,
|
|
3970
|
-
instead of on their next visit. It is off by default, because it sends the
|
|
3971
|
-
site's URLs to a third party. To turn it on, select **Submit changes to
|
|
3972
|
-
IndexNow** under Settings → Reading.
|
|
3973
|
-
|
|
3974
|
-
The first time you turn it on, the CMS generates a key of 32 hex digits and
|
|
3975
|
-
writes it to `site.json` as `indexNowKey`, beside `"indexNow": true`. The site
|
|
3976
|
-
then serves the key at `/{key}.txt` as plain text, which is how an engine
|
|
3977
|
-
checks that the submissions are yours. Turning IndexNow off takes the file away
|
|
3978
|
-
and keeps the key, so turning it back on reuses the key the engines already
|
|
3979
|
-
checked. To change the key, write a new one of 8 to 128 letters, digits or
|
|
3980
|
-
dashes to `indexNowKey`. A key that does not fit that pattern counts as none,
|
|
3981
|
-
and the site sends nothing.
|
|
3982
|
-
|
|
3983
|
-
When a post or a page is published, changed, moved or deleted, the CMS submits
|
|
3984
|
-
its URL. A move submits the old URL and the new one. Unpublishing, trashing and
|
|
3985
|
-
deleting submit the URL that went away. The changes of the next ten seconds go
|
|
3986
|
-
out together as one `POST` to `https://api.indexnow.org/indexnow`:
|
|
3987
|
-
|
|
3988
|
-
```json
|
|
3989
|
-
{
|
|
3990
|
-
"host": "example.com",
|
|
3991
|
-
"key": "0123456789abcdef0123456789abcdef",
|
|
3992
|
-
"keyLocation": "https://example.com/0123456789abcdef0123456789abcdef.txt",
|
|
3993
|
-
"urlList": ["https://example.com/hello/"]
|
|
3994
|
-
}
|
|
3995
|
-
```
|
|
3996
|
-
|
|
3997
|
-
A network failure, a 429 or a 5xx answer is tried again after ten seconds, then
|
|
3998
|
-
after forty. Any other refusal, such as a 403 for a key the engine could not
|
|
3999
|
-
fetch, is not. A batch that still fails is logged as a warning, and the post
|
|
4000
|
-
stays published.
|
|
4001
|
-
|
|
4002
|
-
Nothing is sent for a draft or a scheduled post, for the boot scan, or while
|
|
4003
|
-
the base URL's host is local: `localhost`, a `.local` name, or a private IP
|
|
4004
|
-
address.
|
|
4005
|
-
|
|
4006
|
-
A site can change how the CMS sends with `indexNow` in its config. Every field
|
|
4007
|
-
is optional:
|
|
4008
|
-
|
|
4009
|
-
```ts
|
|
4010
|
-
export default defineConfig({
|
|
4011
|
-
indexNow: {
|
|
4012
|
-
fetch, // what submits the URLs; a test names one
|
|
4013
|
-
endpoint: 'https://www.bing.com/indexnow',
|
|
4014
|
-
batchMs: 10_000,
|
|
4015
|
-
attempts: 3,
|
|
4016
|
-
backoffMs: (attempt) => attempt * attempt * 10_000,
|
|
4017
|
-
logger: console,
|
|
4018
|
-
},
|
|
4019
|
-
});
|
|
4020
|
-
```
|
|
4021
|
-
|
|
4022
|
-
## Theme overrides
|
|
4023
|
-
|
|
4024
|
-
A site's themes live one directory per theme under `themesDir` (`themes/` by
|
|
4025
|
-
default), each with a `theme.json` naming it:
|
|
4026
|
-
|
|
4027
|
-
```json
|
|
4028
|
-
{
|
|
4029
|
-
"name": "Midnight",
|
|
4030
|
-
"kind": "site",
|
|
4031
|
-
"description": "Dark, quiet, mostly type."
|
|
4032
|
-
}
|
|
4033
|
-
```
|
|
4034
|
-
|
|
4035
|
-
`name` is what a person reads, `kind` is `site` — the only kind there is, and
|
|
4036
|
-
the field is in the file so another can be added later without the format
|
|
4037
|
-
changing — and `description` is optional. The directory name is the theme's id.
|
|
4038
|
-
|
|
4039
|
-
One setting in `content/_data/site.json`, `theme`, says which one the site is
|
|
4040
|
-
wearing, and that id is what it holds:
|
|
4041
|
-
|
|
4042
|
-
```json
|
|
4043
|
-
{ "theme": "midnight" }
|
|
4044
|
-
```
|
|
4045
|
-
|
|
4046
|
-
**Appearance > Themes** in the admin is where the choice is made. It lists the
|
|
4047
|
-
theme inside this package and every folder under `themesDir` with its name and
|
|
4048
|
-
description, marks the one in use, and Activate on another writes the setting —
|
|
4049
|
-
or, on the packaged theme, takes the key out of `site.json` again, which is why
|
|
4050
|
-
a site wearing it has no `theme` key rather than an empty one. A folder whose
|
|
4051
|
-
`theme.json` is missing, unparseable or names another kind is listed under
|
|
4052
|
-
"Not themes" with the reason, so a typo is something you can see on the screen
|
|
4053
|
-
that would otherwise have shown the theme. Activating takes effect on the next
|
|
4054
|
-
request, with no restart. Editing the setting by hand does exactly the same
|
|
4055
|
-
thing.
|
|
4056
|
-
|
|
4057
|
-
A template is then looked up in that theme first and in the theme that ships
|
|
4058
|
-
inside this package second, file by file. Sites override one template at a time
|
|
4059
|
-
and keep receiving updates to the rest. Files under `/theme/` resolve the same
|
|
4060
|
-
way, so `themes/midnight/static/style.css` replaces the packaged stylesheet,
|
|
4061
|
-
and the [email messages](#messages) under `mail/` resolve the same way too.
|
|
4062
|
-
|
|
4063
|
-
Neither the setting nor `themesDir` need exist. A site that names no theme
|
|
4064
|
-
serves every page, the 404 and the stylesheet out of the packaged theme, and an
|
|
4065
|
-
unchosen theme sitting in `themes/` changes nothing. A `theme` naming a
|
|
4066
|
-
directory that is not there, or one whose manifest will not read, falls back to
|
|
4067
|
-
the packaged theme with a warning in the log rather than a broken site.
|
|
4068
|
-
|
|
4069
|
-
A theme that ships only
|
|
4070
|
-
|
|
4071
|
-
```
|
|
4072
|
-
themes/midnight/layouts/post.njk
|
|
4073
|
-
```
|
|
4074
|
-
|
|
4075
|
-
replaces the post layout; the base layout, the archive, the tag pages and the
|
|
4076
|
-
404 all still come from the package. Templates are Nunjucks, and an override
|
|
4077
|
-
can extend a packaged one by name:
|
|
4078
|
-
|
|
4079
|
-
```njk
|
|
4080
|
-
{% extends "layouts/base.njk" %}
|
|
4081
|
-
|
|
4082
|
-
{% block content %}
|
|
4083
|
-
<h1>{{ title }}</h1>
|
|
4084
|
-
{{ content | safe }}
|
|
4085
|
-
{% endblock %}
|
|
4086
|
-
```
|
|
4087
|
-
|
|
4088
|
-
The admin is not themed. Its templates and its static files live in a tree of
|
|
4089
|
-
their own with a loader of their own, deliberately off this search path, so no
|
|
4090
|
-
theme can shadow the login form or the CSRF field inside it.
|
|
4091
|
-
|
|
4092
|
-
Analytics and other markup that goes on every page need no layout at all. The
|
|
4093
|
-
packaged `layouts/base.njk` includes `partials/head-end.njk` last in `<head>`
|
|
4094
|
-
and `partials/body-end.njk` last in `<body>`, both empty in the package, so a
|
|
4095
|
-
theme of `theme.json` and `partials/head-end.njk` holding the Umami or Google
|
|
4096
|
-
Analytics snippet adds it to every public HTML page and keeps every other
|
|
4097
|
-
packaged template. Neither is printed in a feed, a Markdown, text or JSON
|
|
4098
|
-
representation, mail, the admin or the editor's preview. The theme README's
|
|
4099
|
-
[Adding to every page](./themes/default/README.md#adding-to-every-page) has the
|
|
4100
|
-
Umami and GA4 example, the Docker volume, and what a Content-Security-Policy
|
|
4101
|
-
has to allow for them.
|
|
4102
|
-
|
|
4103
|
-
The packaged theme is the Paper design (doc-9) on the andrewshell.org shell
|
|
4104
|
-
(decision-16). Its stylesheet is written in Tailwind v4 in
|
|
4105
|
-
`themes/default/src/style.css` and compiled to `themes/default/static/style.css`
|
|
4106
|
-
when the package is built (decision-22), so what ships is one plain CSS file; a
|
|
4107
|
-
site theme's own `static/style.css` replaces it wholesale and needs no
|
|
4108
|
-
Tailwind. Every colour is a `--color-*` custom property a site can override.
|
|
4109
|
-
`base.njk` is its shell: a skip link, a `.global-wrapper` carrying `data-is-root-path="true"`
|
|
4110
|
-
at `/` only, a header that is the site title and tagline on the front page and a
|
|
4111
|
-
small link home on every other — with `menus.primary` inside it on every page
|
|
4112
|
-
— `<main id="main">`, and a footer with the copyright year, the site author's
|
|
4113
|
-
display name (the site title on a site with several authors), the colophon and
|
|
4114
|
-
`menus.footer`. The footer reads nothing off an account: it
|
|
4115
|
-
used to print an RSS link and one `rel="me"` link per link on the site author's
|
|
4116
|
-
profile, and a site that wants either types it into its footer menu. Nothing
|
|
4117
|
-
particular to one site is in it — webrings and badges belong in a site theme's
|
|
4118
|
-
`footer` block. Its head
|
|
4119
|
-
carries a description, Open Graph and Twitter card tags, icons derived from the
|
|
4120
|
-
site's avatar, and one JSON-LD `@graph` from `partials/jsonld.njk`: that partial
|
|
4121
|
-
is all the structured data the theme emits, there is no Microdata anywhere, and
|
|
4122
|
-
a site that wants a different graph replaces the one file. The stylesheet
|
|
4123
|
-
is a serif body and sans headings at an 18px root, one column at 42rem, warm
|
|
4124
|
-
paper with a rust primary and a blue secondary, and a second scheme under
|
|
4125
|
-
`prefers-color-scheme: dark` that redefines the same `--color-*` tokens on dark
|
|
4126
|
-
paper. Both meet WCAG 2.2 AA, checked by a test that reads the custom properties
|
|
4127
|
-
out of the stylesheet and computes the ratios.
|
|
4128
|
-
|
|
4129
|
-
The context mirrors what an Eleventy layout receives — `title`, `date`, `tags`,
|
|
4130
|
-
`content`, `page.url`, and every front matter key the file carried — plus
|
|
4131
|
-
`site`, which is `content/_data/site.json`. It is part of the semver contract.
|
|
4132
|
-
The one key that is not the front matter's own string is `author`: it is the
|
|
4133
|
-
person the file names, resolved against the site's users, with `author.name` to
|
|
4134
|
-
print and `author.url` — their archive at `/author/{username}/` — to link to
|
|
4135
|
-
when the name is one of them. `siteAuthor` is the same object answering a
|
|
4136
|
-
different question — who the page in front of the reader is by, which is the
|
|
4137
|
-
entry's author on a document, the archive's person on an author archive and the
|
|
4138
|
-
profile behind the site's `author` setting everywhere else — so the bio, the
|
|
4139
|
-
`rel="me"` links and any structured data a theme emits all read one profile.
|
|
4140
|
-
The full table of context keys, blocks and
|
|
4141
|
-
filters is in [`themes/default/README.md`](./themes/default/README.md).
|
|
4142
|
-
|
|
4143
|
-
Rendering is also callable without a request:
|
|
4144
|
-
|
|
4145
|
-
```ts
|
|
4146
|
-
import { createRenderer, resolveConfig } from '@geekity/cms';
|
|
4147
|
-
|
|
4148
|
-
const renderer = createRenderer({
|
|
4149
|
-
config: resolveConfig({ baseUrl: 'https://example.com' }),
|
|
4150
|
-
});
|
|
4151
|
-
renderer.renderDocument(document); // the HTML the site would serve
|
|
4152
|
-
renderer.site(); // content/_data/site.json, defaults filled in
|
|
4153
|
-
```
|
|
4154
|
-
|
|
4155
|
-
Handlers reach the same renderer as `c.var.renderer`.
|
|
4156
|
-
|
|
4157
|
-
### Moving a `theme/` directory
|
|
4158
|
-
|
|
4159
|
-
Before named themes a site had one `theme/` directory, always on the search
|
|
4160
|
-
path, pointed at by `themeDir` and `GEEKITY_THEME_DIR`. Both are gone and
|
|
4161
|
-
neither is aliased, so a site carrying one moves it:
|
|
4162
|
-
|
|
4163
|
-
```sh
|
|
4164
|
-
mkdir -p themes/mine
|
|
4165
|
-
git mv theme/* themes/mine/
|
|
4166
|
-
rmdir theme
|
|
4167
|
-
```
|
|
4168
|
-
|
|
4169
|
-
Then write `themes/mine/theme.json` with a `name` and `"kind": "site"`, choose
|
|
4170
|
-
it on **Appearance > Themes** (or put `"theme": "mine"` in
|
|
4171
|
-
`content/_data/site.json`), and drop `themeDir` from `geekity.config.ts` and
|
|
4172
|
-
`GEEKITY_THEME_DIR` from the environment — a config naming `themeDir` is now a
|
|
4173
|
-
type error and the variable is ignored. Nothing inside the theme changes: the
|
|
4174
|
-
layouts, partials, `mail/` and `static/` keep their names and their meaning.
|
|
4175
|
-
Until it is chosen the site serves the packaged theme, so the move and the
|
|
4176
|
-
choice belong in the same deploy.
|
|
4177
|
-
|
|
4178
|
-
## Building the same content with Eleventy
|
|
4179
|
-
|
|
4180
|
-
The content directory is an [Eleventy](https://www.11ty.dev) 3 input directory.
|
|
4181
|
-
The same files this CMS serves build into a static site at the same URLs, so
|
|
4182
|
-
leaving is a build step rather than a migration. That is a promise, and it is
|
|
4183
|
-
kept by a test rather than by good intentions.
|
|
4184
|
-
|
|
4185
|
-
Copy
|
|
4186
|
-
[`docs/eleventy.config.example.js`](https://github.com/geekitycom/cms/blob/main/packages/cms/docs/eleventy.config.example.js)
|
|
4187
|
-
into your site as `eleventy.config.js` and run Eleventy:
|
|
4188
|
-
|
|
4189
|
-
```sh
|
|
4190
|
-
npm install --save-dev @11ty/eleventy
|
|
4191
|
-
npx @11ty/eleventy
|
|
4192
|
-
```
|
|
4193
|
-
|
|
4194
|
-
It is a plain ESM config with no dependency on this package, and it writes out
|
|
4195
|
-
the rules the CMS follows that Eleventy does not know about on its own:
|
|
4196
|
-
|
|
4197
|
-
| Rule | How the config does it |
|
|
4198
|
-
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
4199
|
-
| `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
|
|
4200
|
-
| `visibility: unlisted` is listed nowhere | An `addPreprocessor` that sets `eleventyExcludeFromCollections` on the document, so a feed, sitemap or archive drawn from collections leaves it out, and sets `noindex` for the layout to print as `<meta name="robots" content="noindex">`. Any value other than `public` or `unlisted` returns `false`, as a draft does. |
|
|
4201
|
-
| 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. |
|
|
4202
|
-
| `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/`. |
|
|
4203
|
-
| `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. |
|
|
4204
|
-
| `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`. |
|
|
4205
|
-
| `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. |
|
|
4206
|
-
| 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. |
|
|
4207
|
-
| 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. |
|
|
4208
|
-
|
|
4209
|
-
It also builds two collections Eleventy has no notion of. `collections.categories`
|
|
4210
|
-
is the second taxonomy, one entry of `{ name, posts }` per category in use, for
|
|
4211
|
-
paginating into archives at `/{{ site.categoryBase }}/{name}/`.
|
|
4212
|
-
`collections.menus` is the site's named menus — the `menus` object of
|
|
4213
|
-
`site.json`, and nothing else — each an ordered list of `{ label, url }`
|
|
4214
|
-
entries, plus `rel` on a link that carries `rel` values; a layout
|
|
4215
|
-
marks the current one itself by comparing `item.url` with `page.url`, because a
|
|
4216
|
-
collection is built once for the whole site.
|
|
4217
|
-
|
|
4218
|
-
The `date` filter is the one that needs a word. The files hold UTC instants and
|
|
4219
|
-
the `timezone` setting decides how they read, so the config's filter takes the
|
|
4220
|
-
zone from `content/_data/site.json` — the file the settings screen writes —
|
|
4221
|
-
and renders with `Intl`, which keeps the file free of dependencies. If your site
|
|
4222
|
-
already uses [Luxon](https://moment.github.io/luxon/), which Eleventy ships
|
|
4223
|
-
anyway, the same filter is shorter:
|
|
4224
|
-
|
|
4225
|
-
```js
|
|
4226
|
-
import { DateTime } from 'luxon';
|
|
4227
|
-
|
|
4228
|
-
const zone =
|
|
4229
|
-
JSON.parse(readFileSync('content/_data/site.json', 'utf8')).timezone ?? 'utc';
|
|
4230
|
-
|
|
4231
|
-
eleventyConfig.addFilter('date', (value, format = 'readable') => {
|
|
4232
|
-
const at = DateTime.fromJSDate(
|
|
4233
|
-
value instanceof Date ? value : new Date(value),
|
|
4234
|
-
).setZone(zone);
|
|
4235
|
-
if (!at.isValid) return '';
|
|
4236
|
-
|
|
4237
|
-
// `iso` is the instant, never the zone: a <time datetime> and a feed want
|
|
4238
|
-
// UTC and must not move when a setting does.
|
|
4239
|
-
if (format === 'iso') return at.toUTC().toISO();
|
|
4240
|
-
if (format === 'html') return at.toFormat('yyyy-MM-dd');
|
|
4241
|
-
if (format === 'year') return at.toFormat('yyyy');
|
|
4242
|
-
return at.toFormat('d LLLL yyyy');
|
|
4243
|
-
});
|
|
4244
|
-
```
|
|
4245
|
-
|
|
4246
|
-
Either way it is the CMS's own filter, format for format, so a layout moved
|
|
4247
|
-
across prints the same dates.
|
|
4248
|
-
|
|
4249
|
-
It also turns the template engine off for Markdown
|
|
4250
|
-
(`markdownTemplateEngine: false`), because the CMS renders Markdown with
|
|
4251
|
-
markdown-it and nothing else. Leaving Liquid or Nunjucks on would make `{{ … }}`
|
|
4252
|
-
in a post mean one thing on the live site and another in the build.
|
|
4253
|
-
|
|
4254
|
-
Layouts are yours. The directory data files name them — `posts.json` says
|
|
4255
|
-
`"layout": "post"` and `pages.json` says `"layout": "page"` — so Eleventy wants
|
|
4256
|
-
`content/_includes/post.njk` and `content/_includes/page.njk`. The context an
|
|
4257
|
-
Eleventy layout receives is the one this package's theme mirrors, so a layout
|
|
4258
|
-
can often be moved across with only its `{% extends %}` removed.
|
|
4259
|
-
|
|
4260
|
-
The default theme's own layouts and partials build too, copied into
|
|
4261
|
-
`content/_includes/` as `layouts/` and `partials/`: the config adds the theme's
|
|
4262
|
-
other filters (`absoluteUrl`, `asset`, `host`, `fediverseHandle`) and the
|
|
4263
|
-
`newestPosts(count)` a front page override lists posts with, so a site theme's
|
|
4264
|
-
`front-page.njk` calling `{% set posts = newestPosts(5) %}` builds. They get
|
|
4265
|
-
only the context the config gives, though: `collections.menus` rather than
|
|
4266
|
-
`menus`, the `conversation` filter rather than the key, and no comment form,
|
|
4267
|
-
search or listing pagination.
|
|
4268
|
-
|
|
4269
|
-
Some things do not carry over. `/feed/`, `/feed/atom/` and `/feed/json/` are
|
|
4270
|
-
generated in code here, not by a template, so an Eleventy build needs its own;
|
|
4271
|
-
`/sitemap.xml` and `/robots.txt` are the same story, for the same reason. A
|
|
4272
|
-
comment's own page at `/comment/{key}/` is not written, since a build has no
|
|
4273
|
-
server to take a comment, so a comment links to its `#comment-{key}` anchor on
|
|
4274
|
-
the post. The site's own reply posts are not threaded under what they answer,
|
|
4275
|
-
and a document's backlinks are not listed: both read the CMS's index of every
|
|
4276
|
-
document, which the config's filters do not have. The config's header lists
|
|
4277
|
-
the same.
|
|
4278
|
-
|
|
4279
|
-
### The compatibility test
|
|
4280
|
-
|
|
4281
|
-
```sh
|
|
4282
|
-
pnpm --filter @geekity/cms test:11ty # or, from the repository root, pnpm test:11ty
|
|
4283
|
-
```
|
|
4284
|
-
|
|
4285
|
-
`test:11ty` builds `test/fixtures/` with Eleventy 3 and the example config, then
|
|
4286
|
-
checks every document against what the CMS computed for the same file: the
|
|
4287
|
-
output path equals the permalink plus `index.html`, drafts and the trash are
|
|
4288
|
-
absent, and uploads are copied through byte for byte. The fixtures deliberately
|
|
4289
|
-
cover a post whose permalink is not the default, a hand-authored post and page
|
|
4290
|
-
with no permalink at all, an accented title, a draft and a trashed file.
|
|
4291
|
-
|
|
4292
|
-
It is a separate script from `pnpm test` on purpose. The unit suite stays fast
|
|
4293
|
-
and free of a build tool; `test:11ty` pulls in Eleventy, writes a temporary
|
|
4294
|
-
directory and is the slow one. CI runs both. Neither `docs/` nor `test/` is in
|
|
4295
|
-
the published tarball — the example config lives in the repository, which is
|
|
4296
|
-
where you copy it from.
|
|
4297
|
-
|
|
4298
|
-
The repository's demo site makes the same comparison over content that reads
|
|
4299
|
-
like a real site rather than like fixtures, with the two layouts above in
|
|
4300
|
-
`apps/demo/content/_includes/`, so `pnpm test:11ty` from the root runs both.
|
|
4301
|
-
|
|
4302
|
-
## License
|
|
62
|
+
MIT
|