domma-cms 0.70.0 → 0.71.0

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 (35) hide show
  1. package/CLAUDE.md +24 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +3 -3
  4. package/admin/js/lib/admin-scope.js +1 -0
  5. package/admin/js/lib/sidebar-renderer.js +4 -4
  6. package/admin/js/templates/page-editor.html +1 -4
  7. package/admin/js/templates/role-editor.html +28 -0
  8. package/admin/js/views/menu-editor.js +5 -5
  9. package/admin/js/views/page-editor.js +26 -26
  10. package/admin/js/views/role-editor.js +3 -1
  11. package/package.json +1 -1
  12. package/plugins/_lib/admin/mail/reader-view.js +2 -0
  13. package/plugins/_template/plugin.js +5 -2
  14. package/plugins/blog/CLAUDE.md +20 -5
  15. package/plugins/blog/admin/views/kit.js +8 -10
  16. package/plugins/blog/lib/layouts.js +36 -0
  17. package/plugins/blog/lib/nav-link.js +22 -0
  18. package/plugins/blog/lib/page.js +13 -1
  19. package/plugins/blog/plugin.js +28 -9
  20. package/plugins/blog/plugin.json +6 -6
  21. package/plugins/blog/plugin.public.js +102 -80
  22. package/plugins/blog/tests/layouts.test.js +29 -0
  23. package/plugins/blog/tests/nav-link.test.js +10 -1
  24. package/plugins/blog/tests/public.test.js +21 -0
  25. package/plugins/free-tier.lock.json +13 -13
  26. package/server/middleware/auth.js +21 -0
  27. package/server/routes/api/auth.js +15 -7
  28. package/server/routes/api/collections.js +40 -3
  29. package/server/routes/api/sidebar.js +26 -10
  30. package/server/routes/public.js +13 -17
  31. package/server/services/menus.js +1 -1
  32. package/server/services/permissionRegistry.js +10 -7
  33. package/server/services/pluginInstaller.js +23 -9
  34. package/server/services/plugins.js +245 -40
  35. package/server/services/roles.js +170 -5
@@ -57,7 +57,10 @@ All routes are under `/api/plugins/blog`. All require authentication unless note
57
57
 
58
58
  ## Public Routes
59
59
 
60
- Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaults to `/blog` (overridable via `basePath` config key).
60
+ Served at the site root (no `/api/plugins/blog` prefix), under the base path in the settings (`basePath`,
61
+ default `/blog`). A saved change applies at once - no restart. They are not Fastify routes: one global
62
+ `onRequest` hook in plugin.public.js matches each GET against the live base path (`matchBlogPath()` in
63
+ lib/layouts.js) and answers it; anything it does not claim goes on to the site as usual.
61
64
 
62
65
  | Method | Path | Purpose |
63
66
  |---|---|---|
@@ -67,7 +70,8 @@ Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaul
67
70
  | GET | `{base}/tag/:tag` | Posts filtered by tag |
68
71
  | GET | `{base}/author/:id` | Posts filtered by author user ID |
69
72
  | GET | `{base}` | Blog index (page 1) |
70
- | GET | `{base}/:slug` | Single post page - registered LAST |
73
+ | GET | `{base}/` | 301 to `{base}` (else it would be a post with an empty slug: "Not found") |
74
+ | GET | `{base}/:slug` | Single post page |
71
75
 
72
76
  ## Data Model
73
77
 
@@ -111,7 +115,7 @@ Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaul
111
115
 
112
116
  | Key | Default | Purpose |
113
117
  |---|---|---|
114
- | `basePath` | `'/blog'` | URL prefix for all public blog routes |
118
+ | `basePath` | `'/blog'` | URL prefix for all public blog pages; read live, tidied by `cleanBasePath()` on save |
115
119
  | `postsPerPage` | `10` | Archive pagination page size |
116
120
  | `commentsEnabled` | `false` | Show comment list and form on post pages |
117
121
  | `commentModeration` | `true` | New comments start as `pending`; `false` = auto-approve |
@@ -205,7 +209,8 @@ with `?display=` and swaps the cards, since each display is different block mark
205
209
  state - NOT as a marker on the item, because the Menus editor rewrites items with the keys it knows and
206
210
  drops the rest. `onDisable` removes a top-level item with that url, and only when `navLink` says the
207
211
  plugin added it, so a site's own Blog link is never touched. Sites where the blog was already enabled
