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