domma-cms 0.66.0 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -0,0 +1,16 @@
1
+ <!--
2
+ blog-card - one post in a Blog listing (the Cards and Magazine displays).
3
+ Edit it here to restyle every card; copy it as blog-card-<name> to add a display.
4
+
5
+ {{title}} {{url}} {{excerpt}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
6
+ {{publishedAt:D MMM YYYY}} any date format
7
+ {{imageUrl}} {{imageClass}} (has-image | no-image) {{category}} (the first one)
8
+ {{{image}}} {{{categories}}} {{{tags}}} ready-made markup
9
+ -->
10
+ <a class="bc-media {{imageClass}}" href="{{url}}" tabindex="-1" aria-hidden="true">{{{image}}}</a>
11
+ <div class="bc-body">
12
+ <div class="bc-kicker">{{{categories}}}</div>
13
+ <h2 class="bc-title"><a href="{{url}}">{{title}}</a></h2>
14
+ <p class="bc-excerpt">{{excerpt}}</p>
15
+ <p class="bc-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMM YYYY}}</time> · {{readingTime}}</p>
16
+ </div>
@@ -0,0 +1,15 @@
1
+ .pc { max-width: 46rem; margin: 0 auto; }
2
+ .pc-head { text-align: center; margin: 1rem 0 1.75rem; }
3
+ .pc-kicker { display: flex; justify-content: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .75rem; }
4
+ .pc-title { font-size: clamp(2rem, 4.5vw, 2.9rem); line-height: 1.12; margin: 0 0 .75rem; letter-spacing: -.015em; }
5
+ .pc-standfirst { font-size: 1.2rem; color: var(--dm-text-muted); margin: 0 auto .9rem; max-width: 38rem; line-height: 1.5; }
6
+ .pc-standfirst:empty { display: none; }
7
+ .pc-meta { color: var(--dm-text-muted); font-size: .9rem; margin: 0; }
8
+ .pc-meta a { color: inherit; font-weight: 600; }
9
+ .pc-figure { margin: 0 -2rem 2rem; border-radius: var(--dm-radius-lg, 12px); overflow: hidden; }
10
+ .pc-figure img { width: 100%; display: block; }
11
+ .pc-figure.no-image { display: none; }
12
+ .pc-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
13
+ margin: 2.5rem 0 0; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
14
+ .pc-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
15
+ @media (max-width: 900px) { .pc-figure { margin-left: 0; margin-right: 0; } }
@@ -0,0 +1,28 @@
1
+ <!--
2
+ blog-post-classic - title, image, then the story in one centred column.
3
+ Edit it to restyle every post that uses Classic; copy it as blog-post-<name>
4
+ to add a layout authors can choose.
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="pc">
13
+ <header class="pc-head">
14
+ <div class="pc-kicker">{{{categories}}}</div>
15
+ <h1 class="pc-title">{{title}}</h1>
16
+ <p class="pc-standfirst">{{excerpt}}</p>
17
+ <p class="pc-meta">By <a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
18
+ </header>
19
+ <figure class="pc-figure {{imageClass}}">{{{image}}}</figure>
20
+ <div class="pc-body blog-content">{{{content}}}</div>
21
+ <footer class="pc-foot">
22
+ <div class="pc-tags">{{{tags}}}</div>
23
+ {{{share}}}
24
+ </footer>
25
+ {{{prevNext}}}
26
+ {{{related}}}
27
+ {{{comments}}}
28
+ </article>
@@ -0,0 +1,20 @@
1
+ .pf-hero { position: relative; min-height: min(72vh, 40rem); display: flex; align-items: flex-end;
2
+ margin: 0 calc(50% - 50vw) 2.5rem; padding: 0 calc(50vw - 50%);
3
+ background: var(--pf-image) center / cover no-repeat, var(--dm-primary, #334155); color: #fff; }
4
+ .pf-hero.no-image { min-height: 22rem;
5
+ background: linear-gradient(135deg, var(--dm-primary, #334155), color-mix(in srgb, var(--dm-primary, #334155) 55%, #000)); }
6
+ .pf-hero::before { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0) 25%, rgba(0,0,0,.72) 100%); }
7
+ .pf-hero-inner { position: relative; width: 100%; max-width: 52rem; margin: 0 auto; padding: 3rem 1.25rem 2.5rem; }
8
+ .pf-kicker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .9rem; }
9
+ .pf-kicker .blog-chip { background: rgba(255,255,255,.18); color: #fff; border-color: rgba(255,255,255,.35); }
10
+ .pf-title { font-size: clamp(2.2rem, 5.5vw, 3.8rem); line-height: 1.05; margin: 0 0 .9rem; color: #fff; letter-spacing: -.02em;
11
+ text-shadow: 0 2px 18px rgba(0,0,0,.35); }
12
+ .pf-standfirst { font-size: 1.25rem; line-height: 1.5; margin: 0 0 1rem; max-width: 40rem; opacity: .92; }
13
+ .pf-standfirst:empty { display: none; }
14
+ .pf-meta { margin: 0; font-size: .92rem; opacity: .85; }
15
+ .pf-meta a { color: inherit; font-weight: 600; }
16
+ .pf-wrap { max-width: 44rem; margin: 0 auto; }
17
+ .pf-body > p:first-of-type::first-letter { float: left; font-size: 3.6em; line-height: .85; padding: .08em .12em 0 0; font-weight: 700; color: var(--dm-primary); }
18
+ .pf-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
19
+ margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
20
+ .pf-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
@@ -0,0 +1,30 @@
1
+ <!--
2
+ blog-post-feature - a full-width hero image with the title over it.
3
+ Without a featured image the hero falls back to the theme's primary colour.
4
+
5
+ {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
6
+ {{publishedAt:D MMMM YYYY}} any date format
7
+ {{imageUrl}} {{imageClass}} (has-image | no-image)
8
+ Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
9
+ {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
10
+ -->
11
+ <article class="pf">
12
+ <header class="pf-hero {{imageClass}}" style="--pf-image: url('{{imageUrl}}')">
13
+ <div class="pf-hero-inner">
14
+ <div class="pf-kicker">{{{categories}}}</div>
15
+ <h1 class="pf-title">{{title}}</h1>
16
+ <p class="pf-standfirst">{{excerpt}}</p>
17
+ <p class="pf-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
18
+ </div>
19
+ </header>
20
+ <div class="pf-wrap">
21
+ <div class="pf-body blog-content">{{{content}}}</div>
22
+ <footer class="pf-foot">
23
+ <div class="pf-tags">{{{tags}}}</div>
24
+ {{{share}}}
25
+ </footer>
26
+ {{{prevNext}}}
27
+ {{{related}}}
28
+ {{{comments}}}
29
+ </div>
30
+ </article>
@@ -0,0 +1,10 @@
1
+ .pm { max-width: 38rem; margin: 0 auto; }
2
+ .pm-head { margin: 2.5rem 0 2.25rem; }
3
+ .pm-meta { margin: 0 0 .6rem; font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; color: var(--dm-text-muted); }
4
+ .pm-title { font-size: clamp(1.9rem, 4vw, 2.5rem); line-height: 1.15; margin: 0 0 .6rem; font-weight: 700; }
5
+ .pm-by { margin: 0; font-style: italic; color: var(--dm-text-muted); }
6
+ .pm-by a { color: inherit; }
7
+ .pm-body { font-size: 1.13rem; line-height: 1.85; }
8
+ .pm-foot { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25));
9
+ display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
10
+ .pm-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
@@ -0,0 +1,23 @@
1
+ <!--
2
+ blog-post-minimal - just the words: a narrow reading column, no image.
3
+
4
+ {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
5
+ {{publishedAt:D MMMM YYYY}} any date format
6
+ {{imageUrl}} {{imageClass}} (has-image | no-image)
7
+ Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
8
+ {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
9
+ -->
10
+ <article class="pm">
11
+ <header class="pm-head">
12
+ <p class="pm-meta"><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
13
+ <h1 class="pm-title">{{title}}</h1>
14
+ <p class="pm-by">by <a href="{{authorUrl}}">{{authorName}}</a></p>
15
+ </header>
16
+ <div class="pm-body blog-content">{{{content}}}</div>
17
+ <footer class="pm-foot">
18
+ <div class="pm-tags">{{{categories}}}{{{tags}}}</div>
19
+ {{{share}}}
20
+ </footer>
21
+ {{{prevNext}}}
22
+ {{{comments}}}
23
+ </article>
@@ -0,0 +1,22 @@
1
+ .ps-head { max-width: 50rem; margin: 1rem 0 2rem; }
2
+ .ps-kicker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .75rem; }
3
+ .ps-title { font-size: clamp(2rem, 4.2vw, 2.8rem); line-height: 1.12; margin: 0 0 .7rem; letter-spacing: -.015em; }
4
+ .ps-standfirst { font-size: 1.15rem; color: var(--dm-text-muted); margin: 0; line-height: 1.55; }
5
+ .ps-standfirst:empty { display: none; }
6
+ .ps-grid { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; gap: 3rem; align-items: start; }
7
+ .ps-figure { margin: 0 0 2rem; border-radius: var(--dm-radius-lg, 12px); overflow: hidden; }
8
+ .ps-figure img { width: 100%; display: block; }
9
+ .ps-figure.no-image { display: none; }
10
+ .ps-aside { align-self: stretch; display: flex; flex-direction: column; gap: 1.25rem; }
11
+ .ps-aside .blog-toc { position: sticky; top: calc(var(--dm-navbar-height, 64px) + 1.25rem); max-height: calc(100vh - var(--dm-navbar-height, 64px) - 2.5rem); overflow-y: auto; }
12
+ .ps-card { padding: 1.1rem 1.2rem; border: 1px solid var(--dm-border, rgba(127,127,127,.25)); border-radius: var(--dm-radius-lg, 12px); font-size: .9rem; }
13
+ .ps-card p { margin: 0 0 .7rem; }
14
+ .ps-label { font-size: .72rem !important; letter-spacing: .06em; text-transform: uppercase; color: var(--dm-text-muted); margin-bottom: .15rem !important; }
15
+ .ps-author a { font-weight: 600; color: inherit; }
16
+ .ps-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin: .25rem 0 .9rem; }
17
+ .ps-tags:empty { display: none; }
18
+ @media (max-width: 960px) {
19
+ .ps-grid { grid-template-columns: 1fr; gap: 2rem; }
20
+ .ps-aside { order: -1; }
21
+ .ps-aside .blog-toc { display: none; }
22
+ }
@@ -0,0 +1,36 @@
1
+ <!--
2
+ blog-post-sidebar - the story beside a sticky contents list, details and share links.
3
+
4
+ {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
5
+ {{publishedAt:D MMMM YYYY}} any date format
6
+ {{imageUrl}} {{imageClass}} (has-image | no-image)
7
+ Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
8
+ {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
9
+ -->
10
+ <article class="ps">
11
+ <header class="ps-head">
12
+ <div class="ps-kicker">{{{categories}}}</div>
13
+ <h1 class="ps-title">{{title}}</h1>
14
+ <p class="ps-standfirst">{{excerpt}}</p>
15
+ </header>
16
+ <div class="ps-grid">
17
+ <div class="ps-main">
18
+ <figure class="ps-figure {{imageClass}}">{{{image}}}</figure>
19
+ <div class="ps-body blog-content">{{{content}}}</div>
20
+ {{{prevNext}}}
21
+ {{{comments}}}
22
+ </div>
23
+ <aside class="ps-aside">
24
+ <div class="ps-card">
25
+ <p class="ps-label">Written by</p>
26
+ <p class="ps-author"><a href="{{authorUrl}}">{{authorName}}</a></p>
27
+ <p class="ps-label">Published</p>
28
+ <p><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
29
+ <div class="ps-tags">{{{tags}}}</div>
30
+ {{{share}}}
31
+ </div>
32
+ {{{toc}}}
33
+ </aside>
34
+ </div>
35
+ {{{related}}}
36
+ </article>
@@ -15,6 +15,8 @@
15
15
  { "name": "authorId", "label": "Author", "type": "text", "required": true },
16
16
  { "name": "status", "label": "Status", "type": "select", "required": true, "options": ["draft", "scheduled", "published"] },
17
17
  { "name": "publishedAt", "label": "Published At", "type": "datetime", "required": false },
18
- { "name": "seo", "label": "SEO", "type": "object", "required": false }
18
+ { "name": "template", "label": "Layout", "type": "text", "required": false },
19
+ { "name": "seo", "label": "SEO", "type": "object", "required": false },
20
+ { "name": "sample", "label": "Sample post", "type": "boolean", "required": false }
19
21
  ]
20
22
  }
@@ -4,5 +4,11 @@ export default {
4
4
  commentsEnabled: false,
5
5
  commentModeration: true,
6
6
  feedLength: 20,
7
+ // Display - see lib/layouts.js. Read live, so no restart is needed.
8
+ postLayout: 'blog-post-classic',
9
+ listDisplay: 'cards',
10
+ displaySwitcher: true,
11
+ interactive: true,
12
+ relatedCount: 3,
7
13
  storage: { adapter: 'file' }
8
14
  };
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Blog layouts - the pure half of rendering posts through blocks.
3
+ *
4
+ * A post page and every listing card are BLOCKS (content/blocks/*.html), so a
5
+ * site restyles its blog in the Blocks tool rather than in this plugin:
6
+ *
7
+ * blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar…)
8
+ * blog-card-<name> one post in a listing (Cards, List, Compact…)
9
+ *
10
+ * Any block a site adds with one of those prefixes becomes a further choice -
11
+ * nothing here needs to know about it. Nothing in this file touches the disk,
12
+ * so all of it is testable on its own; lib/render.js does the I/O.
13
+ *
14
+ * @module blog/lib/layouts
15
+ */
16
+
17
+ /** The post layouts the plugin ships. Each is a block of the same name. */
18
+ export const POST_LAYOUTS = [
19
+ {id: 'blog-post-classic', label: 'Classic', description: 'Title, image, then the story in one centred column.'},
20
+ {id: 'blog-post-feature', label: 'Feature', description: 'A full-width hero image with the title over it - for the big pieces.'},
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.'}
23
+ ];
24
+
25
+ /**
26
+ * The listing displays the plugin ships. A display is a card block plus how the
27
+ * cards are arranged; Magazine reuses the Cards block and makes the first post
28
+ * the lead.
29
+ */
30
+ export const LIST_DISPLAYS = [
31
+ {id: 'cards', block: 'blog-card', arrange: 'grid', label: 'Cards', description: 'A grid of picture cards.'},
32
+ {id: 'list', block: 'blog-card-row', arrange: 'stack', label: 'List', description: 'One post per row, picture beside the text.'},
33
+ {id: 'magazine', block: 'blog-card', arrange: 'grid', label: 'Magazine', description: 'The newest post large across the top, the rest as cards.', lead: true},
34
+ {id: 'compact', block: 'blog-card-compact', arrange: 'stack', label: 'Compact', description: 'Titles and dates only - an archive at a glance.'}
35
+ ];
36
+
37
+ export const DEFAULT_POST_LAYOUT = 'blog-post-classic';
38
+ export const DEFAULT_DISPLAY = 'cards';
39
+
40
+ const BUILTIN_CARD_BLOCKS = new Set(LIST_DISPLAYS.map(d => d.block));
41
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
42
+
43
+ /** 'blog-post-long-read' -> 'Long read' */
44
+ function labelFrom(suffix) {
45
+ const s = suffix.replace(/-/g, ' ');
46
+ return s.charAt(0).toUpperCase() + s.slice(1);
47
+ }
48
+
49
+ /**
50
+ * Every post layout on offer: the shipped ones, then any other `blog-post-*`
51
+ * block the site has. A shipped layout whose block was deleted still counts -
52
+ * the plugin carries its own copy.
53
+ *
54
+ * @param {string[]} blockNames - names of the blocks on this site
55
+ * @returns {{id: string, label: string, description: string, custom: boolean}[]}
56
+ */
57
+ export function postLayouts(blockNames = []) {
58
+ const shipped = new Set(POST_LAYOUTS.map(l => l.id));
59
+ const extra = blockNames
60
+ .filter(n => NAME_RE.test(n) && n.startsWith('blog-post-') && !shipped.has(n))
61
+ .sort()
62
+ .map(n => ({id: n, label: labelFrom(n.slice('blog-post-'.length)), description: 'Your own layout, from the Blocks tool.', custom: true}));
63
+ return [...POST_LAYOUTS.map(l => ({...l, custom: false})), ...extra];
64
+ }
65
+
66
+ /**
67
+ * Every listing display on offer: the shipped four, then one per extra
68
+ * `blog-card-*` block, arranged as a grid.
69
+ *
70
+ * @param {string[]} blockNames
71
+ * @returns {{id: string, block: string, arrange: string, label: string, description: string, lead?: boolean, custom: boolean}[]}
72
+ */
73
+ export function listDisplays(blockNames = []) {
74
+ const extra = blockNames
75
+ .filter(n => NAME_RE.test(n) && n.startsWith('blog-card-') && !BUILTIN_CARD_BLOCKS.has(n))
76
+ .sort()
77
+ .map(n => ({id: n, block: n, arrange: 'grid', label: labelFrom(n.slice('blog-card-'.length)), description: 'Your own card, from the Blocks tool.', custom: true}));
78
+ return [...LIST_DISPLAYS.map(d => ({...d, custom: false})), ...extra];
79
+ }
80
+
81
+ /** The layout a post renders with: its own choice if still on offer, else the site default. */
82
+ export function pickPostLayout(post, settings, layouts) {
83
+ const ids = new Set(layouts.map(l => l.id));
84
+ if (post?.template && ids.has(post.template)) return post.template;
85
+ if (settings?.postLayout && ids.has(settings.postLayout)) return settings.postLayout;
86
+ return DEFAULT_POST_LAYOUT;
87
+ }
88
+
89
+ /** The display a listing renders with: a valid request wins, then the setting, then Cards. */
90
+ export function pickDisplay(requested, settings, displays) {
91
+ const byId = new Map(displays.map(d => [d.id, d]));
92
+ return byId.get(requested) ?? byId.get(settings?.listDisplay) ?? byId.get(DEFAULT_DISPLAY);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Settings
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** The display settings and their defaults (the rest live in config.js). */
100
+ export const DISPLAY_DEFAULTS = {
101
+ postLayout: DEFAULT_POST_LAYOUT,
102
+ listDisplay: DEFAULT_DISPLAY,
103
+ displaySwitcher: true,
104
+ interactive: true,
105
+ relatedCount: 3
106
+ };
107
+
108
+ /**
109
+ * Whitelist the display settings from a request body. Only keys present are
110
+ * returned, so a PUT carrying one of them leaves the others as stored.
111
+ *
112
+ * @param {object} body
113
+ * @returns {object}
114
+ */
115
+ export function pickDisplaySettings(body = {}) {
116
+ const out = {};
117
+ if (typeof body.postLayout === 'string' && /^blog-post-[a-z0-9-]+$/.test(body.postLayout)) out.postLayout = body.postLayout;
118
+ if (typeof body.listDisplay === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(body.listDisplay)) out.listDisplay = body.listDisplay;
119
+ if (body.displaySwitcher !== undefined) out.displaySwitcher = Boolean(body.displaySwitcher);
120
+ if (body.interactive !== undefined) out.interactive = Boolean(body.interactive);
121
+ if (body.relatedCount !== undefined) {
122
+ const n = Number(body.relatedCount);
123
+ out.relatedCount = Number.isFinite(n) ? Math.max(0, Math.min(6, Math.round(n))) : DISPLAY_DEFAULTS.relatedCount;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Interpolation
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function escapeHtml(str) {
133
+ return String(str ?? '')
134
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
136
+ }
137
+
138
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
139
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
140
+
141
+ /**
142
+ * Moment-style date formatting, the same tokens block templates use elsewhere
143
+ * (`{{publishedAt:D MMM YYYY}}`). UTC, so a page renders the same wherever the
144
+ * server is.
145
+ *
146
+ * @returns {string|null} null when the value is not a date
147
+ */
148
+ export function formatDate(value, fmt) {
149
+ if (!value) return null;
150
+ const d = new Date(value);
151
+ if (Number.isNaN(d.getTime())) return null;
152
+ const pad = (n) => String(n).padStart(2, '0');
153
+ const t = {
154
+ YYYY: () => d.getUTCFullYear(), YY: () => pad(d.getUTCFullYear() % 100),
155
+ MMMM: () => MONTHS[d.getUTCMonth()], MMM: () => MONTHS[d.getUTCMonth()].slice(0, 3),
156
+ MM: () => pad(d.getUTCMonth() + 1), M: () => d.getUTCMonth() + 1,
157
+ DD: () => pad(d.getUTCDate()), D: () => d.getUTCDate(),
158
+ dddd: () => DAYS[d.getUTCDay()], ddd: () => DAYS[d.getUTCDay()].slice(0, 3),
159
+ HH: () => pad(d.getUTCHours()), mm: () => pad(d.getUTCMinutes())
160
+ };
161
+ return fmt.replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g, k => String(t[k]()));
162
+ }
163
+
164
+ /**
165
+ * Fill a block template. `{{{key}}}` is raw (for the fragments this plugin
166
+ * builds and escapes itself), `{{key}}` is escaped, `{{key:D MMM YYYY}}`
167
+ * formats a date. HTML comments go - a block's header documents its
168
+ * placeholders, and a listing of fifty cards need not repeat that fifty times.
169
+ *
170
+ * @param {string} tpl
171
+ * @param {object} vars
172
+ * @returns {string}
173
+ */
174
+ export function interpolate(tpl, vars) {
175
+ return String(tpl ?? '')
176
+ .replace(/<!--[\s\S]*?-->/g, '')
177
+ .replace(/\{\{\{(\w+)\}\}\}/g, (_, k) => String(vars[k] ?? ''))
178
+ .replace(/\{\{(\w+)(?::([^{}]+))?\}\}/g, (_, k, fmt) => {
179
+ const v = vars[k] ?? '';
180
+ if (fmt) {
181
+ const f = formatDate(v, fmt);
182
+ if (f !== null) return escapeHtml(f);
183
+ }
184
+ return escapeHtml(v);
185
+ })
186
+ .trim();
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // What a post page and a card are made of
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** An image address safe inside `url('…')` and `src="…"`, or ''. */
194
+ export function safeImageUrl(url) {
195
+ const s = String(url ?? '').trim();
196
+ return /^(\/|https?:\/\/)[^\s'"()\\<>]+$/.test(s) ? s : '';
197
+ }
198
+
199
+ /** "4 min read", from the words in the post (markup stripped). */
200
+ export function readingTime(text) {
201
+ const words = String(text ?? '').replace(/<[^>]*>/g, ' ').split(/\s+/).filter(Boolean).length;
202
+ return `${Math.max(1, Math.round(words / 200))} min read`;
203
+ }
204
+
205
+ function slugify(s) {
206
+ return String(s).toLowerCase().replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/g, '')
207
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section';
208
+ }
209
+
210
+ /**
211
+ * Give the body's h2/h3 headings ids and build a contents list from them.
212
+ * Fewer than two headings is not worth a list, and gets none.
213
+ *
214
+ * @param {string} html - the rendered post body
215
+ * @returns {{html: string, toc: string, count: number}}
216
+ */
217
+ export function buildToc(html) {
218
+ const items = [];
219
+ const used = new Set();
220
+ const out = String(html ?? '').replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/g, (m, level, attrs, inner) => {
221
+ let id = /\sid="([^"]+)"/.exec(attrs)?.[1];
222
+ if (!id) {
223
+ const base = slugify(inner);
224
+ id = base;
225
+ for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
226
+ attrs = `${attrs} id="${id}"`;
227
+ }
228
+ used.add(id);
229
+ items.push({level: Number(level), id, text: inner.replace(/<[^>]*>/g, '').trim()});
230
+ return `<h${level}${attrs}>${inner}</h${level}>`;
231
+ });
232
+ if (items.length < 2) return {html: out, toc: '', count: items.length};
233
+ const lis = items.map(i => `<li class="blog-toc-l${i.level}"><a href="#${escapeHtml(i.id)}">${i.text}</a></li>`).join('');
234
+ return {html: out, toc: `<nav class="blog-toc" aria-label="On this page" data-blog-toc><p class="blog-toc-title">On this page</p><ol>${lis}</ol></nav>`, count: items.length};
235
+ }
236
+
237
+ /**
238
+ * Posts related to this one: shared categories count double, shared tags once;
239
+ * ties go to the newer post.
240
+ */
241
+ export function relatedPosts(post, all, count = 3) {
242
+ if (!count) return [];
243
+ const cats = new Set(post.categories ?? []);
244
+ const tags = new Set(post.tags ?? []);
245
+ return all
246
+ .filter(p => p.id !== post.id)
247
+ .map(p => ({p, score: (p.categories ?? []).filter(c => cats.has(c)).length * 2 + (p.tags ?? []).filter(t => tags.has(t)).length}))
248
+ .filter(x => x.score > 0)
249
+ .sort((a, b) => b.score - a.score || new Date(b.p.publishedAt || 0) - new Date(a.p.publishedAt || 0))
250
+ .slice(0, count)
251
+ .map(x => x.p);
252
+ }
253
+
254
+ /**
255
+ * The placeholders a card block can use, for one post. Raw (`{{{…}}}`) ones are
256
+ * built here from escaped parts; everything else is plain text.
257
+ *
258
+ * @param {object} post
259
+ * @param {object} ctx - {base, catMap: id -> {name, slug}, authorName}
260
+ */
261
+ export function cardVars(post, {base, catMap = {}, authorName = ''}) {
262
+ const url = `${base}/${encodeURIComponent(post.slug ?? '')}`;
263
+ const imageUrl = safeImageUrl(post.featuredImage);
264
+ const cats = (post.categories ?? []).map(id => catMap[id]).filter(Boolean);
265
+ return {
266
+ id: post.id,
267
+ title: post.title ?? '',
268
+ url,
269
+ excerpt: post.excerpt ?? '',
270
+ publishedAt: post.publishedAt ?? '',
271
+ date: formatDate(post.publishedAt, 'D MMM YYYY') ?? '',
272
+ authorName,
273
+ authorUrl: post.authorId ? `${base}/author/${encodeURIComponent(post.authorId)}` : base,
274
+ readingTime: readingTime(post.content),
275
+ imageUrl,
276
+ imageClass: imageUrl ? 'has-image' : 'no-image',
277
+ image: imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" loading="lazy">` : '',
278
+ category: cats[0]?.name ?? '',
279
+ categories: cats.map(c => `<a class="blog-chip" href="${base}/category/${encodeURIComponent(c.slug)}" data-blog-cat="${escapeHtml(c.slug)}">${escapeHtml(c.name)}</a>`).join(''),
280
+ tags: (post.tags ?? []).map(t => `<a class="blog-tag" href="${base}/tag/${encodeURIComponent(t)}" data-blog-tag="${escapeHtml(t)}">#${escapeHtml(t)}</a>`).join('')
281
+ };
282
+ }
283
+
284
+ /**
285
+ * What the browser needs to filter, sort and right-click a card: plain values,
286
+ * carried in a data attribute - never markup.
287
+ */
288
+ export function cardMeta(post, {catMap = {}, authorName = ''}) {
289
+ return {
290
+ id: post.id,
291
+ title: post.title ?? '',
292
+ excerpt: post.excerpt ?? '',
293
+ date: post.publishedAt ?? '',
294
+ cats: (post.categories ?? []).map(id => catMap[id]).filter(Boolean).map(c => ({slug: c.slug, name: c.name})),
295
+ tags: post.tags ?? [],
296
+ author: {id: post.authorId ?? '', name: authorName},
297
+ minutes: Number.parseInt(readingTime(post.content), 10)
298
+ };
299
+ }
300
+
301
+ /** Newest first, by publishedAt; undated posts last. */
302
+ export function byNewest(a, b) {
303
+ return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
304
+ }