208
- get no link; there is no enable event for them. A later change of `basePath` does not move the link.
212
+ get no link; there is no enable event for them. Saving a new `basePath` moves that link (`moveNavLink()`)
213
+ and updates `navLink.url`; links a site typed into its own pages or menus are left alone.
209
214
 
210
215
  - **The admin already does Ctrl+S.** app.js clicks the view's primary Save button and calls
211
216
  `preventDefault()`. The editor's own handler saved as well, so every Ctrl+S was two PUTs - and on a
@@ -243,7 +248,17 @@ with `?display=` and swaps the cards, since each display is different block mark
243
248
 
244
249
  - **Named-role publish gate**: `canPublish()` uses `['blog-editor', 'admin', 'super-admin'].includes(user.role)` - NOT a level comparison. Do not refactor this to `getRoleLevel() <= N`; `blog-author` and `blog-editor` are both level 2, so a level check would give blog-authors publish access.
245
250
 
246
- - **Route registration order in plugin.public.js**: Static path segments (`/feed.xml`, `/page/:n`, `/category/:slug`, `/tag/:tag`, `/author/:id`, base index) are all registered **before** `/:slug`. Fastify matches routes in registration order for same-prefix paths, so `/:slug` must be last to avoid swallowing archive routes.
251
+ - **The base path is read per request, so the public pages are an `onRequest` hook, not routes.**
252
+ Fastify routes are fixed once the server is ready, so pages registered at `/blog` stayed there after
253
+ Settings saved `/news` - the new path said "Not found" until a restart. The hook is made global with
254
+ `Symbol.for('skip-override')` (what fastify-plugin sets); without it an encapsulated hook would only
255
+ see routes inside the plugin, and it has none. It must `return` the reply it sends. Shortcodes, the
256
+ preview and the navbar link read the base the same way (`liveBase()` in lib/page.js).
257
+
258
+ - **Blog Pro's posts from its other blogs are never shown here.** Blog Pro (paid, `supersedes: ["blog"]`) uses
259
+ these same collections and adds `blogId` to a post; this blog is Pro's FIRST blog (`'main'` or no blogId).
260
+ `isVisible()` (lib/page.js and plugin.js) refuses any other blogId, so if a Pro licence lapses and this plugin
261
+ loads again, those posts are hidden rather than piled into /blog. The admin list still shows them.
247
262
 
248
263
  - **Lazy scheduled post check**: `plugin.public.js` calls `isVisible(post)` on every `GET /:slug` request. A post transitions from `scheduled` to visible the moment `new Date(post.publishedAt) <= new Date()` - no background job needed, no cron required.
249
264
 
@@ -17,7 +17,7 @@ export function escapeHtml(s) {
17
17
 
18
18
  /**
19
19
  * The blog's public base path (`/blog` unless configured), for links to the
20
- * live site. Read once per page load; a change needs a restart anyway.
20
+ * live site. Cleared when Settings saves a new one.
21
21
  *
22
22
  * @returns {Promise<string>}
23
23
  */
@@ -184,13 +184,13 @@ const SETTINGS_PANEL = `
184
184
  <div class="blog-panel">
185
185
  <div class="blog-field">
186
186
  <label class="form-label" for="blog-set-base">Base path
187
- <span data-help="The URL every blog page lives under - /blog gives /blog/my-post. Takes effect after a server restart."></span>
187
+ <span data-help="The URL every blog page lives under - /blog gives /blog/my-post. Applies as soon as you save; the navbar link Blog added moves with it, but links typed into pages do not."></span>
188
188
  </label>
189
189
  <input id="blog-set-base" type="text" class="form-input font-mono" placeholder="/blog" data-model="basePath.value">
190
190
  </div>
191
191
  <div class="blog-field">
192
192
  <label class="form-label" for="blog-set-ppp">Posts per page
193
- <span data-help="How many posts a listing page shows before paging. Takes effect after a server restart."></span>
193
+ <span data-help="How many posts a listing page shows before paging."></span>
194
194
  </label>
195
195
  <input id="blog-set-ppp" type="number" min="1" max="100" class="form-input" data-model="postsPerPage.value">
196
196
  </div>
@@ -252,7 +252,6 @@ const SETTINGS_PANEL = `
252
252
  <button type="button" class="btn btn-sm btn-ghost blog-panel-link" data-on-click="openBlocks">
253
253
  <span data-icon="layout" data-icon-size="13"></span> Edit the layouts in Blocks
254
254
  </button>
255
- <p class="blog-panel-note" data-if="needsRestart.value">Base path and posts per page change after the server restarts.</p>
256
255
  <div class="blog-panel-actions">
257
256
  <span class="blog-panel-status" data-bind-text="status.value"></span>
258
257
  <span class="blog-spacer"></span>
@@ -320,11 +319,7 @@ export async function settingsPanel({onClose = null} = {}) {
320
319
  closable: Boolean(onClose),
321
320
  layoutOptions: M.observable(withCurrent(layouts.postLayouts ?? [], initial.postLayout)),
322
321
  displayOptions: M.observable(withCurrent(layouts.listDisplays ?? [], initial.listDisplay)),
323
- dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value)),
324
- needsRestart: M.computed(() => {
325
- const b = baseline.value;
326
- return String(f.basePath.value).trim() !== b.basePath || Number(f.postsPerPage.value) !== b.postsPerPage;
327
- })
322
+ dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value))
328
323
  };
