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.
- package/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /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": "
|
|
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
|
}
|
package/plugins/blog/config.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|