domma-cms 0.69.3 → 0.70.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 (33) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/admin/js/lib/shortcode-context-menu.js +1 -1
  4. package/admin/js/views/analytics.js +1 -1
  5. package/admin/js/views/contacts.js +1 -1
  6. package/admin/js/views/context-menu-editor.js +1 -1
  7. package/admin/js/views/notes.js +1 -1
  8. package/admin/js/views/todo.js +1 -1
  9. package/package.json +2 -2
  10. package/plugins/blog/CLAUDE.md +23 -2
  11. package/plugins/blog/admin/css/index.css +28 -0
  12. package/plugins/blog/admin/templates/post-editor.html +25 -9
  13. package/plugins/blog/admin/views/blog.js +2 -0
  14. package/plugins/blog/admin/views/categories.js +1 -0
  15. package/plugins/blog/admin/views/comments.js +1 -0
  16. package/plugins/blog/admin/views/kit.js +1 -1
  17. package/plugins/blog/admin/views/post-editor.js +228 -45
  18. package/plugins/blog/blocks/blog-post-essay.css +29 -0
  19. package/plugins/blog/blocks/blog-post-essay.html +28 -0
  20. package/plugins/blog/blocks/blog-post-split.css +22 -0
  21. package/plugins/blog/blocks/blog-post-split.html +32 -0
  22. package/plugins/blog/lib/layouts.js +4 -2
  23. package/plugins/blog/lib/nav-link.js +72 -0
  24. package/plugins/blog/plugin.js +40 -1
  25. package/plugins/blog/plugin.json +6 -6
  26. package/plugins/blog/plugin.public.js +1 -1
  27. package/plugins/blog/tests/layouts.test.js +1 -1
  28. package/plugins/blog/tests/nav-link.test.js +57 -0
  29. package/plugins/free-tier.lock.json +21 -15
  30. package/public/js/collection-context.js +2 -2
  31. package/public/js/context-menus.js +1 -1
  32. package/scripts/run-tests.mjs +3 -1
  33. package/server/services/siteGitignore.js +8 -5
