domma-cms 0.66.0 → 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 +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- 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/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- 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 +3 -3
- 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/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -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/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 +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- 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 +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- 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 +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- 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/posts/schema.json +3 -1
- 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 +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- 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/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -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 +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- 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 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- 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/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- 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/plugin.json +0 -33
- 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/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- 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/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|