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
@@ -1,100 +1,53 @@
1
1
  /**
2
2
  * Blog Plugin - Public Routes
3
3
  * SSR public-facing routes for the blog. Mounted at the Fastify root.
4
+ *
5
+ * Posts and listing cards render through BLOCKS (see lib/layouts.js), and the
6
+ * page works fully without JavaScript. public/blog.js then takes the listing
7
+ * over - search, category pills, sort, display switching and a right-click
8
+ * menu - and adds reading progress, contents tracking, sharing and in-page
9
+ * comments to a post.
4
10
  */
5
11
  import path from 'path';
6
12
  import {fileURLToPath} from 'url';
7
13
  import {renderBlogPage} from '../../server/services/renderer.js';
8
- import {listEntries} from '../../server/services/collections.js';
9
- import {parseMarkdown} from '../../server/services/markdown.js';
10
- import {getUserById} from '../../server/services/users.js';
14
+ import {byNewest, escapeHtml, pickDisplay, safeImageUrl} from './lib/layouts.js';
15
+ import {catalogue, renderCards} from './lib/render.js';
16
+ import {authorNamesFor, isVisible, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
11
17
 
12
18
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
13
19
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
14
20
 
21
+ /** Bumped with the plugin, so a release is never served a stale script. */
22
+ const ASSET_V = '1.4.4';
23
+
24
+ /**
25
+ * Above this many posts a listing pages on the server instead: the browser
26
+ * filters what it was sent, and sending thousands of cards is not a feature.
27
+ */
28
+ const INTERACTIVE_CAP = 300;
29
+
15
30
  // ---------------------------------------------------------------------------
16
31
  // Helpers
17
32
  // ---------------------------------------------------------------------------
18
33
 
19
- function escapeHtml(str) {
20
- return String(str ?? '')
21
- .replace(/&/g, '&')
22
- .replace(/</g, '&lt;')
23
- .replace(/>/g, '&gt;')
24
- .replace(/"/g, '&quot;')
25
- .replace(/'/g, '&#39;');
26
- }
27
-
28
- function formatDate(iso) {
29
- if (!iso) return '';
30
- return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
31
- }
32
-
33
- function isVisible(post) {
34
- return post.status === 'published' ||
35
- (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
36
- }
37
-
38
- /** Always returns the actual array of entries, regardless of adapter shape. */
39
- async function loadEntries(slug) {
40
- const result = await listEntries(slug, { limit: 100000, sort: 'createdAt', order: 'desc' });
41
- if (Array.isArray(result)) return result.map((entry) => ({ id: entry.id, ...(entry.data ?? entry) }));
42
- if (Array.isArray(result?.entries)) {
43
- return result.entries.map((entry) => ({ id: entry.id, ...(entry.data ?? entry) }));
44
- }
45
- return [];
46
- }
47
-
48
- /** Render a post body through the full Markdown + shortcode pipeline. */
49
- async function renderPostBody(content) {
50
- if (!content) return { html: '', usedComponents: [] };
51
- try {
52
- // Blog public renderer is anonymous - pass empty opts so the [menu]
53
- // shortcode treats the visitor as unauthenticated. The blog plugin
54
- // does not track per-request user state for its public posts.
55
- const { html, usedComponents } = await parseMarkdown('---\n---\n' + content, {});
56
- return { html, usedComponents: usedComponents || [] };
57
- } catch {
58
- return { html: escapeHtml(content), usedComponents: [] };
59
- }
60
- }
61
-
62
- function buildPostCard(post, authorName, base) {
63
- const image = post.featuredImage
64
- ? `<a href="${base}/${escapeHtml(post.slug)}"><img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-card-image"></a>`
65
- : '';
66
- return `<article class="card mb-4 blog-card">
67
- ${image}
68
- <div class="card-body">
69
- <h2><a href="${base}/${escapeHtml(post.slug)}">${escapeHtml(post.title)}</a></h2>
70
- <p class="text-muted small">by ${escapeHtml(authorName)} &middot; ${formatDate(post.publishedAt)}</p>
71
- <p>${escapeHtml(post.excerpt || '')}</p>
72
- <a href="${base}/${escapeHtml(post.slug)}" class="btn btn-sm btn-primary">Read more</a>
73
- </div>
74
- </article>`;
75
- }
76
-
77
- function buildPagination(page, totalPages, basePath) {
34
+ function buildPagination(page, totalPages, basePath, query = '') {
78
35
  if (totalPages <= 1) return '';
79
36
  const parts = [];
80
37
  if (page > 1) {
81
38
  const prevHref = page === 2 ? basePath : `${basePath}/page/${page - 1}`;
82
- parts.push(`<a href="${prevHref}" class="btn btn-sm btn-secondary mr-2">&laquo; Previous</a>`);
39
+ parts.push(`<a href="${prevHref}${query}" class="btn btn-sm btn-secondary">&laquo; Newer</a>`);
83
40
  }
41
+ parts.push(`<span class="blog-page-of">Page ${page} of ${totalPages}</span>`);
84
42
  if (page < totalPages) {
85
- parts.push(`<a href="${basePath}/page/${page + 1}" class="btn btn-sm btn-secondary">Next &raquo;</a>`);
43
+ parts.push(`<a href="${basePath}/page/${page + 1}${query}" class="btn btn-sm btn-secondary">Older &raquo;</a>`);
86
44
  }
87
- return `<nav class="blog-pagination">${parts.join('')}</nav>`;
45
+ return `<nav class="blog-pagination" aria-label="Pages">${parts.join('')}</nav>`;
88
46
  }
89
47
 
90
- async function resolveAuthorName(authorId) {
91
- if (!authorId) return 'Unknown';
92
- try {
93
- const user = await getUserById(authorId);
94
- return user?.name || user?.email || 'Unknown';
95
- } catch {
96
- return 'Unknown';
97
- }
48
+ /** JSON inside a <script> element, safe against `</script>` in any value. */
49
+ function scriptJson(value) {
50
+ return JSON.stringify(value).replace(/</g, '\\u003c');
98
51
  }
99
52
 
100
53
  // ---------------------------------------------------------------------------
@@ -102,43 +55,79 @@ async function resolveAuthorName(authorId) {
102
55
  // ---------------------------------------------------------------------------
103
56
 
104
57
  export default async function blogPublicPlugin(fastify, options) {
105
- const { settings, config } = options;
58
+ const {settings, config} = options;
106
59
  const base = (settings?.basePath ?? '/blog').replace(/\/$/, '');
107
- const postsPerPage = settings?.postsPerPage ?? 10;
108
60
 
109
- async function renderArchive(reply, posts, archiveTitle, page) {
110
- const visible = posts.filter(isVisible).sort((a, b) => {
111
- return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
112
- });
113
-
114
- const totalPages = Math.max(1, Math.ceil(visible.length / postsPerPage));
61
+ /**
62
+ * A listing: the index, a page of it, or a category / tag / author archive.
63
+ *
64
+ * @param {object} request
65
+ * @param {object} reply
66
+ * @param {object[]} posts - candidates; drafts are dropped here
67
+ * @param {object} opts - {title, intro, page, pageBase, filter}
68
+ */
69
+ async function renderArchive(request, reply, posts, {title, intro = '', page = 1, pageBase = base, filter = null}) {
70
+ const s = liveSettings(settings);
71
+ const perPage = Math.max(1, Number(s.postsPerPage) || 10);
72
+ const {listDisplays} = await catalogue();
73
+ const display = pickDisplay(String(request.query?.display ?? ''), s, listDisplays);
74
+
75
+ const visible = posts.filter(isVisible).sort(byNewest);
76
+ const interactive = Boolean(s.interactive) && visible.length <= INTERACTIVE_CAP;
77
+ const totalPages = Math.max(1, Math.ceil(visible.length / perPage));
115
78
  const safePage = Math.min(Math.max(1, page), totalPages);
116
- const slice = visible.slice((safePage - 1) * postsPerPage, safePage * postsPerPage);
79
+ // Interactive listings send every post and page in the browser; the
80
+ // script shows the first page at once, so nothing jumps.
81
+ const shown = interactive ? visible : visible.slice((safePage - 1) * perPage, safePage * perPage);
82
+
83
+ const categories = await loadEntries('blog-categories');
84
+ const catMap = Object.fromEntries(categories.map(c => [c.id, {name: c.name, slug: c.slug}]));
85
+ const authorNames = await authorNamesFor(shown);
86
+ const {items, style} = await renderCards(shown, display, {base, catMap, authorNames});
87
+
88
+ const requested = request.query?.display ? `?display=${encodeURIComponent(display.id)}` : '';
89
+ const switcher = s.displaySwitcher && listDisplays.length > 1
90
+ ? `<div class="blog-displays" role="group" aria-label="Display">${listDisplays.map(d =>
91
+ `<a href="?display=${encodeURIComponent(d.id)}" data-blog-display="${escapeHtml(d.id)}" title="${escapeHtml(d.description)}"${d.id === display.id ? ' aria-current="true"' : ''}>${escapeHtml(d.label)}</a>`
92
+ ).join('')}</div>`
93
+ : '';
117
94
 
118
- const cards = await Promise.all(slice.map(async (post) => {
119
- const authorName = await resolveAuthorName(post.authorId);
120
- return buildPostCard(post, authorName, base);
121
- }));
95
+ const cfg = {
96
+ base, perPage, interactive, filter,
97
+ display: display.id,
98
+ displays: listDisplays.map(d => ({id: d.id, arrange: d.arrange, lead: Boolean(d.lead)})),
99
+ categories: categories.map(c => ({slug: c.slug, name: c.name}))
100
+ };
122
101
 
123
- const cardsHtml = cards.join('\n') || '<p>No posts found.</p>';
124
- const paginationHtml = buildPagination(safePage, totalPages, base);
102
+ const archive = `${style}
103
+ <section class="blog-archive" data-blog-archive>
104
+ <script type="application/json" data-blog-config>${scriptJson(cfg)}</script>
105
+ <div class="blog-archive-bar">${switcher}</div>
106
+ <div class="blog-items blog-items--${escapeHtml(display.arrange)}${display.lead ? ' blog-items--lead' : ''}" data-display="${escapeHtml(display.id)}">
107
+ ${items || ''}
108
+ </div>
109
+ <p class="blog-empty"${shown.length ? ' hidden' : ''}>No posts here yet.</p>
110
+ </section>`;
125
111
 
126
112
  const html = await renderBlogPage(
127
113
  path.join(TEMPLATES_DIR, 'index.html'),
128
- { archiveTitle, posts: cardsHtml, pagination: paginationHtml },
129
- {}
114
+ {
115
+ archiveTitle: escapeHtml(title),
116
+ archiveIntro: intro ? `<p class="blog-archive-intro">${escapeHtml(intro)}</p>` : '',
117
+ archive,
118
+ pagination: interactive ? '' : buildPagination(safePage, totalPages, pageBase, requested),
119
+ assetV: ASSET_V
120
+ },
121
+ {title, urlPath: request.url.split('?')[0]}
130
122
  );
131
- reply.type('text/html').send(html);
123
+ return reply.type('text/html').send(html);
132
124
  }
133
125
 
134
126
  // 1. RSS feed
135
127
  fastify.get(`${base}/feed.xml`, async (request, reply) => {
128
+ const s = liveSettings(settings);
136
129
  const allPosts = await loadEntries('blog-posts');
137
- const feedLength = settings?.feedLength ?? 20;
138
- const posts = allPosts
139
- .filter(isVisible)
140
- .sort((a, b) => new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0))
141
- .slice(0, feedLength);
130
+ const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
142
131
 
143
132
  const siteTitle = config?.site?.title ?? 'Blog';
144
133
  const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
@@ -151,7 +140,7 @@ export default async function blogPublicPlugin(fastify, options) {
151
140
  <guid>${siteUrl}${base}/${escapeHtml(post.slug)}</guid>
152
141
  </item>`).join('\n');
153
142
 
154
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
143
+ return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
155
144
  <rss version="2.0">
156
145
  <channel>
157
146
  <title>${escapeHtml(siteTitle)}</title>
@@ -159,154 +148,70 @@ export default async function blogPublicPlugin(fastify, options) {
159
148
  <description>Blog feed</description>
160
149
  ${items}
161
150
  </channel>
162
- </rss>`;
163
-
164
- reply.type('application/xml').send(xml);
151
+ </rss>`);
165
152
  });
166
153
 
167
154
  // 2. Paginated index
168
155
  fastify.get(`${base}/page/:n`, async (request, reply) => {
169
156
  const page = parseInt(request.params.n, 10) || 1;
170
- const allPosts = await loadEntries('blog-posts');
171
- await renderArchive(reply, allPosts, 'Blog', page);
157
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog', page});
172
158
  });
173
159
 
174
160
  // 3. Category archive
175
161
  fastify.get(`${base}/category/:slug`, async (request, reply) => {
176
- const { slug } = request.params;
177
- const [allPosts, allCategories] = await Promise.all([
178
- loadEntries('blog-posts'),
179
- loadEntries('blog-categories')
180
- ]);
181
-
162
+ const {slug} = request.params;
163
+ const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
182
164
  const category = allCategories.find((c) => c.slug === slug);
183
- if (!category) {
184
- return reply.code(404).send('Category not found');
185
- }
186
-
165
+ if (!category) return reply.code(404).send('Category not found');
187
166
  const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
188
- await renderArchive(reply, posts, `Category: ${escapeHtml(category.name)}`, 1);
167
+ return renderArchive(request, reply, posts, {
168
+ title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
169
+ });
189
170
  });
190
171
 
191
172
  // 4. Tag archive
192
173
  fastify.get(`${base}/tag/:tag`, async (request, reply) => {
193
- const { tag } = request.params;
194
- const allPosts = await loadEntries('blog-posts');
195
- const posts = allPosts.filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
196
- await renderArchive(reply, posts, `Tag: ${escapeHtml(tag)}`, 1);
174
+ const {tag} = request.params;
175
+ const posts = (await loadEntries('blog-posts')).filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
176
+ return renderArchive(request, reply, posts, {title: `#${tag}`, filter: {type: 'tag', value: tag}});
197
177
  });
198
178
 
199
179
  // 5. Author archive
200
180
  fastify.get(`${base}/author/:id`, async (request, reply) => {
201
- const { id } = request.params;
202
- const allPosts = await loadEntries('blog-posts');
203
- const posts = allPosts.filter((p) => p.authorId === id);
181
+ const {id} = request.params;
182
+ const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
204
183
  const authorName = await resolveAuthorName(id);
205
- await renderArchive(reply, posts, `Author: ${escapeHtml(authorName)}`, 1);
184
+ return renderArchive(request, reply, posts, {title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
206
185
  });
207
186
 
208
187
  // 6. Blog index
209
188
  fastify.get(base, async (request, reply) => {
210
- const allPosts = await loadEntries('blog-posts');
211
- await renderArchive(reply, allPosts, 'Blog', 1);
189
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog'});
212
190
  });
213
191
 
214
192
  // 7. Individual post - registered LAST so the slug match doesn't shadow archives
215
193
  fastify.get(`${base}/:slug`, async (request, reply) => {
216
- const { slug } = request.params;
194
+ const {slug} = request.params;
217
195
  const allPosts = await loadEntries('blog-posts');
218
196
  const post = allPosts.find((p) => p.slug === slug);
197
+ if (!post || !isVisible(post)) return reply.code(404).send('Not found');
219
198
 
220
- if (!post || !isVisible(post)) {
221
- return reply.code(404).send('Not found');
222
- }
223
-
224
- const authorName = await resolveAuthorName(post.authorId);
225
-
226
- let categoriesHtml = '';
227
- if (Array.isArray(post.categories) && post.categories.length) {
228
- try {
229
- const allCategories = await loadEntries('blog-categories');
230
- const catMap = Object.fromEntries(allCategories.map((c) => [c.id, { name: c.name, slug: c.slug }]));
231
- categoriesHtml = post.categories
232
- .map((id) => catMap[id]
233
- ? `<a href="${base}/category/${escapeHtml(catMap[id].slug)}" class="badge badge-secondary mr-1">${escapeHtml(catMap[id].name)}</a>`
234
- : '')
235
- .join('');
236
- } catch {
237
- // non-fatal
238
- }
239
- }
240
-
241
- let tagsHtml = '';
242
- if (Array.isArray(post.tags) && post.tags.length) {
243
- tagsHtml = post.tags
244
- .map((tag) => `<a href="${base}/tag/${escapeHtml(tag)}" class="badge badge-secondary mr-1">${escapeHtml(tag)}</a>`)
245
- .join('');
246
- }
247
-
248
- const featuredImageHtml = post.featuredImage
249
- ? `<img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-post-image mb-4">`
250
- : '';
251
-
252
- const { html: bodyHtml, usedComponents } = await renderPostBody(post.content);
253
-
254
- let commentsHtml = '';
255
- let commentFormHtml = '';
256
- if (settings?.commentsEnabled) {
257
- try {
258
- const allComments = await loadEntries('blog-comments');
259
- const approved = allComments.filter((c) => c.postId === post.id && c.status === 'approved');
260
- if (approved.length) {
261
- const items = approved.map((c) => `<li class="mb-3">
262
- <strong>${escapeHtml(c.authorName)}</strong> <span class="text-muted small">${formatDate(c.createdAt)}</span>
263
- <p class="mt-1">${escapeHtml(c.body)}</p>
264
- </li>`).join('\n');
265
- commentsHtml = `<h4 class="mb-3">Comments</h4><ul class="list-unstyled">${items}</ul>`;
266
- }
267
- } catch {
268
- // non-fatal
269
- }
270
-
271
- commentFormHtml = `<div class="card mt-4">
272
- <div class="card-header"><h4>Leave a comment</h4></div>
273
- <div class="card-body">
274
- <form method="POST" action="/api/plugins/blog/comments">
275
- <input type="hidden" name="postId" value="${escapeHtml(post.id)}">
276
- <div class="mb-3"><label>Name *<br><input type="text" name="authorName" required class="form-control"></label></div>
277
- <div class="mb-3"><label>Email *<br><input type="email" name="authorEmail" required class="form-control"></label></div>
278
- <div class="mb-3" style="display:none"><label>Website<br><input type="text" name="website"></label></div>
279
- <div class="mb-3"><label>Comment *<br><textarea name="body" required class="form-control" rows="4"></textarea></label></div>
280
- <button type="submit" class="btn btn-primary">Submit</button>
281
- </form>
282
- </div>
283
- </div>`;
284
- }
285
-
286
- const seoMeta = {
287
- title: post.seo?.title || post.title,
288
- description: post.seo?.description || post.excerpt || '',
289
- ogImage: post.featuredImage || '',
290
- ogType: 'article',
291
- usedComponents
292
- };
199
+ const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
200
+ const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
293
201
 
294
202
  const html = await renderBlogPage(
295
203
  path.join(TEMPLATES_DIR, 'post.html'),
204
+ {post: page.html, assetV: ASSET_V},
296
205
  {
297
- title: escapeHtml(post.title),
298
- authorName: escapeHtml(authorName),
299
- publishedAt: formatDate(post.publishedAt),
300
- categories: categoriesHtml,
301
- tags: tagsHtml,
302
- featuredImage: featuredImageHtml,
303
- content: bodyHtml,
304
- comments: commentsHtml,
305
- commentForm: commentFormHtml
306
- },
307
- seoMeta
206
+ title: post.seo?.title || post.title,
207
+ description: post.seo?.description || post.excerpt || '',
208
+ ogImage: safeImageUrl(post.featuredImage),
209
+ ogType: 'article',
210
+ usedComponents: page.usedComponents,
211
+ urlPath: `${base}/${slug}`
212
+ }
308
213
  );
309
-
310
- reply.type('text/html').send(html);
214
+ return reply.type('text/html').send(html);
311
215
  });
216
+
312
217
  }
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Blog - what every layout shares: the listing's frame and toolbar, chips,
3
+ * post typography, contents list, sharing, comments and the right-click menu.
4
+ * Each layout's own look lives in its block (content/blocks/blog-*.css), so a
5
+ * site restyles a layout in the Blocks tool without touching this file.
6
+ */
7
+
8
+ .blog-page { padding-top: 2rem; padding-bottom: 3rem; }
9
+
10
+ /*
11
+ * The site theme colours and underlines every link in .page-body, with
12
+ * !important and a selector as specific as (0,8,4) (public/css/site.css). A
13
+ * blog's links are its interface - titles, chips, the display switcher - so they
14
+ * opt out here, by id to outrank it. Links written in a post keep the theme's
15
+ * look. A block's CSS sets link colour on the link's parent (it inherits).
16
+ * The reset sits in :where() so it weighs only its id (1,0,0) - still above the
17
+ * theme, and below every exception here. Without it, `:not(.blog-content a)`
18
+ * lifted the reset over the exceptions and the active display button went
19
+ * dark-on-dark.
20
+ */
21
+ #dm-blog :where(a[href]:not(.blog-content a)) { color: inherit !important; text-decoration: none !important; }
22
+ #dm-blog :where(a[href]:not(.blog-content a)):hover { color: var(--dm-primary, #3b82f6) !important; }
23
+ #dm-blog a.blog-chip { color: var(--dm-primary, #3b82f6) !important; }
24
+ #dm-blog .blog-displays a[aria-current="true"] { color: #fff !important; }
25
+ #dm-blog a.blog-tag, #dm-blog .blog-toc a { color: var(--dm-text-muted) !important; }
26
+ #dm-blog a.blog-tag:hover, #dm-blog .blog-toc a:hover { color: var(--dm-text) !important; }
27
+ #dm-blog .blog-toc a.is-active { color: var(--dm-primary, #3b82f6) !important; }
28
+
29
+ /* ── Listing ──────────────────────────────────────────────────────────── */
30
+ .blog-archive-head { margin-bottom: 1.25rem; }
31
+ .blog-archive-head h1 { margin: 0 0 .35rem; font-size: clamp(2rem, 4vw, 2.6rem); letter-spacing: -.015em; }
32
+ .blog-archive-intro { margin: 0; color: var(--dm-text-muted); font-size: 1.1rem; max-width: 42rem; }
33
+
34
+ .blog-archive-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1rem; }
35
+ .blog-archive-bar:empty { display: none; }
36
+
37
+ .blog-search { position: relative; flex: 1 1 16rem; max-width: 24rem; }
38
+ .blog-search .blog-search-input { width: 100%; padding: .5rem .75rem .5rem 2.1rem; border-radius: 999px;
39
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: var(--dm-surface, transparent); color: inherit; font: inherit; }
40
+ .blog-search .blog-search-input:focus { outline: 2px solid color-mix(in srgb, var(--dm-primary, #3b82f6) 45%, transparent); outline-offset: 1px; }
41
+ .blog-search svg { position: absolute; left: .75rem; top: 50%; transform: translateY(-50%); opacity: .55; pointer-events: none; }
42
+
43
+ .blog-sort { padding: .45rem .6rem; border-radius: 999px; border: 1px solid var(--dm-border, rgba(127,127,127,.3));
44
+ background: var(--dm-surface, transparent); color: inherit; font: inherit; font-size: .9rem; }
45
+
46
+ .blog-displays { display: inline-flex; margin-left: auto; padding: 3px; border-radius: 999px;
47
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); }
48
+ .blog-displays a { padding: .3rem .8rem; border-radius: 999px; font-size: .85rem; text-decoration: none; color: var(--dm-text-muted); }
49
+ .blog-displays a:hover { color: var(--dm-text); }
50
+ .blog-displays a[aria-current="true"] { background: var(--dm-primary, #3b82f6); color: #fff; }
51
+
52
+ .blog-pills { display: flex; gap: .4rem; flex-wrap: wrap; margin: 0 0 1rem; }
53
+ .blog-pill { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .8rem; border-radius: 999px; cursor: pointer;
54
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: none; color: inherit; font: inherit; font-size: .88rem; }
55
+ .blog-pill:hover { border-color: var(--dm-primary, #3b82f6); }
56
+ .blog-pill.is-active { background: var(--dm-primary, #3b82f6); border-color: var(--dm-primary, #3b82f6); color: #fff; }
57
+ .blog-pill .n { font-size: .75rem; opacity: .7; font-variant-numeric: tabular-nums; }
58
+ .blog-pill[disabled] { opacity: .4; cursor: default; }
59
+
60
+ .blog-active { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: -.25rem 0 1rem; font-size: .88rem; color: var(--dm-text-muted); }
61
+ .blog-active:empty { display: none; }
62
+ .blog-active .blog-filter-chip { display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .35rem .2rem .7rem; border-radius: 999px;
63
+ background: color-mix(in srgb, var(--dm-primary, #3b82f6) 14%, transparent); color: var(--dm-text); }
64
+ .blog-active .blog-filter-chip button { border: 0; background: none; color: inherit; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 .25rem; border-radius: 50%; }
65
+ .blog-active .blog-filter-chip button:hover { background: rgba(127,127,127,.2); }
66
+ .blog-active .blog-clear { border: 0; background: none; color: var(--dm-primary); cursor: pointer; font: inherit; text-decoration: underline; padding: 0; }
67
+
68
+ .blog-items--grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); gap: 1.5rem; }
69
+ .blog-items--stack { display: flex; flex-direction: column; }
70
+ .blog-items .blog-item[hidden] { display: none !important; }
71
+ .blog-items.is-swapping { opacity: .35; transition: opacity .15s; }
72
+
73
+ .blog-empty { padding: 3rem 1rem; text-align: center; color: var(--dm-text-muted); }
74
+ .blog-more { display: flex; flex-direction: column; align-items: center; gap: .5rem; margin-top: 2rem; }
75
+ .blog-more-count { font-size: .85rem; color: var(--dm-text-muted); }
76
+ .blog-pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 2rem; }
77
+ .blog-page-of { font-size: .9rem; color: var(--dm-text-muted); }
78
+
79
+ /* Right-click affordance on a listing that answers it. */
80
+ .blog-archive.is-live .blog-item { cursor: context-menu; }
81
+ .blog-archive.is-live .blog-item a { cursor: pointer; }
82
+ .blog-item.is-flash { animation: blog-flash .9s ease; }
83
+ @keyframes blog-flash { 0% { box-shadow: 0 0 0 3px var(--dm-primary, #3b82f6); } 100% { box-shadow: 0 0 0 3px transparent; } }
84
+
85
+ /* ── Chips and tags ───────────────────────────────────────────────────── */
86
+ .blog-chip { display: inline-block; padding: .12rem .6rem; border-radius: 999px; font-size: .72rem; font-weight: 600;
87
+ letter-spacing: .03em; text-transform: uppercase; text-decoration: none;
88
+ color: var(--dm-primary, #3b82f6); background: color-mix(in srgb, var(--dm-primary, #3b82f6) 12%, transparent);
89
+ border: 1px solid transparent; }
90
+ .blog-chip:hover { border-color: currentColor; }
91
+ .blog-tag { font-size: .85rem; color: var(--dm-text-muted); text-decoration: none; }
92
+ .blog-tag:hover { color: var(--dm-primary); }
93
+
94
+ /* ── A post ───────────────────────────────────────────────────────────── */
95
+ .blog-progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 1100; pointer-events: none; }
96
+ .blog-progress span { display: block; height: 100%; width: 100%; transform-origin: 0 50%; transform: scaleX(0);
97
+ background: var(--dm-primary, #3b82f6); transition: transform .08s linear; }
98
+
99
+ .blog-content { font-size: 1.075rem; line-height: 1.75; }
100
+ .blog-content > * + * { margin-top: 1.1em; }
101
+ .blog-content h2 { font-size: 1.6rem; margin-top: 2em; line-height: 1.25; scroll-margin-top: calc(var(--dm-navbar-height, 64px) + 1rem); }
102
+ .blog-content h3 { font-size: 1.25rem; margin-top: 1.6em; scroll-margin-top: calc(var(--dm-navbar-height, 64px) + 1rem); }
103
+ .blog-content img { max-width: 100%; height: auto; border-radius: var(--dm-radius-md, 8px); }
104
+ .blog-content blockquote { margin: 1.75em 0; padding: .25em 0 .25em 1.25em; border-left: 3px solid var(--dm-primary, #3b82f6);
105
+ font-size: 1.2em; font-style: italic; color: var(--dm-text-muted); }
106
+ .blog-content pre { padding: 1rem; border-radius: var(--dm-radius-md, 8px); overflow-x: auto; background: rgba(127,127,127,.1); font-size: .9em; }
107
+ .blog-content :not(pre) > code { padding: .1em .35em; border-radius: 4px; background: rgba(127,127,127,.14); font-size: .9em; }
108
+
109
+ .blog-toc { font-size: .9rem; }
110
+ .blog-toc-title { margin: 0 0 .5rem; font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--dm-text-muted); }
111
+ .blog-toc ol { list-style: none; margin: 0; padding: 0; border-left: 2px solid var(--dm-border, rgba(127,127,127,.25)); }
112
+ .blog-toc li a { display: block; padding: .3rem 0 .3rem .9rem; margin-left: -2px; border-left: 2px solid transparent;
113
+ color: var(--dm-text-muted); text-decoration: none; line-height: 1.35; }
114
+ .blog-toc li.blog-toc-l3 a { padding-left: 1.7rem; font-size: .85rem; }
115
+ .blog-toc li a:hover { color: var(--dm-text); }
116
+ .blog-toc li a.is-active { color: var(--dm-primary, #3b82f6); border-left-color: var(--dm-primary, #3b82f6); font-weight: 600; }
117
+
118
+ .blog-share { display: flex; gap: .4rem; flex-wrap: wrap; }
119
+ .blog-share-btn { display: inline-flex; align-items: center; gap: .35rem; padding: .35rem .8rem; border-radius: 999px; cursor: pointer;
120
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: none; color: inherit; font: inherit; font-size: .85rem; text-decoration: none; }
121
+ .blog-share-btn:hover { border-color: var(--dm-primary, #3b82f6); color: var(--dm-primary, #3b82f6); }
122
+
123
+ .blog-prevnext { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 2.5rem 0 0; }
124
+ .blog-prevnext a { display: flex; flex-direction: column; gap: .25rem; padding: 1rem 1.1rem; border-radius: var(--dm-radius-lg, 12px);
125
+ border: 1px solid var(--dm-border, rgba(127,127,127,.25)); text-decoration: none; color: inherit; }
126
+ .blog-prevnext a:hover { border-color: var(--dm-primary, #3b82f6); }
127
+ .blog-prevnext a span { font-size: .78rem; color: var(--dm-text-muted); text-transform: uppercase; letter-spacing: .05em; }
128
+ .blog-prevnext .is-newer { text-align: right; grid-column: 2; }
129
+ @media (max-width: 600px) { .blog-prevnext { grid-template-columns: 1fr; } .blog-prevnext .is-newer { grid-column: 1; } }
130
+
131
+ .blog-section-title { font-size: 1.35rem; margin: 0 0 1.1rem; display: flex; align-items: center; gap: .5rem; }
132
+ .blog-related { margin-top: 3.5rem; padding-top: 2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
133
+ .blog-count { font-size: .8rem; padding: .05rem .5rem; border-radius: 999px; background: rgba(127,127,127,.16); font-weight: 600; }
134
+
135
+ /* ── Comments ─────────────────────────────────────────────────────────── */
136
+ .blog-comments { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
137
+ .blog-comment-list { list-style: none; margin: 0 0 2rem; padding: 0; display: flex; flex-direction: column; gap: 1.1rem; }
138
+ .blog-comment-list:empty { display: none; }
139
+ .blog-comment { padding: .9rem 1rem; border-radius: var(--dm-radius-md, 8px); background: rgba(127,127,127,.07); }
140
+ .blog-comment.is-new { outline: 2px solid color-mix(in srgb, var(--dm-success, #16a34a) 55%, transparent); }
141
+ .blog-comment-head { margin: 0 0 .35rem; font-size: .9rem; }
142
+ .blog-comment-head time { color: var(--dm-text-muted); font-size: .82rem; margin-left: .35rem; }
143
+ .blog-comment-body { margin: 0; white-space: pre-line; }
144
+ .blog-comment-form { display: flex; flex-direction: column; gap: .8rem; }
145
+ .blog-comment-form h3 { margin: 0; font-size: 1.1rem; }
146
+ .blog-comment-form label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 600; }
147
+ .blog-comment-form label small { font-weight: 400; color: var(--dm-text-muted); }
148
+ .blog-comment-row { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
149
+ @media (max-width: 600px) { .blog-comment-row { grid-template-columns: 1fr; } }
150
+ .blog-comment-actions { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
151
+ .blog-comment-status { font-size: .9rem; }
152
+ .blog-comment-status.is-ok { color: var(--dm-success, #16a34a); }
153
+ .blog-comment-status.is-error { color: var(--dm-danger, #dc2626); }
154
+ .blog-hp { position: absolute !important; left: -9999px !important; width: 1px; height: 1px; overflow: hidden; }
155
+
156
+ /* ── The right-click menu ─────────────────────────────────────────────── */
157
+ .blog-menu { position: fixed; z-index: 10000; min-width: 220px; max-width: 300px; padding: .3rem;
158
+ background: var(--dm-surface, #fff); color: var(--dm-text, #111); border: 1px solid var(--dm-border, rgba(127,127,127,.3));
159
+ border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.22); font-size: .88rem; }
160
+ .blog-menu[hidden] { display: none; }
161
+ .blog-menu-head { padding: .45rem .65rem .35rem; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase;
162
+ color: var(--dm-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
163
+ .blog-menu button { display: flex; align-items: center; gap: .55rem; width: 100%; padding: .45rem .65rem; border: 0; border-radius: 6px;
164
+ background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
165
+ .blog-menu button:hover, .blog-menu button:focus-visible { background: color-mix(in srgb, var(--dm-primary, #3b82f6) 12%, transparent); outline: none; }
166
+ .blog-menu button .k { margin-left: auto; font-size: .78rem; color: var(--dm-text-muted); }
167
+ .blog-menu hr { border: 0; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); margin: .3rem .2rem; }
168
+ .blog-toast { position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%); z-index: 10001; padding: .55rem 1rem;
169
+ border-radius: 999px; background: var(--dm-text, #111); color: var(--dm-background, #fff); font-size: .88rem; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
170
+
171
+ @media (prefers-reduced-motion: reduce) {
172
+ .blog-item, .blog-item * { transition: none !important; animation: none !important; }
173
+ }
174
+
175
+ /* A Feature post's hero starts where the theme's content does - no page padding
176
+ above it. (It cannot reach the navbar: the theme's wrapper clips overflow.) */
177
+ .blog-page:has(.pf-hero) { padding-top: 0; }