@@ -67,7 +67,7 @@ async function mountLayoutCard(card, {current, collect}) {
67
67
  try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
68
68
  const known = catalogue.postLayouts ?? [];
69
69
  const defaultLabel = known.find(l => l.id === catalogue.postLayout)?.label ?? 'Classic';
70
- const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar'].includes(id) ? id.slice(10) : 'custom');
70
+ const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar', 'blog-post-split', 'blog-post-essay'].includes(id) ? id.slice(10) : 'custom');
71
71
 
72
72
  const choices = [
73
73
  {id: '', label: 'Site default', description: `Follows Blog settings - currently ${defaultLabel}.`, thumb: thumbOf(catalogue.postLayout)},
@@ -155,6 +155,173 @@ async function mountLayoutCard(card, {current, collect}) {
155
155
  return {value: () => layout.peek()};
156
156
  }
157
157
 
158
+ const STAMP_FORMAT = 'D MMM YYYY, HH:mm';
159
+ const STATUS_CLASS = {published: 'badge-success', scheduled: 'badge-warning'};
160
+
161
+ /**
162
+ * The Status card: the badge, and when the post went (or goes) live, was last
163
+ * saved and was created. Reactive: `set(post)` after any load, save, publish or
164
+ * schedule and the card follows. The "3 days ago" part ticks once a minute.
165
+ *
166
+ * Every computed reads the observables directly - a binding hung off a
167
+ * computed derived from another computed applies once and then freezes.
168
+ *
169
+ * @param {HTMLElement} card
170
+ * @returns {{set: (post: object|null) => void, status: object, dispose: () => void}}
171
+ */
172
+ function mountStatusCard(card) {
173
+ const status = M.observable('draft');
174
+ const publishedAt = M.observable('');
175
+ const updatedAt = M.observable('');
176
+ const createdAt = M.observable('');
177
+ const now = M.observable(Date.now());
178
+ const tick = setInterval(() => { now.value = Date.now(); }, 60000);
179
+
180
+ const when = (iso) => (iso && !Number.isNaN(Date.parse(iso)) ? D(iso).format(STAMP_FORMAT) : '');
181
+ const ago = (iso, at) => (iso && !Number.isNaN(Date.parse(iso)) ? `(${D(iso).fromNow(at)})` : '');
182
+
183
+ const data = {
184
+ status,
185
+ statusClass: M.computed(() => STATUS_CLASS[status.value] ?? 'badge-secondary'),
186
+ liveLabel: M.computed(() => (status.value === 'scheduled' ? 'Goes live' : 'Published')),
187
+ liveWhen: M.computed(() => (status.value === 'draft' ? '' : when(publishedAt.value))),
188
+ liveAgo: M.computed(() => (status.value === 'draft' ? '' : ago(publishedAt.value, now.value))),
189
+ savedWhen: M.computed(() => when(updatedAt.value)),
190
+ savedAgo: M.computed(() => ago(updatedAt.value, now.value)),
191
+ createdWhen: M.computed(() => when(createdAt.value))
192
+ };
193
+ const handle = M.applyBindings(data, card.querySelector('.card-body'));
194
+
195
+ return {
196
+ status,
197
+ set(post) {
198
+ publishedAt.value = post?.publishedAt ?? '';
199
+ updatedAt.value = post?.updatedAt ?? post?.createdAt ?? '';
200
+ createdAt.value = post?.createdAt ?? '';
201
+ now.value = Date.now();
202
+ },
203
+ dispose() { clearInterval(tick); handle?.dispose?.(); }
204
+ };
205
+ }
206
+
207
+ /**
208
+ * A typeable pick-list with the choices shown as pills, each with an x -
209
+ * Domma's Pillbox over a plain input. Clicking the box (or ArrowDown in it)
210
+ * drops the whole list; typing narrows it.
211
+ *
212
+ * `creatable` also takes free text: Enter or a comma makes a pill of what was
213
+ * typed, a pasted "a, b, c" becomes three, and text left in the box when it
214
+ * loses focus is kept rather than lost. Matching ignores case, so "News" and
215
+ * "news" are one tag.
216
+ *
217
+ * Labels reach the page as text, never markup: category names and tags are
218
+ * typed by people.
219
+ *
220
+ * @param {HTMLInputElement} input
221
+ * @param {object} opts
222
+ * @param {{value: string, label: string}[]} opts.data - the choices
223
+ * @param {string[]} opts.value - selected values
224
+ * @param {boolean} [opts.creatable=false]
225
+ * @param {string} opts.placeholder
226
+ * @param {string} opts.empty - shown when nothing matches
227
+ * @param {object} opts.selected - an observable the values are written to
228
+ * @returns {{pending: () => string, destroy: () => void}}
229
+ */
230
+ function mountPills(input, {data, value, creatable = false, placeholder, empty, selected}) {
231
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
232
+ let box = null;
233
+ let field = null;
234
+ box = E.pillbox(input, {
235
+ data,
236
+ value,
237
+ searchable: true,
238
+ creatable,
239
+ clearable: false,
240
+ placeholder,
241
+ noResultsMessage: empty,
242
+ duplicateMessage: 'Already added',
243
+ renderPill: ({label}) => {
244
+ const span = document.createElement('span');
245
+ span.textContent = label;
246
+ return span;
247
+ },
248
+ validatePill: (v) => (box && box.getValue().some(x => same(x, v)) ? 'Already added' : true),
249
+ onChange: (values) => { selected.value = [...values]; },
250
+ onCreate: () => box?.close(),
251
+ // A repeat is dropped, and so is its text - left in the box it ran
252
+ // into the next word typed.
253
+ onValidationError: (msg, value) => {
254
+ if (field) field.value = '';
255
+ box?.close();
256
+ E.toast(msg === 'Already added' ? `"${value}" is already on this post.` : msg, {type: 'info'});
257
+ }
258
+ });
259
+ selected.value = box.getValue();
260
+
261
+ const wrapper = input.nextElementSibling;
262
+ field = wrapper?.querySelector('.dm-pillbox-input');
263
+ if (!field) return {pending: () => '', destroy: () => box.destroy?.()};
264
+
265
+ // Pillbox only opens once something is typed; a list you cannot see is a
266
+ // list you cannot pick from. _filterOptions is Pillbox's own "narrow to
267
+ // this" - with '' it is "everything not yet chosen".
268
+ const showAll = () => {
269
+ if (field.value || typeof box._filterOptions !== 'function') return;
270
+ if (!data.some(d => !box.getValue().includes(d.value))) return;
271
+ box._filterOptions('');
272
+ };
273
+ wrapper.querySelector('.dm-pillbox-container')?.addEventListener('click', showAll);
274
+ field.addEventListener('keydown', (e) => {
275
+ if (e.key === 'ArrowDown' && !box.isOpen()) { e.preventDefault(); showAll(); }
276
+ });
277
+
278
+ const commit = (text) => {
279
+ const t = String(text).trim();
280
+ if (!t) return;
281
+ const known = data.find(d => same(d.label, t));
282
+ if (box.getValue().some(x => same(x, known?.value ?? t))) {
283
+ E.toast(`"${t}" is already on this post.`, {type: 'info'});
284
+ return;
285
+ }
286
+ box.addPill(known?.value ?? t, known?.label ?? t);
287
+ };
288
+ if (creatable) {
289
+ // Enter with no suggestion highlighted goes through commit() rather
290
+ // than Pillbox's own create, so "RELEASE" becomes the "release" the
291
+ // site already uses instead of a second spelling of it. Capture phase:
292
+ // this has to run before Pillbox's handler on the input.
293
+ wrapper.addEventListener('keydown', (e) => {
294
+ if (e.target !== field || e.key !== 'Enter' || !field.value.trim()) return;
295
+ if (wrapper.querySelector('.dm-pillbox-option.active')) return;
296
+ e.preventDefault();
297
+ e.stopPropagation();
298
+ commit(field.value);
299
+ field.value = '';
300
+ box.close();
301
+ }, true);
302
+ field.addEventListener('input', () => {
303
+ if (!field.value.includes(',')) return;
304
+ const parts = field.value.split(',');
305
+ const rest = parts.pop();
306
+ parts.forEach(commit);
307
+ field.value = rest.trimStart();
308
+ if (!field.value) box.close();
309
+ });
310
+ // Later than Pillbox's own blur (200ms), so a click on a suggestion
311
+ // wins over the half-typed word it was chosen to replace.
312
+ field.addEventListener('blur', () => setTimeout(() => {
313
+ if (document.activeElement === field || !field.value.trim()) return;
314
+ commit(field.value);
315
+ field.value = '';
316
+ }, 250));
317
+ }
318
+
319
+ return {
320
+ pending: () => (creatable ? field.value.trim() : ''),
321
+ destroy: () => box.destroy?.()
322
+ };
323
+ }
324
+
158
325
  export const postEditorView = {
159
326
  templateUrl: '/plugins/blog/admin/templates/post-editor.html',
160
327
 
@@ -180,7 +347,6 @@ export const postEditorView = {
180
347
 
181
348
  let post = null;
182
349
  let categories = [];
183
- let selectedCategoryIds = [];
184
350
  let featuredImageUrl = '';
185
351
 
186
352
  try {
@@ -210,12 +376,11 @@ export const postEditorView = {
210
376
  const excerptEl = $container.find('#post-excerpt').get(0);
211
377
  const contentEl = $container.find('#post-content').get(0);
212
378
  const tagsEl = $container.find('#post-tags').get(0);
379
+ const catsEl = $container.find('#post-categories').get(0);
213
380
  const seoTitleEl = $container.find('#seo-title').get(0);
214
381
  const seoDescEl = $container.find('#seo-description').get(0);
215
- const statusBadge = $container.find('#post-status-badge').get(0);
216
382
  const scheduleEl = $container.find('#schedule-picker').get(0);
217
383
  const schedAtEl = $container.find('#post-scheduled-at').get(0);
218
- const catsListEl = $container.find('#categories-checklist').get(0);
219
384
  const imagePreviewEl = $container.find('#featured-image-preview').get(0);
220
385
  const clearImageBtn = $container.find('#btn-clear-image').get(0);
221
386
 
@@ -226,11 +391,8 @@ export const postEditorView = {
226
391
  if (postSlugEl) postSlugEl.value = post.slug ?? '';
227
392
  if (excerptEl) excerptEl.value = post.excerpt ?? '';
228
393
  if (contentEl) contentEl.value = post.content ?? '';
229
- if (tagsEl) tagsEl.value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags ?? '');
230
394
  if (seoTitleEl) seoTitleEl.value = post.seo?.title ?? '';
231
395
  if (seoDescEl) seoDescEl.value = post.seo?.description ?? '';
232
- if (statusBadge) statusBadge.textContent = post.status ?? 'draft';
233
- selectedCategoryIds = Array.isArray(post.categories) ? [...post.categories] : [];
234
396
  featuredImageUrl = post.featuredImage ?? '';
235
397
  renderImagePreview();
236
398
  } else {
@@ -252,39 +414,51 @@ export const postEditorView = {
252
414
  }
253
415
  }
254
416
 
255
- if (catsListEl) {
256
- while (catsListEl.firstChild) catsListEl.removeChild(catsListEl.firstChild);
257
- if (categories.length === 0) {
258
- const p = document.createElement('p');
259
- p.className = 'text-muted text-sm';
260
- p.textContent = 'No categories yet.';
261
- catsListEl.appendChild(p);
262
- } else {
263
- categories.forEach((cat) => {
264
- const label = document.createElement('label');
265
- label.className = 'flex items-center gap-2 mb-2 blog-clickable';
266
-
267
- const cb = document.createElement('input');
268
- cb.type = 'checkbox';
269
- cb.value = cat.id;
270
- cb.checked = selectedCategoryIds.includes(cat.id);
271
- cb.addEventListener('change', () => {
272
- if (cb.checked) {
273
- if (!selectedCategoryIds.includes(cat.id)) selectedCategoryIds.push(cat.id);
274
- } else {
275
- selectedCategoryIds = selectedCategoryIds.filter((x) => x !== cat.id);
276
- }
277
- });
278
-
279
- const span = document.createElement('span');
280
- span.textContent = cat.name ?? cat.slug ?? cat.id;
281
-
282
- label.appendChild(cb);
283
- label.appendChild(span);
284
- catsListEl.appendChild(label);
285
- });
417
+ // ---- Status, categories and tags (domma-reactive) ----
418
+ const disposers = [];
419
+ const stamps = mountStatusCard($container.find('#status-card').get(0));
420
+ stamps.set(post);
421
+ disposers.push(() => stamps.dispose());
422
+
423
+ // A category deleted since the post was saved has no name to show; it
424
+ // is kept as it was rather than silently dropped on the next save.
425
+ const knownIds = new Set(categories.map(c => c.id));
426
+ const postCats = Array.isArray(post?.categories) ? post.categories : [];
427
+ const unknownCats = postCats.filter(cid => !knownIds.has(cid));
428
+ const selectedCats = M.observable([]);
429
+ const cats = catsEl ? mountPills(catsEl, {
430
+ data: categories.map(c => ({value: c.id, label: c.name ?? c.slug ?? c.id})),
431
+ value: postCats.filter(cid => knownIds.has(cid)),
432
+ placeholder: categories.length ? 'Type or click to choose…' : 'No categories yet',
433
+ empty: 'No category by that name',
434
+ selected: selectedCats
435
+ }) : null;
436
+ if (!categories.length) $container.find('#categories-note').get(0)?.classList.remove('blog-hidden');
437
+ disposers.push(() => cats?.destroy());
438
+
439
+ // Suggestions: every tag already on a post, most used first.
440
+ let tagChoices = [];
441
+ try {
442
+ const res = await H.get(`${BASE}/posts`);
443
+ const all = res?.data ?? (Array.isArray(res) ? res : []);
444
+ const counts = new Map();
445
+ for (const p of all) for (const t of (Array.isArray(p.tags) ? p.tags : [])) {
446
+ const k = String(t).trim();
447
+ if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
286
448
  }
287
- }
449
+ tagChoices = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => ({value: t, label: t}));
450
+ } catch { /* free text still works */ }
451
+ const postTags = Array.isArray(post?.tags) ? post.tags : String(post?.tags ?? '').split(',').map(t => t.trim()).filter(Boolean);
452
+ const selectedTags = M.observable([]);
453
+ const tags = tagsEl ? mountPills(tagsEl, {
454
+ data: tagChoices,
455
+ value: postTags,
456
+ creatable: true,
457
+ placeholder: 'Add a tag…',
458
+ empty: 'New tag - press Enter to add it',
459
+ selected: selectedTags
460
+ }) : null;
461
+ disposers.push(() => tags?.destroy());
288
462
 
289
463
  const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
290
464
  current: post?.template ?? '',
@@ -298,8 +472,9 @@ export const postEditorView = {
298
472
  slug: postSlugEl?.value?.trim() ?? '',
299
473
  excerpt: excerptEl?.value?.trim() ?? '',
300
474
  content: contentEl?.value ?? '',
301
- tags: (tagsEl?.value ?? '').split(',').map((t) => t.trim()).filter(Boolean),
302
- categories: [...selectedCategoryIds],
475
+ // Text still in the tag box counts: Ctrl+S from inside it saves it.
476
+ tags: [...new Set([...selectedTags.peek(), ...(tags?.pending() ? [tags.pending()] : [])])],
477
+ categories: [...selectedCats.peek(), ...unknownCats],
303
478
  featuredImage: featuredImageUrl || null,
304
479
  seo: {
305
480
  title: seoTitleEl?.value?.trim() ?? '',
@@ -331,6 +506,7 @@ export const postEditorView = {
331
506
  if (titleEl) titleEl.textContent = 'Editing post';
332
507
  }
333
508
  post = saved;
509
+ stamps.set(saved);
334
510
  baseline = JSON.stringify(collectValues());
335
511
  setStatus(saved.status ?? status);
336
512
  showSaved();
@@ -363,10 +539,7 @@ export const postEditorView = {
363
539
  }
364
540
  function setStatus(next) {
365
541
  status = next || 'draft';
366
- if (statusBadge) {
367
- statusBadge.textContent = status;
368
- statusBadge.className = `badge ${({ published: 'badge-success', scheduled: 'badge-warning' })[status] ?? 'badge-secondary'}`;
369
- }
542
+ stamps.status.value = status;
370
543
  const live = status === 'published';
371
544
  $container.find('#btn-publish').get(0)?.classList.toggle('blog-hidden', live);
372
545
  $container.find('#btn-schedule').get(0)?.classList.toggle('blog-hidden', live);
@@ -382,6 +555,9 @@ export const postEditorView = {
382
555
  queueMicrotask(() => { queued = false; refreshState(); });
383
556
  };
384
557
  ['input', 'change', 'click'].forEach(ev => $container.get(0).addEventListener(ev, recheck));
558
+ // A pill made with Enter fires none of those.
559
+ const stopPillWatch = M.effect(() => { selectedCats.value; selectedTags.value; recheck(); });
560
+ disposers.push(() => stopPillWatch?.());
385
561
 
386
562
  async function save() {
387
563
  if (await doSave()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', { type: 'success' });
@@ -402,7 +578,11 @@ export const postEditorView = {
402
578
 
403
579
  // Ctrl/Cmd+S saves; a closing tab with unsaved work asks first. Both
404
580
  // come off when the hash leaves this editor (the router has no unmount).
581
+ // The admin's own Ctrl+S (app.js) clicks the view's primary Save and
582
+ // marks the event handled; saving again here made two requests - and
583
+ // on a new post, two posts. This one is for an admin without it.
405
584
  const onKey = (e) => {
585
+ if (e.defaultPrevented) return;
406
586
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
407
587
  e.preventDefault();
408
588
  save();
@@ -418,6 +598,7 @@ export const postEditorView = {
418
598
  document.removeEventListener('keydown', onKey);
419
599
  window.removeEventListener('beforeunload', onUnload);
420
600
  window.removeEventListener('hashchange', onHash);
601
+ disposers.splice(0).forEach(fn => { try { fn(); } catch { /* already gone */ } });
421
602
  };
422
603
  window.addEventListener('hashchange', onHash);
423
604
 
@@ -460,6 +641,7 @@ export const postEditorView = {
460
641
  const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
461
642
  if (res.error) throw new Error(res.error);
462
643
  post = res.data ?? res;
644
+ stamps.set(post);
463
645
  setStatus('published');
464
646
  E.toast('Published - the post is live.', { type: 'success' });
465
647
  } catch (err) {
@@ -489,6 +671,7 @@ export const postEditorView = {
489
671
  const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
490
672
  if (res.error) throw new Error(res.error);
491
673
  post = res.data ?? res;
674
+ stamps.set(post);
492
675
  setStatus('scheduled');
493
676
  if (scheduleEl) scheduleEl.classList.add('blog-hidden');
494
677
  E.toast(`Scheduled for ${new Date(publishedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}.`, { type: 'success' });
@@ -0,0 +1,29 @@
1
+ .pe { --pe-serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif; max-width: 40rem; margin: 0 auto; }
2
+ .pe-head { text-align: center; margin: 2rem 0 2.5rem; }
3
+ .pe-kicker { display: flex; justify-content: center; gap: .4rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
4
+ .pe-title { font-family: var(--pe-serif); font-weight: 400; font-size: clamp(2.4rem, 6vw, 4rem); line-height: 1.04; margin: 0 0 1.1rem; letter-spacing: -.01em; }
5
+ .pe-standfirst { font-family: var(--pe-serif); font-style: italic; font-size: 1.3rem; line-height: 1.5; color: var(--dm-text-muted); margin: 0 auto 1.5rem; max-width: 34rem; }
6
+ .pe-standfirst:empty { display: none; }
7
+ .pe-byline { display: inline-block; margin: 0; padding: .6rem 1.2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.3)); border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.3));
8
+ font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; color: var(--dm-text-muted); }
9
+ .pe-byline a { color: var(--dm-text); font-weight: 600; }
10
+ .pe-figure { margin: 0 -4rem 3rem; }
11
+ .pe-figure img { width: 100%; display: block; border-radius: var(--dm-radius, 6px); }
12
+ .pe-figure.no-image { display: none; }
13
+ .pe-body { font-family: var(--pe-serif); font-size: 1.2rem; line-height: 1.75; }
14
+ .pe-body > p:first-of-type::first-letter { float: left; font-size: 4.4em; line-height: .8; padding: .06em .1em 0 0; color: var(--dm-primary); }
15
+ .pe-body > p:last-of-type::after { content: "\220E"; margin-left: .35em; color: var(--dm-primary); }
16
+ .pe-body h2, .pe-body h3 { font-family: var(--pe-serif); font-weight: 400; text-align: center; margin: 2.5rem 0 1rem; }
17
+ .pe-body h2 { font-size: 1.7rem; }
18
+ .pe-body blockquote { margin: 2.5rem -1.5rem; padding: 1.25rem 0; border: 0; border-top: 2px solid var(--dm-primary); border-bottom: 2px solid var(--dm-primary);
19
+ background: none; text-align: center; font-size: 1.55rem; line-height: 1.4; font-style: italic; color: var(--dm-text); }
20
+ .pe-body blockquote p { margin: 0; }
21
+ .pe-body hr { border: 0; margin: 2.5rem 0; text-align: center; }
22
+ .pe-body hr::before { content: "\2022\2003\2022\2003\2022"; color: var(--dm-text-muted); }
23
+ .pe-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
24
+ margin: 3rem 0 0; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
25
+ .pe-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
26
+ @media (max-width: 900px) {
27
+ .pe-figure { margin-left: 0; margin-right: 0; }
28
+ .pe-body blockquote { margin-left: 0; margin-right: 0; }
29
+ }
@@ -0,0 +1,28 @@
1
+ <!--
2
+ blog-post-essay - a long read set like a printed essay: a serif reading column,
3
+ a drop cap, quotations as pull quotes and an end mark. For the pieces people
4
+ sit down with.
5
+
6
+ {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
7
+ {{publishedAt:D MMMM YYYY}} any date format
8
+ {{imageUrl}} {{imageClass}} (has-image | no-image)
9
+ Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
10
+ {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
11
+ -->
12
+ <article class="pe">
13
+ <header class="pe-head">
14
+ <div class="pe-kicker">{{{categories}}}</div>
15
+ <h1 class="pe-title">{{title}}</h1>
16
+ <p class="pe-standfirst">{{excerpt}}</p>
17
+ <p class="pe-byline">By <a href="{{authorUrl}}">{{authorName}}</a> <span aria-hidden="true">/</span> <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> <span aria-hidden="true">/</span> {{readingTime}}</p>
18
+ </header>
19
+ <figure class="pe-figure {{imageClass}}">{{{image}}}</figure>
20
+ <div class="pe-body blog-content">{{{content}}}</div>
21
+ <footer class="pe-foot">
22
+ <div class="pe-tags">{{{tags}}}</div>
23
+ {{{share}}}
24
+ </footer>
25
+ {{{prevNext}}}
26
+ {{{related}}}
27
+ {{{comments}}}
28
+ </article>
@@ -0,0 +1,22 @@
1
+ .pt-head { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem);
2
+ align-items: center; margin: 1rem 0 3rem; }
3
+ .pt-picture { aspect-ratio: 4 / 5; max-height: 36rem; width: 100%; border-radius: var(--dm-radius-lg, 12px);
4
+ background: var(--pt-image) center / cover no-repeat, var(--dm-primary, #334155); }
5
+ .pt-head.no-image .pt-picture { aspect-ratio: 4 / 3;
6
+ background: radial-gradient(circle at 30% 25%, color-mix(in srgb, var(--dm-primary, #334155) 55%, #fff), transparent 60%),
7
+ linear-gradient(135deg, var(--dm-primary, #334155), color-mix(in srgb, var(--dm-primary, #334155) 50%, #000)); }
8
+ .pt-kicker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: 1rem; }
9
+ .pt-title { font-size: clamp(2.1rem, 4.4vw, 3.4rem); line-height: 1.06; margin: 0 0 1rem; letter-spacing: -.02em; }
10
+ .pt-standfirst { font-size: 1.2rem; line-height: 1.55; color: var(--dm-text-muted); margin: 0 0 1.5rem; }
11
+ .pt-standfirst:empty { display: none; }
12
+ .pt-meta { margin: 0; padding-left: .9rem; border-left: 3px solid var(--dm-primary, #334155); font-size: .92rem; line-height: 1.6; color: var(--dm-text-muted); }
13
+ .pt-meta a { color: var(--dm-text); font-weight: 600; }
14
+ .pt-wrap { max-width: 44rem; margin: 0 auto; }
15
+ .pt-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
16
+ margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
17
+ .pt-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
18
+ @media (max-width: 800px) {
19
+ .pt-head { grid-template-columns: 1fr; gap: 1.5rem; }
20
+ .pt-picture, .pt-head.no-image .pt-picture { aspect-ratio: 16 / 9; }
21
+ .pt-head.no-image .pt-picture { display: none; }
22
+ }
@@ -0,0 +1,32 @@
1
+ <!--
2
+ blog-post-split - the picture on one half, the headline on the other, then the
3
+ story in a centred column. Without a featured image the picture half becomes a
4
+ panel in the theme's primary colour.
5
+
6
+ {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
7
+ {{publishedAt:D MMMM YYYY}} any date format
8
+ {{imageUrl}} {{imageClass}} (has-image | no-image)
9
+ Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
10
+ {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
11
+ -->
12
+ <article class="pt">
13
+ <header class="pt-head {{imageClass}}">
14
+ <div class="pt-picture" style="--pt-image: url('{{imageUrl}}')" aria-hidden="true"></div>
15
+ <div class="pt-words">
16
+ <div class="pt-kicker">{{{categories}}}</div>
17
+ <h1 class="pt-title">{{title}}</h1>
18
+ <p class="pt-standfirst">{{excerpt}}</p>
19
+ <p class="pt-meta"><a href="{{authorUrl}}">{{authorName}}</a><br><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
20
+ </div>
21
+ </header>
22
+ <div class="pt-wrap">
23
+ <div class="pt-body blog-content">{{{content}}}</div>
24
+ <footer class="pt-foot">
25
+ <div class="pt-tags">{{{tags}}}</div>
26
+ {{{share}}}
27
+ </footer>
28
+ {{{prevNext}}}
29
+ {{{related}}}
30
+ {{{comments}}}
31
+ </div>
32
+ </article>
@@ -4,7 +4,7 @@
4
4
  * A post page and every listing card are BLOCKS (content/blocks/*.html), so a
5
5
  * site restyles its blog in the Blocks tool rather than in this plugin:
6
6
  *
7
- * blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar…)
7
+ * blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar, Split, Essay…)
8
8
  * blog-card-<name> one post in a listing (Cards, List, Compact…)
9
9
  *
10
10
  * Any block a site adds with one of those prefixes becomes a further choice -
@@ -19,7 +19,9 @@ export const POST_LAYOUTS = [
19
19
  {id: 'blog-post-classic', label: 'Classic', description: 'Title, image, then the story in one centred column.'},
20
20
  {id: 'blog-post-feature', label: 'Feature', description: 'A full-width hero image with the title over it - for the big pieces.'},
21
21
  {id: 'blog-post-minimal', label: 'Minimal', description: 'Just the words: a narrow reading column, no image.'},
22
- {id: 'blog-post-sidebar', label: 'Sidebar', description: 'The story beside a sticky contents list, details and share links.'}
22
+ {id: 'blog-post-sidebar', label: 'Sidebar', description: 'The story beside a sticky contents list, details and share links.'},
23
+ {id: 'blog-post-split', label: 'Split', description: 'The picture on one half, the headline on the other - a cover for the story.'},
24
+ {id: 'blog-post-essay', label: 'Essay', description: 'A long read set like print: serif type, a drop cap and pull quotes.'}
23
25
  ];
24
26
 
25
27
  /**
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The navbar link to the blog: added when the plugin is enabled, taken away
3
+ * again when it is disabled - but only if this plugin put it there.
4
+ *
5
+ * The Menus editor rewrites items with the keys it knows and drops the rest,
6
+ * so a marker on the item itself would not survive the site's first edit.
7
+ * What was added is remembered in the plugin's own state instead
8
+ * (`navLink: {menu, url}` in config/plugins.json). A link the site already had
9
+ * - or makes itself - is never removed.
10
+ *
11
+ * @module blog/lib/nav-link
12
+ */
13
+ import * as menuService from '../../../server/services/menus.js';
14
+
15
+ const trim = (url) => String(url ?? '').trim().replace(/\/+$/, '') || '/';
16
+
17
+ /** True when any item, at any depth, already links to `url`. */
18
+ export function hasLink(items, url) {
19
+ const want = trim(url);
20
+ return (items || []).some(it => (it && typeof it.url === 'string' && trim(it.url) === want) || hasLink(it?.items, url));
21
+ }
22
+
23
+ /**
24
+ * The item to add. It carries an icon only when most of the navbar's other
25
+ * links do, so it looks like the rest of the menu either way.
26
+ */
27
+ export function blogItem(items, url) {
28
+ const links = (items || []).filter(it => it && it.type !== 'separator' && it.type !== 'spacer');
29
+ const withIcons = links.filter(it => it.icon).length;
30
+ return {text: 'Blog', url: trim(url), ...(links.length && withIcons * 2 >= links.length && {icon: 'file-text'})};
31
+ }
32
+
33
+ /** Top-level items without the one linking to `url`. Nested copies are the site's own. */
34
+ export function withoutLink(items, url) {
35
+ const want = trim(url);
36
+ return (items || []).filter(it => !(it && typeof it.url === 'string' && trim(it.url) === want));
37
+ }
38
+
39
+ /**
40
+ * Add the blog to the menu in the `navbar` slot, unless it already links there.
41
+ *
42
+ * @param {string} url - the blog's base path
43
+ * @param {object} [menus] - the menus service (a test passes a fake)
44
+ * @returns {Promise<{menu: string, url: string}|null>} what was added, or null
45
+ */
46
+ export async function addNavLink(url, menus = menuService) {
47
+ const slug = (await menus.getLocations())?.navbar;
48
+ if (!slug) return null;
49
+ const menu = await menus.getMenu(slug);
50
+ if (!menu || hasLink(menu.items, url)) return null;
51
+ const items = Array.isArray(menu.items) ? menu.items : [];
52
+ await menus.updateMenu(slug, {...menu, items: [...items, blogItem(items, url)]});
53
+ return {menu: slug, url: trim(url)};
54
+ }
55
+
56
+ /**
57
+ * Take away a link this plugin added. Anything else in the menu is left alone.
58
+ *
59
+ * @param {{menu: string, url: string}|null} added - from addNavLink()
60
+ * @param {object} [menus]
61
+ * @returns {Promise<boolean>} whether a link was removed
62
+ */
63
+ export async function removeNavLink(added, menus = menuService) {
64
+ if (!added?.menu || !added?.url) return false;
65
+ const menu = await menus.getMenu(added.menu);
66
+ if (!menu) return false;
67
+ const items = Array.isArray(menu.items) ? menu.items : [];
68
+ const kept = withoutLink(items, added.url);
69
+ if (kept.length === items.length) return false;
70
+ await menus.updateMenu(added.menu, {...menu, items: kept});
71
+ return true;
72
+ }
@@ -6,6 +6,7 @@ import {renderBlogPage} from '../../server/services/renderer.js';
6
6
  import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
8
  import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
9
+ import {addNavLink, removeNavLink} from './lib/nav-link.js';
9
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
10
11
 
11
12
  const POSTS_SLUG = 'blog-posts';
@@ -295,7 +296,7 @@ export default async function blogPlugin(fastify, options) {
295
296
  });
296
297
  const html = await renderBlogPage(
297
298
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
298
- { post: page.html, assetV: '1.4.4' },
299
+ { post: page.html, assetV: '1.6.0' },
299
300
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
300
301
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
301
302
  );
@@ -716,3 +717,41 @@ export default async function blogPlugin(fastify, options) {
716
717
  return reply.send({ ok: true, settings: merged });
717
718
  });
718
719
  }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Lifecycle: the navbar link
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /** The blog's base path from its saved settings, as the routes will use it. */
726
+ function currentBasePath() {
727
+ const saved = getPluginStates()?.blog?.settings ?? {};
728
+ return String(saved.basePath ?? defaultConfig.basePath ?? '/blog').replace(/\/+$/, '') || '/blog';
729
+ }
730
+
731
+ /**
732
+ * Enabling the blog puts a Blog link in the navbar, so the listing is not a
733
+ * page nobody can reach. Skipped when the navbar already links there; what
734
+ * was added is remembered so disabling can take it away again (lib/nav-link.js).
735
+ */
736
+ export async function onEnable({fastify}) {
737
+ try {
738
+ const added = await addNavLink(currentBasePath());
739
+ if (added) {
740
+ savePluginState('blog', {navLink: added});
741
+ fastify?.log?.info(`[blog] added a Blog link to the "${added.menu}" menu`);
742
+ }
743
+ } catch (err) {
744
+ fastify?.log?.warn(`[blog] could not add the navbar link: ${err.message}`);
745
+ }
746
+ }
747
+
748
+ /** Disabling takes the link away - only the one enabling added, never the site's own. */
749
+ export async function onDisable({fastify}) {
750
+ try {
751
+ const added = getPluginStates()?.blog?.navLink ?? null;
752
+ if (await removeNavLink(added)) fastify?.log?.info(`[blog] removed the Blog link from the "${added.menu}" menu`);
753
+ if (added) savePluginState('blog', {navLink: null});
754
+ } catch (err) {
755
+ fastify?.log?.warn(`[blog] could not remove the navbar link: ${err.message}`);
756
+ }
757
+ }