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.
Files changed (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /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 ?? '')}&amp;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
+ }