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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog layouts - the pure half of rendering posts through blocks.
|
|
3
|
+
*
|
|
4
|
+
* A post page and every listing card are BLOCKS (content/blocks/*.html), so a
|
|
5
|
+
* site restyles its blog in the Blocks tool rather than in this plugin:
|
|
6
|
+
*
|
|
7
|
+
* blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar…)
|
|
8
|
+
* blog-card-<name> one post in a listing (Cards, List, Compact…)
|
|
9
|
+
*
|
|
10
|
+
* Any block a site adds with one of those prefixes becomes a further choice -
|
|
11
|
+
* nothing here needs to know about it. Nothing in this file touches the disk,
|
|
12
|
+
* so all of it is testable on its own; lib/render.js does the I/O.
|
|
13
|
+
*
|
|
14
|
+
* @module blog/lib/layouts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The post layouts the plugin ships. Each is a block of the same name. */
|
|
18
|
+
export const POST_LAYOUTS = [
|
|
19
|
+
{id: 'blog-post-classic', label: 'Classic', description: 'Title, image, then the story in one centred column.'},
|
|
20
|
+
{id: 'blog-post-feature', label: 'Feature', description: 'A full-width hero image with the title over it - for the big pieces.'},
|
|
21
|
+
{id: 'blog-post-minimal', label: 'Minimal', description: 'Just the words: a narrow reading column, no image.'},
|
|
22
|
+
{id: 'blog-post-sidebar', label: 'Sidebar', description: 'The story beside a sticky contents list, details and share links.'}
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The listing displays the plugin ships. A display is a card block plus how the
|
|
27
|
+
* cards are arranged; Magazine reuses the Cards block and makes the first post
|
|
28
|
+
* the lead.
|
|
29
|
+
*/
|
|
30
|
+
export const LIST_DISPLAYS = [
|
|
31
|
+
{id: 'cards', block: 'blog-card', arrange: 'grid', label: 'Cards', description: 'A grid of picture cards.'},
|
|
32
|
+
{id: 'list', block: 'blog-card-row', arrange: 'stack', label: 'List', description: 'One post per row, picture beside the text.'},
|
|
33
|
+
{id: 'magazine', block: 'blog-card', arrange: 'grid', label: 'Magazine', description: 'The newest post large across the top, the rest as cards.', lead: true},
|
|
34
|
+
{id: 'compact', block: 'blog-card-compact', arrange: 'stack', label: 'Compact', description: 'Titles and dates only - an archive at a glance.'}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_POST_LAYOUT = 'blog-post-classic';
|
|
38
|
+
export const DEFAULT_DISPLAY = 'cards';
|
|
39
|
+
|
|
40
|
+
const BUILTIN_CARD_BLOCKS = new Set(LIST_DISPLAYS.map(d => d.block));
|
|
41
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
42
|
+
|
|
43
|
+
/** 'blog-post-long-read' -> 'Long read' */
|
|
44
|
+
function labelFrom(suffix) {
|
|
45
|
+
const s = suffix.replace(/-/g, ' ');
|
|
46
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every post layout on offer: the shipped ones, then any other `blog-post-*`
|
|
51
|
+
* block the site has. A shipped layout whose block was deleted still counts -
|
|
52
|
+
* the plugin carries its own copy.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} blockNames - names of the blocks on this site
|
|
55
|
+
* @returns {{id: string, label: string, description: string, custom: boolean}[]}
|
|
56
|
+
*/
|
|
57
|
+
export function postLayouts(blockNames = []) {
|
|
58
|
+
const shipped = new Set(POST_LAYOUTS.map(l => l.id));
|
|
59
|
+
const extra = blockNames
|
|
60
|
+
.filter(n => NAME_RE.test(n) && n.startsWith('blog-post-') && !shipped.has(n))
|
|
61
|
+
.sort()
|
|
62
|
+
.map(n => ({id: n, label: labelFrom(n.slice('blog-post-'.length)), description: 'Your own layout, from the Blocks tool.', custom: true}));
|
|
63
|
+
return [...POST_LAYOUTS.map(l => ({...l, custom: false})), ...extra];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every listing display on offer: the shipped four, then one per extra
|
|
68
|
+
* `blog-card-*` block, arranged as a grid.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} blockNames
|
|
71
|
+
* @returns {{id: string, block: string, arrange: string, label: string, description: string, lead?: boolean, custom: boolean}[]}
|
|
72
|
+
*/
|
|
73
|
+
export function listDisplays(blockNames = []) {
|
|
74
|
+
const extra = blockNames
|
|
75
|
+
.filter(n => NAME_RE.test(n) && n.startsWith('blog-card-') && !BUILTIN_CARD_BLOCKS.has(n))
|
|
76
|
+
.sort()
|
|
77
|
+
.map(n => ({id: n, block: n, arrange: 'grid', label: labelFrom(n.slice('blog-card-'.length)), description: 'Your own card, from the Blocks tool.', custom: true}));
|
|
78
|
+
return [...LIST_DISPLAYS.map(d => ({...d, custom: false})), ...extra];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The layout a post renders with: its own choice if still on offer, else the site default. */
|
|
82
|
+
export function pickPostLayout(post, settings, layouts) {
|
|
83
|
+
const ids = new Set(layouts.map(l => l.id));
|
|
84
|
+
if (post?.template && ids.has(post.template)) return post.template;
|
|
85
|
+
if (settings?.postLayout && ids.has(settings.postLayout)) return settings.postLayout;
|
|
86
|
+
return DEFAULT_POST_LAYOUT;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The display a listing renders with: a valid request wins, then the setting, then Cards. */
|
|
90
|
+
export function pickDisplay(requested, settings, displays) {
|
|
91
|
+
const byId = new Map(displays.map(d => [d.id, d]));
|
|
92
|
+
return byId.get(requested) ?? byId.get(settings?.listDisplay) ?? byId.get(DEFAULT_DISPLAY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Settings
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** The display settings and their defaults (the rest live in config.js). */
|
|
100
|
+
export const DISPLAY_DEFAULTS = {
|
|
101
|
+
postLayout: DEFAULT_POST_LAYOUT,
|
|
102
|
+
listDisplay: DEFAULT_DISPLAY,
|
|
103
|
+
displaySwitcher: true,
|
|
104
|
+
interactive: true,
|
|
105
|
+
relatedCount: 3
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whitelist the display settings from a request body. Only keys present are
|
|
110
|
+
* returned, so a PUT carrying one of them leaves the others as stored.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} body
|
|
113
|
+
* @returns {object}
|
|
114
|
+
*/
|
|
115
|
+
export function pickDisplaySettings(body = {}) {
|
|
116
|
+
const out = {};
|
|
117
|
+
if (typeof body.postLayout === 'string' && /^blog-post-[a-z0-9-]+$/.test(body.postLayout)) out.postLayout = body.postLayout;
|
|
118
|
+
if (typeof body.listDisplay === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(body.listDisplay)) out.listDisplay = body.listDisplay;
|
|
119
|
+
if (body.displaySwitcher !== undefined) out.displaySwitcher = Boolean(body.displaySwitcher);
|
|
120
|
+
if (body.interactive !== undefined) out.interactive = Boolean(body.interactive);
|
|
121
|
+
if (body.relatedCount !== undefined) {
|
|
122
|
+
const n = Number(body.relatedCount);
|
|
123
|
+
out.relatedCount = Number.isFinite(n) ? Math.max(0, Math.min(6, Math.round(n))) : DISPLAY_DEFAULTS.relatedCount;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Interpolation
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export function escapeHtml(str) {
|
|
133
|
+
return String(str ?? '')
|
|
134
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
139
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Moment-style date formatting, the same tokens block templates use elsewhere
|
|
143
|
+
* (`{{publishedAt:D MMM YYYY}}`). UTC, so a page renders the same wherever the
|
|
144
|
+
* server is.
|
|
145
|
+
*
|
|
146
|
+
* @returns {string|null} null when the value is not a date
|
|
147
|
+
*/
|
|
148
|
+
export function formatDate(value, fmt) {
|
|
149
|
+
if (!value) return null;
|
|
150
|
+
const d = new Date(value);
|
|
151
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
152
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
153
|
+
const t = {
|
|
154
|
+
YYYY: () => d.getUTCFullYear(), YY: () => pad(d.getUTCFullYear() % 100),
|
|
155
|
+
MMMM: () => MONTHS[d.getUTCMonth()], MMM: () => MONTHS[d.getUTCMonth()].slice(0, 3),
|
|
156
|
+
MM: () => pad(d.getUTCMonth() + 1), M: () => d.getUTCMonth() + 1,
|
|
157
|
+
DD: () => pad(d.getUTCDate()), D: () => d.getUTCDate(),
|
|
158
|
+
dddd: () => DAYS[d.getUTCDay()], ddd: () => DAYS[d.getUTCDay()].slice(0, 3),
|
|
159
|
+
HH: () => pad(d.getUTCHours()), mm: () => pad(d.getUTCMinutes())
|
|
160
|
+
};
|
|
161
|
+
return fmt.replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g, k => String(t[k]()));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fill a block template. `{{{key}}}` is raw (for the fragments this plugin
|
|
166
|
+
* builds and escapes itself), `{{key}}` is escaped, `{{key:D MMM YYYY}}`
|
|
167
|
+
* formats a date. HTML comments go - a block's header documents its
|
|
168
|
+
* placeholders, and a listing of fifty cards need not repeat that fifty times.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} tpl
|
|
171
|
+
* @param {object} vars
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function interpolate(tpl, vars) {
|
|
175
|
+
return String(tpl ?? '')
|
|
176
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
177
|
+
.replace(/\{\{\{(\w+)\}\}\}/g, (_, k) => String(vars[k] ?? ''))
|
|
178
|
+
.replace(/\{\{(\w+)(?::([^{}]+))?\}\}/g, (_, k, fmt) => {
|
|
179
|
+
const v = vars[k] ?? '';
|
|
180
|
+
if (fmt) {
|
|
181
|
+
const f = formatDate(v, fmt);
|
|
182
|
+
if (f !== null) return escapeHtml(f);
|
|
183
|
+
}
|
|
184
|
+
return escapeHtml(v);
|
|
185
|
+
})
|
|
186
|
+
.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// What a post page and a card are made of
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** An image address safe inside `url('…')` and `src="…"`, or ''. */
|
|
194
|
+
export function safeImageUrl(url) {
|
|
195
|
+
const s = String(url ?? '').trim();
|
|
196
|
+
return /^(\/|https?:\/\/)[^\s'"()\\<>]+$/.test(s) ? s : '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** "4 min read", from the words in the post (markup stripped). */
|
|
200
|
+
export function readingTime(text) {
|
|
201
|
+
const words = String(text ?? '').replace(/<[^>]*>/g, ' ').split(/\s+/).filter(Boolean).length;
|
|
202
|
+
return `${Math.max(1, Math.round(words / 200))} min read`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function slugify(s) {
|
|
206
|
+
return String(s).toLowerCase().replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/g, '')
|
|
207
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Give the body's h2/h3 headings ids and build a contents list from them.
|
|
212
|
+
* Fewer than two headings is not worth a list, and gets none.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} html - the rendered post body
|
|
215
|
+
* @returns {{html: string, toc: string, count: number}}
|
|
216
|
+
*/
|
|
217
|
+
export function buildToc(html) {
|
|
218
|
+
const items = [];
|
|
219
|
+
const used = new Set();
|
|
220
|
+
const out = String(html ?? '').replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/g, (m, level, attrs, inner) => {
|
|
221
|
+
let id = /\sid="([^"]+)"/.exec(attrs)?.[1];
|
|
222
|
+
if (!id) {
|
|
223
|
+
const base = slugify(inner);
|
|
224
|
+
id = base;
|
|
225
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
226
|
+
attrs = `${attrs} id="${id}"`;
|
|
227
|
+
}
|
|
228
|
+
used.add(id);
|
|
229
|
+
items.push({level: Number(level), id, text: inner.replace(/<[^>]*>/g, '').trim()});
|
|
230
|
+
return `<h${level}${attrs}>${inner}</h${level}>`;
|
|
231
|
+
});
|
|
232
|
+
if (items.length < 2) return {html: out, toc: '', count: items.length};
|
|
233
|
+
const lis = items.map(i => `<li class="blog-toc-l${i.level}"><a href="#${escapeHtml(i.id)}">${i.text}</a></li>`).join('');
|
|
234
|
+
return {html: out, toc: `<nav class="blog-toc" aria-label="On this page" data-blog-toc><p class="blog-toc-title">On this page</p><ol>${lis}</ol></nav>`, count: items.length};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Posts related to this one: shared categories count double, shared tags once;
|
|
239
|
+
* ties go to the newer post.
|
|
240
|
+
*/
|
|
241
|
+
export function relatedPosts(post, all, count = 3) {
|
|
242
|
+
if (!count) return [];
|
|
243
|
+
const cats = new Set(post.categories ?? []);
|
|
244
|
+
const tags = new Set(post.tags ?? []);
|
|
245
|
+
return all
|
|
246
|
+
.filter(p => p.id !== post.id)
|
|
247
|
+
.map(p => ({p, score: (p.categories ?? []).filter(c => cats.has(c)).length * 2 + (p.tags ?? []).filter(t => tags.has(t)).length}))
|
|
248
|
+
.filter(x => x.score > 0)
|
|
249
|
+
.sort((a, b) => b.score - a.score || new Date(b.p.publishedAt || 0) - new Date(a.p.publishedAt || 0))
|
|
250
|
+
.slice(0, count)
|
|
251
|
+
.map(x => x.p);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The placeholders a card block can use, for one post. Raw (`{{{…}}}`) ones are
|
|
256
|
+
* built here from escaped parts; everything else is plain text.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} post
|
|
259
|
+
* @param {object} ctx - {base, catMap: id -> {name, slug}, authorName}
|
|
260
|
+
*/
|
|
261
|
+
export function cardVars(post, {base, catMap = {}, authorName = ''}) {
|
|
262
|
+
const url = `${base}/${encodeURIComponent(post.slug ?? '')}`;
|
|
263
|
+
const imageUrl = safeImageUrl(post.featuredImage);
|
|
264
|
+
const cats = (post.categories ?? []).map(id => catMap[id]).filter(Boolean);
|
|
265
|
+
return {
|
|
266
|
+
id: post.id,
|
|
267
|
+
title: post.title ?? '',
|
|
268
|
+
url,
|
|
269
|
+
excerpt: post.excerpt ?? '',
|
|
270
|
+
publishedAt: post.publishedAt ?? '',
|
|
271
|
+
date: formatDate(post.publishedAt, 'D MMM YYYY') ?? '',
|
|
272
|
+
authorName,
|
|
273
|
+
authorUrl: post.authorId ? `${base}/author/${encodeURIComponent(post.authorId)}` : base,
|
|
274
|
+
readingTime: readingTime(post.content),
|
|
275
|
+
imageUrl,
|
|
276
|
+
imageClass: imageUrl ? 'has-image' : 'no-image',
|
|
277
|
+
image: imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" loading="lazy">` : '',
|
|
278
|
+
category: cats[0]?.name ?? '',
|
|
279
|
+
categories: cats.map(c => `<a class="blog-chip" href="${base}/category/${encodeURIComponent(c.slug)}" data-blog-cat="${escapeHtml(c.slug)}">${escapeHtml(c.name)}</a>`).join(''),
|
|
280
|
+
tags: (post.tags ?? []).map(t => `<a class="blog-tag" href="${base}/tag/${encodeURIComponent(t)}" data-blog-tag="${escapeHtml(t)}">#${escapeHtml(t)}</a>`).join('')
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* What the browser needs to filter, sort and right-click a card: plain values,
|
|
286
|
+
* carried in a data attribute - never markup.
|
|
287
|
+
*/
|
|
288
|
+
export function cardMeta(post, {catMap = {}, authorName = ''}) {
|
|
289
|
+
return {
|
|
290
|
+
id: post.id,
|
|
291
|
+
title: post.title ?? '',
|
|
292
|
+
excerpt: post.excerpt ?? '',
|
|
293
|
+
date: post.publishedAt ?? '',
|
|
294
|
+
cats: (post.categories ?? []).map(id => catMap[id]).filter(Boolean).map(c => ({slug: c.slug, name: c.name})),
|
|
295
|
+
tags: post.tags ?? [],
|
|
296
|
+
author: {id: post.authorId ?? '', name: authorName},
|
|
297
|
+
minutes: Number.parseInt(readingTime(post.content), 10)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Newest first, by publishedAt; undated posts last. */
|
|
302
|
+
export function byNewest(a, b) {
|
|
303
|
+
return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
|
|
304
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A post page, rendered through its layout block - shared by the public route
|
|
3
|
+
* (plugin.public.js) and the admin preview (plugin.js), which passes `layout`
|
|
4
|
+
* to try one on before saving.
|
|
5
|
+
*
|
|
6
|
+
* @module blog/lib/page
|
|
7
|
+
*/
|
|
8
|
+
import {listEntries} from '../../../server/services/collections.js';
|
|
9
|
+
import {parseMarkdown} from '../../../server/services/markdown.js';
|
|
10
|
+
import {getUserById} from '../../../server/services/users.js';
|
|
11
|
+
import {getPluginStates} from '../../../server/services/plugins.js';
|
|
12
|
+
import defaultConfig from '../config.js';
|
|
13
|
+
import {buildToc, byNewest, cardVars, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
|
|
14
|
+
import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
|
|
15
|
+
|
|
16
|
+
export function isVisible(post) {
|
|
17
|
+
return post.status === 'published' ||
|
|
18
|
+
(post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Settings as they are NOW - display choices apply without a restart. */
|
|
22
|
+
export function liveSettings(registered) {
|
|
23
|
+
return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Always returns the actual array of entries, regardless of adapter shape. */
|
|
27
|
+
export async function loadEntries(slug) {
|
|
28
|
+
const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
|
|
29
|
+
const arr = Array.isArray(result) ? result : (Array.isArray(result?.entries) ? result.entries : []);
|
|
30
|
+
return arr.map((entry) => ({id: entry.id, ...(entry.data ?? entry)}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render a post body through the full Markdown + shortcode pipeline. */
|
|
34
|
+
export async function renderPostBody(content) {
|
|
35
|
+
if (!content) return {html: '', usedComponents: []};
|
|
36
|
+
try {
|
|
37
|
+
// Anonymous: the [menu] shortcode treats the visitor as signed out.
|
|
38
|
+
const {html, usedComponents} = await parseMarkdown('---\n---\n' + content, {});
|
|
39
|
+
return {html, usedComponents: usedComponents || []};
|
|
40
|
+
} catch {
|
|
41
|
+
return {html: escapeHtml(content), usedComponents: []};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resolveAuthorName(authorId) {
|
|
46
|
+
if (!authorId) return 'Unknown';
|
|
47
|
+
try {
|
|
48
|
+
const user = await getUserById(authorId);
|
|
49
|
+
return user?.name || user?.email || 'Unknown';
|
|
50
|
+
} catch {
|
|
51
|
+
return 'Unknown';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One lookup per author, however many posts they wrote. */
|
|
56
|
+
export async function authorNamesFor(posts) {
|
|
57
|
+
const ids = [...new Set(posts.map(p => p.authorId).filter(Boolean))];
|
|
58
|
+
const names = await Promise.all(ids.map(resolveAuthorName));
|
|
59
|
+
return new Map(ids.map((id, i) => [id, names[i]]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A post, rendered through its layout block.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} post
|
|
66
|
+
* @param {object[]} allPosts - every post, for older/newer and related
|
|
67
|
+
* @param {object} s - liveSettings()
|
|
68
|
+
* @param {object} opts - {base, siteUrl, layout (preview only), preview}
|
|
69
|
+
* @returns {Promise<{html: string, usedComponents: string[], layout: string}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function renderPostPage(post, allPosts, s, {base, siteUrl = '', layout = null, preview = false}) {
|
|
72
|
+
const {postLayouts, listDisplays} = await catalogue();
|
|
73
|
+
const chosen = layout && postLayouts.some(l => l.id === layout) ? layout : pickPostLayout(post, s, postLayouts);
|
|
74
|
+
const block = await loadBlock(chosen) ?? await loadBlock(DEFAULT_POST_LAYOUT);
|
|
75
|
+
|
|
76
|
+
const categories = await loadEntries('blog-categories');
|
|
77
|
+
const catMap = Object.fromEntries(categories.map(c => [c.id, {name: c.name, slug: c.slug}]));
|
|
78
|
+
const authorName = await resolveAuthorName(post.authorId);
|
|
79
|
+
const vars = cardVars(post, {base, catMap, authorName});
|
|
80
|
+
|
|
81
|
+
const {html: bodyHtml, usedComponents} = await renderPostBody(post.content);
|
|
82
|
+
const {html: content, toc} = buildToc(bodyHtml);
|
|
83
|
+
|
|
84
|
+
// Older and newer, among what a reader can actually see.
|
|
85
|
+
const visible = allPosts.filter(isVisible).sort(byNewest);
|
|
86
|
+
const at = visible.findIndex(p => p.id === post.id);
|
|
87
|
+
const newer = at > 0 ? visible[at - 1] : null;
|
|
88
|
+
const older = at >= 0 && at < visible.length - 1 ? visible[at + 1] : null;
|
|
89
|
+
const pn = (p, cls, label) => p
|
|
90
|
+
? `<a class="${cls}" href="${base}/${encodeURIComponent(p.slug)}"><span>${label}</span><strong>${escapeHtml(p.title)}</strong></a>`
|
|
91
|
+
: '<span></span>';
|
|
92
|
+
const prevNext = newer || older
|
|
93
|
+
? `<nav class="blog-prevnext" aria-label="More posts">${pn(older, 'is-older', '← Older')}${pn(newer, 'is-newer', 'Newer →')}</nav>`
|
|
94
|
+
: '';
|
|
95
|
+
|
|
96
|
+
let related = '';
|
|
97
|
+
let relatedStyle = '';
|
|
98
|
+
const rel = relatedPosts(post, visible, Number(s.relatedCount ?? 3));
|
|
99
|
+
if (rel.length) {
|
|
100
|
+
const cards = listDisplays.find(d => d.id === 'cards');
|
|
101
|
+
const {items, style} = await renderCards(rel, cards, {base, catMap, authorNames: await authorNamesFor(rel)});
|
|
102
|
+
relatedStyle = style;
|
|
103
|
+
related = `<section class="blog-related"><h2 class="blog-section-title">Keep reading</h2><div class="blog-items blog-items--grid">${items}</div></section>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const url = `${siteUrl}${base}/${encodeURIComponent(post.slug ?? '')}`;
|
|
107
|
+
const share = `<div class="blog-share" data-blog-share>
|
|
108
|
+
<a class="blog-share-btn" href="mailto:?subject=${encodeURIComponent(post.title ?? '')}&body=${encodeURIComponent(url)}">Email</a>
|
|
109
|
+
<button type="button" class="blog-share-btn" data-blog-copy hidden>Copy link</button>
|
|
110
|
+
<button type="button" class="blog-share-btn" data-blog-native hidden>Share…</button>
|
|
111
|
+
</div>`;
|
|
112
|
+
|
|
113
|
+
const comments = s.commentsEnabled ? await commentsHtml(post) : '';
|
|
114
|
+
|
|
115
|
+
const imageUrl = safeImageUrl(post.featuredImage);
|
|
116
|
+
const html = `${styleTag(chosen, block?.css)}${relatedStyle}
|
|
117
|
+
<div class="blog-post-page" data-block="${escapeHtml(chosen)}" data-blog-post${preview ? ' data-preview' : ''}>
|
|
118
|
+
${interpolate(block?.content ?? '', {
|
|
119
|
+
...vars,
|
|
120
|
+
image: imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="">` : '',
|
|
121
|
+
content, toc, share, prevNext, related, comments
|
|
122
|
+
})}
|
|
123
|
+
</div>`;
|
|
124
|
+
return {html, usedComponents, layout: chosen};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function commentsHtml(post) {
|
|
128
|
+
let approved = [];
|
|
129
|
+
try {
|
|
130
|
+
approved = (await loadEntries('blog-comments'))
|
|
131
|
+
.filter((c) => c.postId === post.id && c.status === 'approved')
|
|
132
|
+
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
|
|
133
|
+
} catch { /* non-fatal */ }
|
|
134
|
+
|
|
135
|
+
const list = approved.map((c) => `<li class="blog-comment">
|
|
136
|
+
<p class="blog-comment-head"><strong>${escapeHtml(c.authorName)}</strong> <time datetime="${escapeHtml(c.createdAt)}">${escapeHtml(new Date(c.createdAt).toLocaleDateString('en-GB', {day: 'numeric', month: 'long', year: 'numeric'}))}</time></p>
|
|
137
|
+
<p class="blog-comment-body">${escapeHtml(c.body)}</p>
|
|
138
|
+
</li>`).join('\n');
|
|
139
|
+
|
|
140
|
+
return `<section class="blog-comments" id="comments">
|
|
141
|
+
<h2 class="blog-section-title">Comments <span class="blog-count" data-blog-comment-count>${approved.length}</span></h2>
|
|
142
|
+
<ol class="blog-comment-list" data-blog-comment-list>${list}</ol>
|
|
143
|
+
<form class="blog-comment-form" method="POST" action="/api/plugins/blog/comments" data-blog-comment-form>
|
|
144
|
+
<h3>Leave a comment</h3>
|
|
145
|
+
<input type="hidden" name="postId" value="${escapeHtml(post.id)}">
|
|
146
|
+
<div class="blog-comment-row">
|
|
147
|
+
<label>Name<input type="text" name="authorName" required maxlength="80" class="form-input" autocomplete="name"></label>
|
|
148
|
+
<label>Email <small>(never shown)</small><input type="email" name="authorEmail" required maxlength="160" class="form-input" autocomplete="email"></label>
|
|
149
|
+
</div>
|
|
150
|
+
<label class="blog-hp" aria-hidden="true">Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
|
151
|
+
<label>Comment<textarea name="body" required rows="4" maxlength="5000" class="form-input"></textarea></label>
|
|
152
|
+
<div class="blog-comment-actions">
|
|
153
|
+
<button type="submit" class="btn btn-primary">Post comment</button>
|
|
154
|
+
<span class="blog-comment-status" role="status" data-blog-comment-status></span>
|
|
155
|
+
</div>
|
|
156
|
+
</form>
|
|
157
|
+
</section>`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog rendering through blocks - the half of layouts.js that touches the disk.
|
|
3
|
+
*
|
|
4
|
+
* A layout's block is read from the site's Blocks (content/blocks/), where it
|
|
5
|
+
* can be edited, and from this plugin's own blocks/ folder when the site has
|
|
6
|
+
* no block of that name - so deleting a shipped block in the Blocks tool puts
|
|
7
|
+
* the original back rather than breaking the blog.
|
|
8
|
+
*
|
|
9
|
+
* @module blog/lib/render
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import {fileURLToPath} from 'url';
|
|
14
|
+
import {getBlock, listBlocks, saveBlock} from '../../../server/services/blocks.js';
|
|
15
|
+
import {getPluginStates, savePluginState} from '../../../server/services/plugins.js';
|
|
16
|
+
import {cardMeta, cardVars, escapeHtml, interpolate, LIST_DISPLAYS, listDisplays, POST_LAYOUTS, postLayouts} from './layouts.js';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SHIPPED_DIR = path.join(__dirname, '..', 'blocks');
|
|
20
|
+
const SHIPPED = [...new Set([...POST_LAYOUTS.map(l => l.id), ...LIST_DISPLAYS.map(d => d.block)])];
|
|
21
|
+
|
|
22
|
+
async function readShipped(name) {
|
|
23
|
+
if (!SHIPPED.includes(name)) return null;
|
|
24
|
+
try {
|
|
25
|
+
const content = await fs.readFile(path.join(SHIPPED_DIR, `${name}.html`), 'utf8');
|
|
26
|
+
const css = await fs.readFile(path.join(SHIPPED_DIR, `${name}.css`), 'utf8').catch(() => '');
|
|
27
|
+
return {content, css};
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Copy the shipped blocks into the site's Blocks, once each. Remembered in
|
|
35
|
+
* config/plugins.json, so a block the site deletes on purpose stays deleted
|
|
36
|
+
* (and the blog quietly falls back to the plugin's copy); one that is already
|
|
37
|
+
* there is never overwritten.
|
|
38
|
+
*
|
|
39
|
+
* @returns {Promise<string[]>} the names copied this time
|
|
40
|
+
*/
|
|
41
|
+
export async function seedBlocks() {
|
|
42
|
+
const done = new Set(getPluginStates()?.blog?.seededBlocks ?? []);
|
|
43
|
+
const copied = [];
|
|
44
|
+
for (const name of SHIPPED.filter(n => !done.has(n))) {
|
|
45
|
+
const shipped = await readShipped(name);
|
|
46
|
+
if (!shipped) continue;
|
|
47
|
+
let exists = true;
|
|
48
|
+
try { await getBlock(name); } catch { exists = false; }
|
|
49
|
+
if (!exists) {
|
|
50
|
+
await saveBlock(name, shipped.content, {css: shipped.css, meta: {plugin: 'blog'}});
|
|
51
|
+
copied.push(name);
|
|
52
|
+
}
|
|
53
|
+
done.add(name);
|
|
54
|
+
}
|
|
55
|
+
if (copied.length || done.size !== (getPluginStates()?.blog?.seededBlocks ?? []).length) {
|
|
56
|
+
savePluginState('blog', {seededBlocks: [...done]});
|
|
57
|
+
}
|
|
58
|
+
return copied;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A block's template and CSS - the site's copy, else the shipped one.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<{content: string, css: string}|null>}
|
|
65
|
+
*/
|
|
66
|
+
export async function loadBlock(name) {
|
|
67
|
+
try {
|
|
68
|
+
const b = await getBlock(name);
|
|
69
|
+
return {content: b.content, css: b.css ?? ''};
|
|
70
|
+
} catch {
|
|
71
|
+
return readShipped(name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The names of every block on the site, plus the shipped ones. */
|
|
76
|
+
async function allBlockNames() {
|
|
77
|
+
const names = new Set(SHIPPED);
|
|
78
|
+
try { for (const b of await listBlocks()) names.add(b.name); } catch { /* no blocks dir yet */ }
|
|
79
|
+
return [...names];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every post layout and listing display on offer on this site. */
|
|
83
|
+
export async function catalogue() {
|
|
84
|
+
const names = await allBlockNames();
|
|
85
|
+
return {postLayouts: postLayouts(names), listDisplays: listDisplays(names)};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A block's CSS, scoped to elements carrying its `data-block` - the same
|
|
90
|
+
* convention every other block on the site uses, so CSS written in the Blocks
|
|
91
|
+
* tool behaves identically here.
|
|
92
|
+
*/
|
|
93
|
+
export function styleTag(name, css) {
|
|
94
|
+
if (!css || !css.trim()) return '';
|
|
95
|
+
const body = String(css).replace(/<\/style/gi, '<\\/style').replace(/\n\s*\n+/g, '\n').trim();
|
|
96
|
+
return `<style>[data-block="${escapeHtml(name)}"] {\n${body}\n}</style>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render posts as a listing in one display. Each card is wrapped in the
|
|
101
|
+
* element the browser script filters and right-clicks - `data-entry-id` and a
|
|
102
|
+
* `data-post` of plain values, never markup.
|
|
103
|
+
*
|
|
104
|
+
* @param {object[]} posts
|
|
105
|
+
* @param {object} display - one of catalogue().listDisplays
|
|
106
|
+
* @param {object} ctx - {base, catMap, authorNames: Map}
|
|
107
|
+
* @returns {Promise<{items: string, style: string}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function renderCards(posts, display, ctx) {
|
|
110
|
+
const block = await loadBlock(display.block) ?? await loadBlock('blog-card');
|
|
111
|
+
const items = posts.map((post, i) => {
|
|
112
|
+
const authorName = ctx.authorNames.get(post.authorId) ?? '';
|
|
113
|
+
const vars = cardVars(post, {base: ctx.base, catMap: ctx.catMap, authorName});
|
|
114
|
+
const meta = Buffer.from(JSON.stringify(cardMeta(post, {catMap: ctx.catMap, authorName})), 'utf8').toString('base64');
|
|
115
|
+
const lead = display.lead && i === 0 ? ' is-lead' : '';
|
|
116
|
+
return `<article class="blog-item${lead}" data-block="${escapeHtml(display.block)}" data-entry-id="${escapeHtml(post.id)}" data-post="${meta}">${interpolate(block?.content ?? '', vars)}</article>`;
|
|
117
|
+
}).join('\n');
|
|
118
|
+
return {items, style: styleTag(display.block, block?.css)};
|
|
119
|
+
}
|