329
324
 
330
325
  const el = fragment(SETTINGS_PANEL);
@@ -346,10 +341,13 @@ export async function settingsPanel({onClose = null} = {}) {
346
341
  try {
347
342
  const res = await H.put(`${BASE}/settings`, body);
348
343
  if (res?.error) throw new Error(res.error);
344
+ // The server tidies the path ("/news/" is saved as "/news"); show what it kept.
345
+ const savedBase = (res?.settings ?? res?.data?.settings)?.basePath;
346
+ if (savedBase) f.basePath.value = body.basePath = savedBase;
349
347
  baseline.value = {...body};
350
348
  basePathCache = null;
351
349
  status.value = 'Saved.';
352
- E.toast('Blog settings saved. Display changes are live now.', {type: 'success'});
350
+ E.toast('Blog settings saved. They are live now.', {type: 'success'});
353
351
  } catch (err) {
354
352
  E.toast(err?.message ?? 'Failed to save settings.', {type: 'error'});
355
353
  } finally {
@@ -304,3 +304,39 @@ export function cardMeta(post, {catMap = {}, authorName = ''}) {
304
304
  export function byNewest(a, b) {
305
305
  return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
306
306
  }
307
+
308
+ /**
309
+ * A base path as the routes use it: one leading slash, no trailing one, no
310
+ * spaces or query. Anything that leaves nothing (or just "/", which would
311
+ * swallow the whole site) is `/blog`.
312
+ */
313
+ export function cleanBasePath(value) {
314
+ const path = String(value ?? '').trim().split(/[?#\s]/)[0].replace(/\/{2,}/g, '/').replace(/^\/*/, '/').replace(/\/+$/, '');
315
+ return path || '/blog';
316
+ }
317
+
318
+ const ARCHIVE_PARAM = {page: 'n', category: 'slug', tag: 'tag', author: 'id'};
319
+
320
+ /**
321
+ * Which public page a path is under `base`, read at request time so a changed
322
+ * base path applies at once. `null` for anything that is not the blog's.
323
+ *
324
+ * @returns {{name: string, params: object}|null} name: index | slash | feed | page | category | tag | author | post
325
+ */
326
+ export function matchBlogPath(pathname, base) {
327
+ if (pathname === base) return {name: 'index', params: {}};
328
+ if (!pathname.startsWith(`${base}/`)) return null;
329
+ const rest = pathname.slice(base.length + 1);
330
+ if (rest === '') return {name: 'slash', params: {}};
331
+ let parts;
332
+ try {
333
+ parts = rest.split('/').map(decodeURIComponent);
334
+ } catch {
335
+ return null;
336
+ }
337
+ if (parts.length === 1) return parts[0] === 'feed.xml' ? {name: 'feed', params: {}} : {name: 'post', params: {slug: parts[0]}};
338
+ if (parts.length === 2 && parts[1] && Object.hasOwn(ARCHIVE_PARAM, parts[0])) {
339
+ return {name: parts[0], params: {[ARCHIVE_PARAM[parts[0]]]: parts[1]}};
340
+ }
341
+ return null;
342
+ }
@@ -70,3 +70,25 @@ export async function removeNavLink(added, menus = menuService) {
70
70
  await menus.updateMenu(added.menu, {...menu, items: kept});
71
71
  return true;
72
72
  }
73
+
74
+ /**
75
+ * Point the link this plugin added at a new base path, after the setting
76
+ * changes. Only the top-level item still linking where it was added moves.
77
+ *
78
+ * @param {{menu: string, url: string}|null} added - from addNavLink()
79
+ * @param {string} url - the new base path
80
+ * @param {object} [menus]
81
+ * @returns {Promise<boolean>} whether a link was moved
82
+ */
83
+ export async function moveNavLink(added, url, menus = menuService) {
84
+ if (!added?.menu || !added?.url) return false;
85
+ const menu = await menus.getMenu(added.menu);
86
+ if (!menu) return false;
87
+ const from = trim(added.url);
88
+ const items = Array.isArray(menu.items) ? menu.items : [];
89
+ const at = items.findIndex(it => it && typeof it.url === 'string' && trim(it.url) === from);
90
+ if (at === -1) return false;
91
+ const moved = items.map((it, i) => (i === at ? {...it, url: trim(url)} : it));
92
+ await menus.updateMenu(added.menu, {...menu, items: moved});
93
+ return true;
94
+ }
@@ -10,10 +10,17 @@ import {parseMarkdown} from '../../../server/services/markdown.js';
10
10
  import {getUserById} from '../../../server/services/users.js';
11
11
  import {getPluginStates} from '../../../server/services/plugins.js';
12
12
  import defaultConfig from '../config.js';
13
- import {buildToc, byNewest, cardVars, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
13
+ import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
14
14
  import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
15
15
 
16
+ /**
17
+ * Whether readers see a post. Blog Pro files posts under several blogs
18
+ * (`blogId`); this blog is its FIRST one ('main', or none). Should a Pro
19
+ * licence lapse and this plugin take over again, posts from Pro's other blogs
20
+ * stay out of /blog rather than all landing in one listing - hidden, not lost.
21
+ */
16
22
  export function isVisible(post) {
23
+ if (post.blogId && post.blogId !== 'main') return false;
17
24
  return post.status === 'published' ||
18
25
  (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
19
26
  }
@@ -23,6 +30,11 @@ export function liveSettings(registered) {
23
30
  return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};
24
31
  }
25
32
 
33
+ /** The base path as it is saved NOW - a change applies without a restart. */
34
+ export function liveBase(registered) {
35
+ return cleanBasePath(liveSettings(registered).basePath);
36
+ }
37
+
26
38
  /** Always returns the actual array of entries, regardless of adapter shape. */
27
39
  export async function loadEntries(slug) {
28
40
  const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
@@ -3,10 +3,10 @@ import defaultConfig from './config.js';
3
3
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
4
4
  import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
5
5
  import {renderBlogPage} from '../../server/services/renderer.js';
6
- import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
6
+ import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
- import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
9
- import {addNavLink, removeNavLink} from './lib/nav-link.js';
8
+ import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
9
+ import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
 
12
12
  const POSTS_SLUG = 'blog-posts';
@@ -121,7 +121,9 @@ function formatDate(iso) {
121
121
  return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
122
122
  }
123
123
 
124
+ /** As lib/page.js isVisible: Blog Pro's other blogs stay out of this one. */
124
125
  function isVisible(post) {
126
+ if (post.blogId && post.blogId !== 'main') return false;
125
127
  return post.status === 'published'
126
128
  || (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
127
129
  }
@@ -164,8 +166,10 @@ function renderEmbedCard(post, base, { showImage = true } = {}) {
164
166
  </article>`;
165
167
  }
166
168
 
167
- function registerEmbedShortcodes(registerShortcode, basePath) {
169
+ /** `baseOf()` is read per render, so links follow a changed base path at once. */
170
+ function registerEmbedShortcodes(registerShortcode, baseOf) {
168
171
  registerShortcode('blog-list', async (attrStr, _body, ctx) => {
172
+ const basePath = baseOf();
169
173
  const attrs = ctx.parseShortcodeAttrs(attrStr);
170
174
  const limit = parseInt(attrs.count ?? attrs.limit ?? '5', 10) || 5;
171
175
  const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
@@ -182,6 +186,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
182
186
  });
183
187
 
184
188
  registerShortcode('blog-grid', async (attrStr, _body, ctx) => {
189
+ const basePath = baseOf();
185
190
  const attrs = ctx.parseShortcodeAttrs(attrStr);
186
191
  const limit = parseInt(attrs.count ?? attrs.limit ?? '6', 10) || 6;
187
192
  const cols = Math.max(1, Math.min(4, parseInt(attrs.cols ?? '3', 10) || 3));
@@ -194,6 +199,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
194
199
  });
195
200
 
196
201
  registerShortcode('blog-featured', async (attrStr, _body, ctx) => {
202
+ const basePath = baseOf();
197
203
  const attrs = ctx.parseShortcodeAttrs(attrStr);
198
204
  const posts = await loadPosts();
199
205
  let post;
@@ -219,6 +225,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
219
225
  });
220
226
 
221
227
  registerShortcode('blog-categories', async (attrStr, _body, ctx) => {
228
+ const basePath = baseOf();
222
229
  const attrs = ctx.parseShortcodeAttrs(attrStr);
223
230
  const showCounts = attrs['show-counts'] === 'true' || attrs.counts === 'true';
224
231
  const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
@@ -236,10 +243,9 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
236
243
  export default async function blogPlugin(fastify, options) {
237
244
  const { authenticate, requireAdmin } = options.auth;
238
245
  const settings = { ...defaultConfig, ...(options.settings || {}) };
239
- const basePath = (settings.basePath ?? '/blog').replace(/\/$/, '');
240
246
 
241
247
  if (options.hooks?.registerShortcode) {
242
- registerEmbedShortcodes(options.hooks.registerShortcode, basePath);
248
+ registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
243
249
  }
244
250
 
245
251
  // The layouts are blocks; put the shipped ones where the Blocks tool can
@@ -291,6 +297,7 @@ export default async function blogPlugin(fastify, options) {
291
297
  post.slug = post.slug || 'preview';
292
298
  const allPosts = (await loadAll(POSTS_SLUG)).filter(p => p.id !== post.id);
293
299
  const s = liveSettings(settings);
300
+ const basePath = liveBase(settings);
294
301
  const page = await renderPostPage(post, [...allPosts, post], s, {
295
302
  base: basePath, layout: cleanTemplate(body.layout) || null, preview: true
296
303
  });
@@ -705,7 +712,7 @@ export default async function blogPlugin(fastify, options) {
705
712
  const merged = {
706
713
  ...defaultConfig,
707
714
  ...stored,
708
- ...(body.basePath !== undefined && { basePath: String(body.basePath) }),
715
+ ...(body.basePath !== undefined && { basePath: cleanBasePath(body.basePath) }),
709
716
  ...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
710
717
  ...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
711
718
  ...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
@@ -714,6 +721,19 @@ export default async function blogPlugin(fastify, options) {
714
721
  };
715
722
 
716
723
  savePluginState('blog', { settings: merged });
724
+
725
+ // The public pages follow the new base path at once (plugin.public.js);
726
+ // so does the navbar link, if enabling put it there.
727
+ const added = getPluginStates()?.blog?.navLink ?? null;
728
+ if (added && added.url !== merged.basePath) {
729
+ try {
730
+ if (await moveNavLink(added, merged.basePath)) {
731
+ savePluginState('blog', { navLink: { ...added, url: merged.basePath } });
732
+ }
733
+ } catch (err) {
734
+ request.log.warn(`[blog] could not move the navbar link: ${err.message}`);
735
+ }
736
+ }
717
737
  return reply.send({ ok: true, settings: merged });
718
738
  });
719
739
  }
@@ -724,8 +744,7 @@ export default async function blogPlugin(fastify, options) {
724
744
 
725
745
  /** The blog's base path from its saved settings, as the routes will use it. */
726
746
  function currentBasePath() {
727
- const saved = getPluginStates()?.blog?.settings ?? {};
728
- return String(saved.basePath ?? defaultConfig.basePath ?? '/blog').replace(/\/+$/, '') || '/blog';
747
+ return liveBase({});
729
748
  }
730
749
 
731
750
  /**
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.6.0",
4
+ "version": "1.6.3",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
@@ -34,11 +34,11 @@
34
34
  { "path": "/plugins/blog/settings", "view": "plugin-blog-settings", "title": "Blog Settings - Domma CMS" }
35
35
  ],
36
36
  "views": {
37
- "plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=8", "exportName": "blogView" },
38
- "plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=8", "exportName": "postEditorView" },
39
- "plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=8", "exportName": "categoriesView" },
40
- "plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=8", "exportName": "commentsView" },
41
- "plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=8", "exportName": "settingsView" }
37
+ "plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=9", "exportName": "blogView" },
38
+ "plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=9", "exportName": "postEditorView" },
39
+ "plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=9", "exportName": "categoriesView" },
40
+ "plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=9", "exportName": "commentsView" },
41
+ "plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=9", "exportName": "settingsView" }
42
42
  }
43
43
  }
44
44
  }
@@ -7,19 +7,23 @@
7
7
  * over - search, category pills, sort, display switching and a right-click
8
8
  * menu - and adds reading progress, contents tracking, sharing and in-page
9
9
  * comments to a post.
10
+ *
11
+ * The pages are not fixed routes: one onRequest hook matches the path against
12
+ * the base path as it is saved NOW, so changing it in Settings moves the blog
13
+ * without a restart. See the Gotchas in CLAUDE.md.
10
14
  */
11
15
  import path from 'path';
12
16
  import {fileURLToPath} from 'url';
13
17
  import {renderBlogPage} from '../../server/services/renderer.js';
14
- import {byNewest, escapeHtml, pickDisplay, safeImageUrl} from './lib/layouts.js';
18
+ import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
15
19
  import {catalogue, renderCards} from './lib/render.js';
16
- import {authorNamesFor, isVisible, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
20
+ import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
17
21
 
18
22
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
19
23
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
20
24
 
21
25
  /** Bumped with the plugin, so a release is never served a stale script. */
22
- const ASSET_V = '1.6.0';
26
+ const ASSET_V = '1.6.3';
23
27
 
24
28
  /**
25
29
  * Above this many posts a listing pages on the server instead: the browser
@@ -56,7 +60,6 @@ function scriptJson(value) {
56
60
 
57
61
  export default async function blogPublicPlugin(fastify, options) {
58
62
  const {settings, config} = options;
59
- const base = (settings?.basePath ?? '/blog').replace(/\/$/, '');
60
63
 
61
64
  /**
62
65
  * A listing: the index, a page of it, or a category / tag / author archive.
@@ -64,9 +67,9 @@ export default async function blogPublicPlugin(fastify, options) {
64
67
  * @param {object} request
65
68
  * @param {object} reply
66
69
  * @param {object[]} posts - candidates; drafts are dropped here
67
- * @param {object} opts - {title, intro, page, pageBase, filter}
70
+ * @param {object} opts - {base, title, intro, page, filter}
68
71
  */
69
- async function renderArchive(request, reply, posts, {title, intro = '', page = 1, pageBase = base, filter = null}) {
72
+ async function renderArchive(request, reply, posts, {base, title, intro = '', page = 1, filter = null}) {
70
73
  const s = liveSettings(settings);
71
74
  const perPage = Math.max(1, Number(s.postsPerPage) || 10);
72
75
  const {listDisplays} = await catalogue();
@@ -115,7 +118,7 @@ ${items || ''}
115
118
  archiveTitle: escapeHtml(title),
116
119
  archiveIntro: intro ? `<p class="blog-archive-intro">${escapeHtml(intro)}</p>` : '',
117
120
  archive,
118
- pagination: interactive ? '' : buildPagination(safePage, totalPages, pageBase, requested),
121
+ pagination: interactive ? '' : buildPagination(safePage, totalPages, base, requested),
119
122
  assetV: ASSET_V
120
123
  },
121
124
  {title, urlPath: request.url.split('?')[0]}
@@ -123,16 +126,18 @@ ${items || ''}
123
126
  return reply.type('text/html').send(html);
124
127
  }
125
128
 
126
- // 1. RSS feed
127
- fastify.get(`${base}/feed.xml`, async (request, reply) => {
128
- const s = liveSettings(settings);
129
- const allPosts = await loadEntries('blog-posts');
130
- const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
129
+ /** One handler per page; `matchBlogPath()` (lib/layouts.js) picks which. */
130
+ const pages = {
131
+ /** RSS feed */
132
+ async feed(request, reply, params, base) {
133
+ const s = liveSettings(settings);
134
+ const allPosts = await loadEntries('blog-posts');
135
+ const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
131
136
 
132
- const siteTitle = config?.site?.title ?? 'Blog';
133
- const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
137
+ const siteTitle = config?.site?.title ?? 'Blog';
138
+ const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
134
139
 
135
- const items = posts.map((post) => ` <item>
140
+ const items = posts.map((post) => ` <item>
136
141
  <title>${escapeHtml(post.title)}</title>
137
142
  <link>${siteUrl}${base}/${escapeHtml(post.slug)}</link>
138
143
  <description>${escapeHtml(post.excerpt || '')}</description>
@@ -140,7 +145,7 @@ ${items || ''}
140
145
  <guid>${siteUrl}${base}/${escapeHtml(post.slug)}</guid>
141
146
  </item>`).join('\n');
142
147
 
143
- return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
148
+ return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
144
149
  <rss version="2.0">
145
150
  <channel>
146
151
  <title>${escapeHtml(siteTitle)}</title>
@@ -149,69 +154,86 @@ ${items || ''}
149
154
  ${items}
150
155
  </channel>
151
156
  </rss>`);
157
+ },
158
+
159
+ /** Paginated index */
160
+ async page(request, reply, {n}, base) {
161
+ const page = parseInt(n, 10) || 1;
162
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {base, title: 'Blog', page});
163
+ },
164
+
165
+ /** Category archive */
166
+ async category(request, reply, {slug}, base) {
167
+ const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
168
+ const category = allCategories.find((c) => c.slug === slug);
169
+ if (!category) return reply.code(404).send('Category not found');
170
+ const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
171
+ return renderArchive(request, reply, posts, {
172
+ base, title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
173
+ });
174
+ },
175
+
176
+ /** Tag archive */
177
+ async tag(request, reply, {tag}, base) {
178
+ const posts = (await loadEntries('blog-posts')).filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
179
+ return renderArchive(request, reply, posts, {base, title: `#${tag}`, filter: {type: 'tag', value: tag}});
180
+ },
181
+
182
+ /** Author archive */
183
+ async author(request, reply, {id}, base) {
184
+ const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
185
+ const authorName = await resolveAuthorName(id);
186
+ return renderArchive(request, reply, posts, {base, title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
187
+ },
188
+
189
+ /** Blog index */
190
+ async index(request, reply, params, base) {
191
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {base, title: 'Blog'});
192
+ },
193
+
194
+ /** `{base}/` - the index, rather than a post with an empty slug */
195
+ async slash(request, reply, params, base) {
196
+ const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
197
+ return reply.redirect(`${base}${query}`, 301);
198
+ },
199
+
200
+ /** Individual post */
201
+ async post(request, reply, {slug}, base) {
202
+ const allPosts = await loadEntries('blog-posts');
203
+ const post = allPosts.find((p) => p.slug === slug);
204
+ if (!post || !isVisible(post)) return reply.code(404).send('Not found');
205
+
206
+ const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
207
+ const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
208
+
209
+ const html = await renderBlogPage(
210
+ path.join(TEMPLATES_DIR, 'post.html'),
211
+ {post: page.html, assetV: ASSET_V},
212
+ {
213
+ title: post.seo?.title || post.title,
214
+ description: post.seo?.description || post.excerpt || '',
215
+ ogImage: safeImageUrl(post.featuredImage),
216
+ ogType: 'article',
217
+ usedComponents: page.usedComponents,
218
+ urlPath: `${base}/${slug}`
219
+ }
220
+ );
221
+ return reply.type('text/html').send(html);
222
+ }
223
+ };
224
+
225
+ // Answered here, ahead of routing, because routes are fixed at boot and the
226
+ // base path is not. The hook is global (skip-override, below); it answers
227
+ // only what matchBlogPath() claims and lets everything else through.
228
+ fastify.addHook('onRequest', async (request, reply) => {
229
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
230
+ const base = liveBase(settings);
231
+ const hit = matchBlogPath(request.url.split('?')[0], base);
232
+ if (!hit) return;
233
+ return pages[hit.name](request, reply, hit.params, base);
152
234
  });
153
-
154
- // 2. Paginated index
155
- fastify.get(`${base}/page/:n`, async (request, reply) => {
156
- const page = parseInt(request.params.n, 10) || 1;
157
- return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog', page});
158
- });
159
-
160
- // 3. Category archive
161
- fastify.get(`${base}/category/:slug`, async (request, reply) => {
162
- const {slug} = request.params;
163
- const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
164
- const category = allCategories.find((c) => c.slug === slug);
165
- if (!category) return reply.code(404).send('Category not found');
166
- const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
167
- return renderArchive(request, reply, posts, {
168
- title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
169
- });
170
- });
171
-
172
- // 4. Tag archive
173
- fastify.get(`${base}/tag/:tag`, async (request, reply) => {
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}});
177
- });
178
-
179
- // 5. Author archive
180
- fastify.get(`${base}/author/:id`, async (request, reply) => {
181
- const {id} = request.params;
182
- const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
183
- const authorName = await resolveAuthorName(id);
184
- return renderArchive(request, reply, posts, {title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
185
- });
186
-
187
- // 6. Blog index
188
- fastify.get(base, async (request, reply) => {
189
- return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog'});
190
- });
191
-
192
- // 7. Individual post - registered LAST so the slug match doesn't shadow archives
193
- fastify.get(`${base}/:slug`, async (request, reply) => {
194
- const {slug} = request.params;
195
- const allPosts = await loadEntries('blog-posts');
196
- const post = allPosts.find((p) => p.slug === slug);
197
- if (!post || !isVisible(post)) return reply.code(404).send('Not found');
198
-
199
- const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
200
- const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
201
-
202
- const html = await renderBlogPage(
203
- path.join(TEMPLATES_DIR, 'post.html'),
204
- {post: page.html, assetV: ASSET_V},
205
- {
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
- }
213
- );
214
- return reply.type('text/html').send(html);
215
- });
216
-
217
235
  }
236
+
237
+ // What fastify-plugin sets: the onRequest hook must see every request, not only
238
+ // routes registered inside this plugin (it registers none).
239
+ blogPublicPlugin[Symbol.for('skip-override')] = true;
@@ -95,3 +95,32 @@ test('related posts: shared categories outweigh shared tags; newer breaks a tie'
95
95
  assert.deepEqual(relatedPosts(me, all, 3).map(p => p.id), ['cat-new', 'cat-old', 'tag-only']);
96
96
  assert.deepEqual(relatedPosts(me, all, 0), []);
97
97
  });
98
+
99
+ test('a base path is tidied, and never the whole site', async () => {
100
+ const {cleanBasePath} = await import('../lib/layouts.js');
101
+ assert.equal(cleanBasePath('/news'), '/news');
102
+ assert.equal(cleanBasePath('news/'), '/news');
103
+ assert.equal(cleanBasePath('//a//b/'), '/a/b');
104
+ assert.equal(cleanBasePath('/x?y=1'), '/x');
105
+ for (const v of ['', '/', ' ', null, undefined]) assert.equal(cleanBasePath(v), '/blog', String(v));
106
+ });
107
+
108
+ test('matchBlogPath claims the blog\'s pages under whatever base it is given', async () => {
109
+ const {matchBlogPath} = await import('../lib/layouts.js');
110
+ assert.deepEqual(matchBlogPath('/news', '/news'), {name: 'index', params: {}});
111
+ assert.deepEqual(matchBlogPath('/news/', '/news'), {name: 'slash', params: {}});
112
+ assert.deepEqual(matchBlogPath('/news/feed.xml', '/news'), {name: 'feed', params: {}});
113
+ assert.deepEqual(matchBlogPath('/news/page/2', '/news'), {name: 'page', params: {n: '2'}});
114
+ assert.deepEqual(matchBlogPath('/news/category/a%20b', '/news'), {name: 'category', params: {slug: 'a b'}});
115
+ assert.deepEqual(matchBlogPath('/news/tag/x', '/news'), {name: 'tag', params: {tag: 'x'}});
116
+ assert.deepEqual(matchBlogPath('/news/author/7', '/news'), {name: 'author', params: {id: '7'}});
117
+ assert.deepEqual(matchBlogPath('/news/my-post', '/news'), {name: 'post', params: {slug: 'my-post'}});
118
+ for (const p of ['/blog', '/newsroom', '/news/a/b', '/news/page/', '/news/%E0%A4%A']) assert.equal(matchBlogPath(p, '/news'), null, p);
119
+ });
120
+
121
+ test('a post Blog Pro filed under another blog is not shown by this one', async () => {
122
+ const {isVisible} = await import('../lib/page.js');
123
+ assert.equal(isVisible({status: 'published'}), true);
124
+ assert.equal(isVisible({status: 'published', blogId: 'main'}), true);
125
+ assert.equal(isVisible({status: 'published', blogId: 'travel'}), false);
126
+ });