domma-cms 0.55.1 → 0.69.3
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/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample posts - a blog to look at before there is one.
|
|
3
|
+
*
|
|
4
|
+
* Every sample post and category carries `sample: true`, so "Remove sample
|
|
5
|
+
* posts" takes away exactly what "Add sample posts" put there and nothing an
|
|
6
|
+
* author wrote. A sample category an author has since filed a real post under
|
|
7
|
+
* is left alone.
|
|
8
|
+
*
|
|
9
|
+
* The posts are spread over the four shipped layouts and use the covers in
|
|
10
|
+
* public/samples/, so nothing is hotlinked from elsewhere.
|
|
11
|
+
*
|
|
12
|
+
* @module blog/lib/samples
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const IMG = (name) => `/plugins/blog/public/samples/${name}.svg`;
|
|
16
|
+
|
|
17
|
+
export const SAMPLE_CATEGORIES = [
|
|
18
|
+
{name: 'Guides', slug: 'guides', description: 'How to get things done, step by step.'},
|
|
19
|
+
{name: 'News', slug: 'news', description: 'What is new, and what changed.'},
|
|
20
|
+
{name: 'Design', slug: 'design', description: 'Notes on layout, type and colour.'},
|
|
21
|
+
{name: 'Engineering', slug: 'engineering', description: 'How it works under the bonnet.'}
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const DAY = 86_400_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} daysAgo
|
|
28
|
+
* @param {number} [hour]
|
|
29
|
+
*/
|
|
30
|
+
const ago = (daysAgo, hour = 9) => {
|
|
31
|
+
const d = new Date(Date.now() - daysAgo * DAY);
|
|
32
|
+
d.setUTCHours(hour, 0, 0, 0);
|
|
33
|
+
return d.toISOString();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Posts reference categories by slug here; the installer maps them to ids. */
|
|
37
|
+
export function samplePosts() {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
title: 'Welcome to the new blog',
|
|
41
|
+
slug: 'welcome-to-the-new-blog',
|
|
42
|
+
excerpt: 'Layouts you can choose per post, listings you can filter with a right-click, and every template editable as a block.',
|
|
43
|
+
categories: ['news'], tags: ['announcement', 'blog'],
|
|
44
|
+
featuredImage: IMG('aurora'), template: 'blog-post-feature', publishedAt: ago(1),
|
|
45
|
+
content: `The blog has had a proper overhaul. Every post can now choose how it looks, every listing can be searched and filtered without leaving the page, and all of it is built from blocks you can edit.
|
|
46
|
+
|
|
47
|
+
## Layouts, per post
|
|
48
|
+
|
|
49
|
+
Open any post in the editor and pick a layout from the **Layout** card: *Classic*, *Feature* (this one), *Minimal* or *Sidebar*. The site-wide default lives in the Blog settings, behind the cog.
|
|
50
|
+
|
|
51
|
+
## Listings that answer back
|
|
52
|
+
|
|
53
|
+
The blog index is no longer a static list. Search it, narrow it to a category with the pills, or **right-click any post** for "More in this category", "More tagged…" and "More by this author".
|
|
54
|
+
|
|
55
|
+
## Everything is a block
|
|
56
|
+
|
|
57
|
+
The layouts are ordinary blocks called \`blog-post-*\` and \`blog-card-*\`. Change one in the Blocks tool and every post using it changes with it. Copy one under a new name and it appears as a new choice.
|
|
58
|
+
|
|
59
|
+
> Start with the Blocks tool, not the code. That is the point.
|
|
60
|
+
|
|
61
|
+
Enjoy it - and delete these sample posts from the Posts menu whenever you are ready.`
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
title: 'Writing your first post, start to finish',
|
|
65
|
+
slug: 'writing-your-first-post',
|
|
66
|
+
excerpt: 'From a blank editor to a published, well-linked post in about ten minutes.',
|
|
67
|
+
categories: ['guides'], tags: ['writing', 'getting-started'],
|
|
68
|
+
featuredImage: IMG('meadow'), template: 'blog-post-sidebar', publishedAt: ago(4),
|
|
69
|
+
content: `This guide walks through a post from the first word to the Publish button. The contents list beside it follows you down the page.
|
|
70
|
+
|
|
71
|
+
## Start with the title
|
|
72
|
+
|
|
73
|
+
A good title says what the reader gets. "Ten minutes to your first post" beats "Some thoughts on writing". The slug fills itself in from the title while **Auto** is ticked.
|
|
74
|
+
|
|
75
|
+
## Write the excerpt
|
|
76
|
+
|
|
77
|
+
The excerpt is the sentence or two shown on listings and in search results. Write it last if that is easier - it is often clearer once the post exists.
|
|
78
|
+
|
|
79
|
+
## The body
|
|
80
|
+
|
|
81
|
+
Posts are Markdown. Headings (\`##\`) become the contents list in the Sidebar layout, so use them for the real sections of your argument.
|
|
82
|
+
|
|
83
|
+
### Links and images
|
|
84
|
+
|
|
85
|
+
Link generously to your own older posts. Pick a featured image from the Media library; it appears on listings and at the top of most layouts.
|
|
86
|
+
|
|
87
|
+
### Shortcodes
|
|
88
|
+
|
|
89
|
+
Every shortcode the site supports works in a post, including blocks.
|
|
90
|
+
|
|
91
|
+
## Categories and tags
|
|
92
|
+
|
|
93
|
+
Categories are the few big shelves your blog has. Tags are the many small labels. A reader can right-click any post on the listing to see more with the same tag.
|
|
94
|
+
|
|
95
|
+
## Publish, or schedule
|
|
96
|
+
|
|
97
|
+
**Publish now** puts it live. **Schedule** holds it until the date you choose - no need to be awake at 7am.`
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: 'Why whitespace is a feature',
|
|
101
|
+
slug: 'why-whitespace-is-a-feature',
|
|
102
|
+
excerpt: 'Room to breathe is not wasted space. It is how a reader knows what matters.',
|
|
103
|
+
categories: ['design'], tags: ['typography', 'layout'],
|
|
104
|
+
featuredImage: IMG('dusk'), template: 'blog-post-minimal', publishedAt: ago(9),
|
|
105
|
+
content: `Every page makes an argument about what matters, and whitespace is how it makes it.
|
|
106
|
+
|
|
107
|
+
Crowd two things together and the reader assumes they belong together. Put room between them and they read as separate thoughts. Layout is grammar.
|
|
108
|
+
|
|
109
|
+
## Measure
|
|
110
|
+
|
|
111
|
+
A line of text is most comfortable at somewhere between 55 and 75 characters. Wider, and the eye loses its place on the way back. This layout keeps to a narrow column on purpose.
|
|
112
|
+
|
|
113
|
+
## Rhythm
|
|
114
|
+
|
|
115
|
+
Consistent spacing - the same gap after every paragraph, the same before every heading - is what makes a page feel calm. Inconsistent spacing is noise the reader cannot name but can feel.
|
|
116
|
+
|
|
117
|
+
## Restraint
|
|
118
|
+
|
|
119
|
+
The hardest part of design is leaving things out. This layout has no featured image and no sidebar. It has the words, and room around them.`
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
title: 'How the listing filters work',
|
|
123
|
+
slug: 'how-the-listing-filters-work',
|
|
124
|
+
excerpt: 'The blog index filters in the browser with domma-reactive - here is what happens between the click and the cards.',
|
|
125
|
+
categories: ['engineering'], tags: ['domma-reactive', 'javascript', 'blog'],
|
|
126
|
+
featuredImage: IMG('harbour'), publishedAt: ago(15),
|
|
127
|
+
content: `The listing you came from is rendered on the server, so it works without JavaScript and search engines see every post. The script then takes it over.
|
|
128
|
+
|
|
129
|
+
## One model
|
|
130
|
+
|
|
131
|
+
Each card carries a small bundle of plain values - title, date, categories, tags, author. The script reads those into a list and keeps the filters in a handful of observables: the search text, the category, the tag, the author and the sort order.
|
|
132
|
+
|
|
133
|
+
## Derived, not managed
|
|
134
|
+
|
|
135
|
+
What is shown is a *computed* value: filter the list, sort it, take the first page. Nothing hides or shows a card by hand; an effect applies whatever the computed says. Change any filter and everything downstream follows.
|
|
136
|
+
|
|
137
|
+
## The right-click menu
|
|
138
|
+
|
|
139
|
+
Right-clicking a card opens a menu built from that card's own values. "More in Design" simply sets the category observable. The chips above the list are the same observables, drawn the other way round.
|
|
140
|
+
|
|
141
|
+
## The address bar
|
|
142
|
+
|
|
143
|
+
Filters are written back into the address, so a filtered view is a link you can share.`
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
title: 'Five small things that make a site feel fast',
|
|
147
|
+
slug: 'five-things-that-make-a-site-feel-fast',
|
|
148
|
+
excerpt: 'Speed is partly measured and partly perceived. These five help with both.',
|
|
149
|
+
categories: ['engineering', 'design'], tags: ['performance'],
|
|
150
|
+
featuredImage: IMG('ember'), publishedAt: ago(23),
|
|
151
|
+
content: `A site can be quick on paper and still feel slow. These are the small things that close the gap.
|
|
152
|
+
|
|
153
|
+
## 1. Reserve space for images
|
|
154
|
+
|
|
155
|
+
Give images their proportions up front so the page does not jump as they arrive. Cards here use a fixed aspect ratio for exactly that reason.
|
|
156
|
+
|
|
157
|
+
## 2. Lazy-load what is below the fold
|
|
158
|
+
|
|
159
|
+
The browser does this for you with \`loading="lazy"\`. Cards use it; the image at the top of a post does not, because it is the first thing you see.
|
|
160
|
+
|
|
161
|
+
## 3. Respond instantly, finish later
|
|
162
|
+
|
|
163
|
+
Filtering the blog listing happens in the browser, so the cards change the moment you type - there is no round trip to wait for.
|
|
164
|
+
|
|
165
|
+
## 4. Compress the right things
|
|
166
|
+
|
|
167
|
+
Text compresses beautifully. Already-compressed files - images, archives - should be sent as they are.
|
|
168
|
+
|
|
169
|
+
## 5. Do less
|
|
170
|
+
|
|
171
|
+
The fastest code is the code that never runs. Every feature should earn its bytes.`
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
title: 'Choosing categories that last',
|
|
175
|
+
slug: 'choosing-categories-that-last',
|
|
176
|
+
excerpt: 'A handful of broad, stable categories and a generous sprinkling of tags will outlive any clever taxonomy.',
|
|
177
|
+
categories: ['guides'], tags: ['writing', 'organisation'],
|
|
178
|
+
featuredImage: IMG('rosewood'), publishedAt: ago(31),
|
|
179
|
+
content: `Categories are the shelves; tags are the labels on the books. Get the shelves right and the rest looks after itself.
|
|
180
|
+
|
|
181
|
+
## Keep them few
|
|
182
|
+
|
|
183
|
+
Four to eight categories is plenty for most blogs. More than that and authors start guessing, and readers stop trusting the pills at the top of the listing.
|
|
184
|
+
|
|
185
|
+
## Make them stable
|
|
186
|
+
|
|
187
|
+
A category should still make sense in two years. "News" will. "Spring 2026 launch" will not - that is a tag.
|
|
188
|
+
|
|
189
|
+
## Let tags do the detail
|
|
190
|
+
|
|
191
|
+
Tags can be as specific as you like. Readers find them by right-clicking a post and choosing "More tagged…", so a precise tag is genuinely useful.`
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
title: 'Release notes: comments, moderation and more',
|
|
195
|
+
slug: 'release-notes-comments-and-moderation',
|
|
196
|
+
excerpt: 'Comments now respect their settings the moment you change them, and moderation gained a Rejected list.',
|
|
197
|
+
categories: ['news'], tags: ['release', 'comments'],
|
|
198
|
+
featuredImage: IMG('harbour'), publishedAt: ago(40),
|
|
199
|
+
content: `A round-up of the changes to comments.
|
|
200
|
+
|
|
201
|
+
## Settings take effect immediately
|
|
202
|
+
|
|
203
|
+
Switching comments off now closes them straight away - no restart. Holding comments for approval works the same way.
|
|
204
|
+
|
|
205
|
+
## A place for rejected comments
|
|
206
|
+
|
|
207
|
+
Rejected comments used to vanish from view. They now have their own list, so a mistaken rejection can be undone.
|
|
208
|
+
|
|
209
|
+
## Posting without leaving the page
|
|
210
|
+
|
|
211
|
+
The comment form under each post now sends in place and tells you whether your comment is live or waiting for approval.`
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
title: 'A colour palette in an afternoon',
|
|
215
|
+
slug: 'a-colour-palette-in-an-afternoon',
|
|
216
|
+
excerpt: 'Start from one colour you love, and let contrast rules do the rest.',
|
|
217
|
+
categories: ['design'], tags: ['colour', 'theming'],
|
|
218
|
+
featuredImage: IMG('meadow'), publishedAt: ago(52),
|
|
219
|
+
content: `You do not need a design degree to build a palette that works. You need one colour, and some discipline.
|
|
220
|
+
|
|
221
|
+
## Start with one
|
|
222
|
+
|
|
223
|
+
Pick a primary colour you would be happy to see every day. It will be your links, your buttons and the accent on every card.
|
|
224
|
+
|
|
225
|
+
## Derive the rest
|
|
226
|
+
|
|
227
|
+
A darker shade for hover, a pale tint for backgrounds, and a neutral grey family for text. The theme settings can generate these from the one colour.
|
|
228
|
+
|
|
229
|
+
## Check contrast
|
|
230
|
+
|
|
231
|
+
Body text needs a contrast ratio of at least 4.5:1 against its background. Test it rather than trusting your eyes on a bright screen.`
|
|
232
|
+
}
|
|
233
|
+
];
|
|
234
|
+
}
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import {fileURLToPath} from 'url';
|
|
1
2
|
import defaultConfig from './config.js';
|
|
2
3
|
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
|
|
3
|
-
import {
|
|
4
|
+
import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
|
|
5
|
+
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
6
|
+
import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
|
|
7
|
+
import {catalogue, seedBlocks} from './lib/render.js';
|
|
8
|
+
import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
|
|
9
|
+
import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
|
|
4
10
|
|
|
5
11
|
const POSTS_SLUG = 'blog-posts';
|
|
6
12
|
const CATEGORIES_SLUG = 'blog-categories';
|
|
@@ -16,6 +22,53 @@ function isEditorOrAdmin(user) {
|
|
|
16
22
|
return canPublish(user);
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
// Sidebar badge text helpers - kept here rather than imported from core, so the
|
|
26
|
+
// plugin still loads on an engine that predates server/services/sidebarBadges.js.
|
|
27
|
+
const plural = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
|
|
28
|
+
function clip(text, max) {
|
|
29
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
30
|
+
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
|
|
31
|
+
}
|
|
32
|
+
function ago(iso) {
|
|
33
|
+
const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
|
|
34
|
+
if (!Number.isFinite(secs)) return '';
|
|
35
|
+
if (secs < 60) return 'just now';
|
|
36
|
+
if (secs < 3600) return `${Math.round(secs / 60)} min ago`;
|
|
37
|
+
if (secs < 86400) return `${plural(Math.round(secs / 3600), 'hour')} ago`;
|
|
38
|
+
return `${plural(Math.round(secs / 86400), 'day')} ago`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** '' (the site default) or a post layout's block name. */
|
|
42
|
+
function cleanTemplate(value) {
|
|
43
|
+
const v = String(value ?? '').trim();
|
|
44
|
+
return /^blog-post-[a-z0-9-]+$/.test(v) ? v : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The fields a caller may set on a post. Status, publish date and authorship
|
|
49
|
+
* go through the publish / schedule routes, which check who is asking: taken
|
|
50
|
+
* straight from the body, a blog-author could publish their own post with
|
|
51
|
+
* `{"status": "published"}` and skip the editor entirely.
|
|
52
|
+
*/
|
|
53
|
+
export function postFields(body, user) {
|
|
54
|
+
const out = {};
|
|
55
|
+
if (typeof body.title === 'string') out.title = body.title.trim();
|
|
56
|
+
if (typeof body.slug === 'string') out.slug = body.slug.trim();
|
|
57
|
+
if (typeof body.content === 'string') out.content = body.content;
|
|
58
|
+
if (typeof body.excerpt === 'string') out.excerpt = body.excerpt.trim();
|
|
59
|
+
if (body.featuredImage !== undefined) out.featuredImage = body.featuredImage ? String(body.featuredImage) : null;
|
|
60
|
+
if (Array.isArray(body.categories)) out.categories = body.categories.map(String);
|
|
61
|
+
if (Array.isArray(body.tags)) out.tags = body.tags.map(t => String(t).trim()).filter(Boolean);
|
|
62
|
+
if (body.seo && typeof body.seo === 'object') {
|
|
63
|
+
out.seo = {title: String(body.seo.title ?? ''), description: String(body.seo.description ?? '')};
|
|
64
|
+
}
|
|
65
|
+
if (body.template !== undefined) out.template = cleanTemplate(body.template);
|
|
66
|
+
// Only a publisher may move a post out of draft here (Save Draft sends 'draft').
|
|
67
|
+
if (body.status === 'draft') out.status = 'draft';
|
|
68
|
+
else if (body.status !== undefined && canPublish(user) && ['scheduled', 'published'].includes(body.status)) out.status = body.status;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
19
72
|
/** Derive a URL slug from a title string. */
|
|
20
73
|
function slugify(str) {
|
|
21
74
|
return String(str)
|
|
@@ -188,6 +241,120 @@ export default async function blogPlugin(fastify, options) {
|
|
|
188
241
|
registerEmbedShortcodes(options.hooks.registerShortcode, basePath);
|
|
189
242
|
}
|
|
190
243
|
|
|
244
|
+
// The layouts are blocks; put the shipped ones where the Blocks tool can
|
|
245
|
+
// edit them. Once each, never over a site's own copy (lib/render.js).
|
|
246
|
+
try {
|
|
247
|
+
const copied = await seedBlocks();
|
|
248
|
+
if (copied.length) fastify.log.info(`[blog] added layout blocks: ${copied.join(', ')}`);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
fastify.log.warn(`[blog] could not add the layout blocks: ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -------------------------------------------------------------------------
|
|
254
|
+
// Layouts
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/** GET /api/plugins/blog/layouts - every post layout and listing display on offer */
|
|
258
|
+
fastify.get('/layouts', { preHandler: [authenticate] }, async () => {
|
|
259
|
+
const s = liveSettings(settings);
|
|
260
|
+
return { ...(await catalogue()), postLayout: s.postLayout, listDisplay: s.listDisplay };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* POST /api/plugins/blog/preview - a post as the site would show it, from
|
|
265
|
+
* the editor's UNSAVED fields and in any layout, for the preview panel.
|
|
266
|
+
* The whole page, chrome and all, so what the author sees is what readers get.
|
|
267
|
+
*/
|
|
268
|
+
fastify.post('/preview', { preHandler: [authenticate] }, async (request, reply) => {
|
|
269
|
+
const body = request.body ?? {};
|
|
270
|
+
const user = request.user;
|
|
271
|
+
let stored = null;
|
|
272
|
+
if (body.id) {
|
|
273
|
+
const entry = await getEntry(POSTS_SLUG, String(body.id));
|
|
274
|
+
if (entry) {
|
|
275
|
+
if (!isEditorOrAdmin(user) && entry.data.authorId !== (user?.id ?? user?.sub)) {
|
|
276
|
+
return reply.code(403).send({ error: 'Forbidden' });
|
|
277
|
+
}
|
|
278
|
+
stored = { id: entry.id, ...entry.data };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const post = {
|
|
282
|
+
id: stored?.id ?? '__preview__',
|
|
283
|
+
status: 'published',
|
|
284
|
+
publishedAt: stored?.publishedAt ?? new Date().toISOString(),
|
|
285
|
+
authorId: stored?.authorId ?? (user?.id ?? user?.sub ?? ''),
|
|
286
|
+
...(stored ?? {}),
|
|
287
|
+
...postFields(body, user)
|
|
288
|
+
};
|
|
289
|
+
post.status = 'published';
|
|
290
|
+
post.slug = post.slug || 'preview';
|
|
291
|
+
const allPosts = (await loadAll(POSTS_SLUG)).filter(p => p.id !== post.id);
|
|
292
|
+
const s = liveSettings(settings);
|
|
293
|
+
const page = await renderPostPage(post, [...allPosts, post], s, {
|
|
294
|
+
base: basePath, layout: cleanTemplate(body.layout) || null, preview: true
|
|
295
|
+
});
|
|
296
|
+
const html = await renderBlogPage(
|
|
297
|
+
fileURLToPath(new URL('./templates/post.html', import.meta.url)),
|
|
298
|
+
{ post: page.html, assetV: '1.4.4' },
|
|
299
|
+
{ title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
|
|
300
|
+
usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
|
|
301
|
+
);
|
|
302
|
+
return reply.send({ html, layout: page.layout });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
// Sample posts
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* POST /api/plugins/blog/samples - add the sample posts (and any sample
|
|
311
|
+
* category the site lacks). A sample whose slug is taken is skipped, so
|
|
312
|
+
* pressing it twice adds nothing the second time.
|
|
313
|
+
*/
|
|
314
|
+
fastify.post('/samples', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
315
|
+
const uid = request.user?.id ?? request.user?.sub ?? null;
|
|
316
|
+
const cats = await loadAll(CATEGORIES_SLUG);
|
|
317
|
+
const catIds = {};
|
|
318
|
+
for (const c of SAMPLE_CATEGORIES) {
|
|
319
|
+
const have = cats.find(x => x.slug === c.slug);
|
|
320
|
+
catIds[c.slug] = have ? have.id : (await createEntry(CATEGORIES_SLUG, { ...c, sample: true }, { createdBy: uid, source: 'admin' })).id;
|
|
321
|
+
}
|
|
322
|
+
const existing = new Set((await loadAll(POSTS_SLUG)).map(p => p.slug));
|
|
323
|
+
let added = 0;
|
|
324
|
+
for (const p of samplePosts()) {
|
|
325
|
+
if (existing.has(p.slug)) continue;
|
|
326
|
+
await createEntry(POSTS_SLUG, {
|
|
327
|
+
...p,
|
|
328
|
+
categories: p.categories.map(slug => catIds[slug]).filter(Boolean),
|
|
329
|
+
template: p.template ?? '',
|
|
330
|
+
status: 'published',
|
|
331
|
+
authorId: uid,
|
|
332
|
+
sample: true,
|
|
333
|
+
seo: { title: '', description: '' }
|
|
334
|
+
}, { createdBy: uid, source: 'admin' });
|
|
335
|
+
added++;
|
|
336
|
+
}
|
|
337
|
+
return { ok: true, added };
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* DELETE /api/plugins/blog/samples - remove exactly what "Add" put there:
|
|
342
|
+
* posts marked as samples, and sample categories no remaining post uses.
|
|
343
|
+
*/
|
|
344
|
+
fastify.delete('/samples', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
345
|
+
let posts = 0, categories = 0;
|
|
346
|
+
for (const p of (await loadAll(POSTS_SLUG)).filter(p => p.sample === true)) {
|
|
347
|
+
await deleteEntry(POSTS_SLUG, p.id);
|
|
348
|
+
posts++;
|
|
349
|
+
}
|
|
350
|
+
const inUse = new Set((await loadAll(POSTS_SLUG)).flatMap(p => p.categories ?? []));
|
|
351
|
+
for (const c of (await loadAll(CATEGORIES_SLUG)).filter(c => c.sample === true && !inUse.has(c.id))) {
|
|
352
|
+
await deleteEntry(CATEGORIES_SLUG, c.id);
|
|
353
|
+
categories++;
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, posts, categories };
|
|
356
|
+
});
|
|
357
|
+
|
|
191
358
|
// -------------------------------------------------------------------------
|
|
192
359
|
// Posts
|
|
193
360
|
// -------------------------------------------------------------------------
|
|
@@ -212,24 +379,31 @@ export default async function blogPlugin(fastify, options) {
|
|
|
212
379
|
const uid = user?.id ?? user?.sub ?? null;
|
|
213
380
|
const body = request.body ?? {};
|
|
214
381
|
|
|
215
|
-
const {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
382
|
+
const { title, slug } = body;
|
|
383
|
+
|
|
384
|
+
// The schema marks title, slug, authorId and status required, so an
|
|
385
|
+
// empty title reaches createEntry and throws - a 500 where the
|
|
386
|
+
// categories and comments routes both answer 400. Say so here instead.
|
|
387
|
+
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
388
|
+
return reply.code(400).send({ error: 'title is required' });
|
|
389
|
+
}
|
|
219
390
|
|
|
220
391
|
const derivedSlug = slug
|
|
221
392
|
? String(slug).trim()
|
|
222
|
-
: slugify(title
|
|
393
|
+
: slugify(title);
|
|
223
394
|
|
|
395
|
+
const fields = postFields(body, user);
|
|
224
396
|
const entry = await createEntry(POSTS_SLUG, {
|
|
225
|
-
|
|
397
|
+
content: '',
|
|
398
|
+
excerpt: '',
|
|
399
|
+
featuredImage: null,
|
|
400
|
+
categories: [],
|
|
401
|
+
tags: [],
|
|
402
|
+
template: '',
|
|
403
|
+
...fields,
|
|
404
|
+
title: title.trim(),
|
|
226
405
|
slug: derivedSlug,
|
|
227
|
-
|
|
228
|
-
excerpt: typeof excerpt === 'string' ? excerpt.trim() : '',
|
|
229
|
-
featuredImage: featuredImage ?? null,
|
|
230
|
-
categories: Array.isArray(categories) ? categories : [],
|
|
231
|
-
tags: Array.isArray(tags) ? tags : [],
|
|
232
|
-
status: status ?? 'draft',
|
|
406
|
+
status: fields.status ?? 'draft',
|
|
233
407
|
authorId: uid
|
|
234
408
|
}, { createdBy: uid, source: 'admin' });
|
|
235
409
|
|
|
@@ -256,8 +430,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
256
430
|
return reply.code(403).send({ error: 'Forbidden' });
|
|
257
431
|
}
|
|
258
432
|
|
|
259
|
-
const
|
|
260
|
-
const merged = { ...entry.data, ...updates };
|
|
433
|
+
const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
|
|
261
434
|
|
|
262
435
|
const updated = await updateEntry(POSTS_SLUG, id, merged);
|
|
263
436
|
return reply.send(toPost(updated));
|
|
@@ -401,10 +574,49 @@ export default async function blogPlugin(fastify, options) {
|
|
|
401
574
|
return reply.send(comments);
|
|
402
575
|
});
|
|
403
576
|
|
|
577
|
+
/** GET /api/plugins/blog/comments/pending-count - the sidebar badge on Blog: comments waiting for approval */
|
|
578
|
+
fastify.get('/comments/pending-count', { preHandler: [authenticate] }, async () => {
|
|
579
|
+
const [comments, posts] = await Promise.all([loadAll(COMMENTS_SLUG), loadAll(POSTS_SLUG)]);
|
|
580
|
+
const pending = comments.filter(c => c.status === 'pending')
|
|
581
|
+
.sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')));
|
|
582
|
+
const titleOf = new Map(posts.map(p => [p.id, p.title || 'Untitled']));
|
|
583
|
+
const count = (status) => posts.filter(p => p.status === status).length;
|
|
584
|
+
const next = posts.filter(p => p.status === 'scheduled' && p.publishedAt)
|
|
585
|
+
.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt)))[0];
|
|
586
|
+
const n = pending.length;
|
|
587
|
+
return {
|
|
588
|
+
count: n,
|
|
589
|
+
label: `${plural(n, 'comment')} waiting for approval`,
|
|
590
|
+
tone: 'attention',
|
|
591
|
+
details: {
|
|
592
|
+
title: 'Blog',
|
|
593
|
+
rows: [
|
|
594
|
+
{ label: 'Waiting for approval', value: String(n) },
|
|
595
|
+
{ label: 'Published', value: String(count('published')) },
|
|
596
|
+
{ label: 'Drafts', value: String(count('draft')) },
|
|
597
|
+
{ label: 'Scheduled', value: next ? `${count('scheduled')} · next ${String(next.publishedAt).slice(0, 10)}` : String(count('scheduled')) }
|
|
598
|
+
],
|
|
599
|
+
// Plain text: the popover sets it with textContent, so a comment is never markup.
|
|
600
|
+
items: pending.slice(0, 4).map(c => ({
|
|
601
|
+
text: `${clip(c.authorName, 24)}: "${clip(c.body, 50)}"`,
|
|
602
|
+
meta: `on ${clip(titleOf.get(c.postId) ?? 'a deleted post', 36)} · ${ago(c.createdAt)}`
|
|
603
|
+
})),
|
|
604
|
+
empty: 'No comments waiting.'
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
|
|
404
609
|
/** POST /api/plugins/blog/comments - public, no authenticate */
|
|
405
610
|
fastify.post('/comments', async (request, reply) => {
|
|
406
611
|
const body = request.body ?? {};
|
|
407
612
|
|
|
613
|
+
// Read the settings as they are NOW. `settings` above is captured when
|
|
614
|
+
// the plugin registers, so switching comments off - or moderation -
|
|
615
|
+
// did nothing until a restart, and a closed blog kept taking comments
|
|
616
|
+
// from anyone who posted straight to this route.
|
|
617
|
+
const live = { ...defaultConfig, ...(getPluginStates()?.blog?.settings ?? {}) };
|
|
618
|
+
if (!live.commentsEnabled) return reply.code(403).send({ error: 'Comments are closed' });
|
|
619
|
+
|
|
408
620
|
// Honeypot: non-empty `website` field = bot, silently discard
|
|
409
621
|
if (body.website) {
|
|
410
622
|
return reply.code(201).send({ ok: true });
|
|
@@ -417,7 +629,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
417
629
|
if (!authorEmail) return reply.code(400).send({ error: 'authorEmail is required' });
|
|
418
630
|
if (!commentBody) return reply.code(400).send({ error: 'body is required' });
|
|
419
631
|
|
|
420
|
-
const commentStatus =
|
|
632
|
+
const commentStatus = live.commentModeration !== false ? 'pending' : 'approved';
|
|
421
633
|
|
|
422
634
|
const entry = await createEntry(COMMENTS_SLUG, {
|
|
423
635
|
postId: String(postId),
|
|
@@ -465,21 +677,42 @@ export default async function blogPlugin(fastify, options) {
|
|
|
465
677
|
// Settings
|
|
466
678
|
// -------------------------------------------------------------------------
|
|
467
679
|
|
|
680
|
+
// Plugin settings live in config/plugins.json under blog.settings, and the
|
|
681
|
+
// way to reach them is getPluginStates() / savePluginState(). These used to
|
|
682
|
+
// call getConfig() and saveConfig() from server/config.js, which take a FILE
|
|
683
|
+
// NAME: getConfig() with no argument read config/undefined.json and threw,
|
|
684
|
+
// so this endpoint answered 500 every time, and saveConfig(cfg) passed the
|
|
685
|
+
// whole config object as the name.
|
|
686
|
+
|
|
468
687
|
/** GET /api/plugins/blog/settings */
|
|
469
688
|
fastify.get('/settings', { preHandler: [authenticate] }, async (request, reply) => {
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
return reply.send({ ...defaultConfig, ...overrides });
|
|
689
|
+
const stored = getPluginStates()?.blog?.settings ?? {};
|
|
690
|
+
return reply.send({ ...defaultConfig, ...DISPLAY_DEFAULTS, ...stored });
|
|
473
691
|
});
|
|
474
692
|
|
|
475
693
|
/** PUT /api/plugins/blog/settings */
|
|
476
|
-
|
|
694
|
+
// requireAdmin reads request.user, which only `authenticate` populates -
|
|
695
|
+
// on its own it answers 401 to everyone, admin included.
|
|
696
|
+
fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
477
697
|
const body = request.body ?? {};
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
698
|
+
|
|
699
|
+
// Whitelist rather than merge: every key here is read back by the
|
|
700
|
+
// public routes, and storing whatever was posted invites surprises.
|
|
701
|
+
// Over what is stored, not over the defaults: a PUT carrying one field
|
|
702
|
+
// used to reset every other setting to its default.
|
|
703
|
+
const stored = getPluginStates()?.blog?.settings ?? {};
|
|
704
|
+
const merged = {
|
|
705
|
+
...defaultConfig,
|
|
706
|
+
...stored,
|
|
707
|
+
...(body.basePath !== undefined && { basePath: String(body.basePath) }),
|
|
708
|
+
...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
|
|
709
|
+
...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
|
|
710
|
+
...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
|
|
711
|
+
...(body.feedLength !== undefined && { feedLength: Number(body.feedLength) || defaultConfig.feedLength }),
|
|
712
|
+
...pickDisplaySettings(body)
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
savePluginState('blog', { settings: merged });
|
|
716
|
+
return reply.send({ ok: true, settings: merged });
|
|
484
717
|
});
|
|
485
718
|
}
|