@acms-subir/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -315
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +455 -47
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +216 -97
- package/src/components/NavManager.tsx +83 -38
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +331 -57
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/analytics.ts +121 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +112 -0
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +10 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +140 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
package/src/pages/api/restore.ts
CHANGED
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { getMediaStore } from '../../lib/media/store';
|
|
5
5
|
import { unzip } from '../../lib/backup/unzip';
|
|
6
|
-
import {
|
|
7
|
-
import { z } from 'zod';
|
|
8
|
-
|
|
9
|
-
// Restore a backup ZIP produced by /api/backup. Two modes:
|
|
10
|
-
// POST /api/restore?dry=1 — parse + validate, return a report, change nothing
|
|
11
|
-
// POST /api/restore — REPLACE all content with the backup's
|
|
12
|
-
// Owner-only, like the backup download. Restored user accounts have no
|
|
13
|
-
// passwords (backups never include hashes) — sign in with ADMIN_PASSWORD.
|
|
14
|
-
|
|
15
|
-
const BackupContent = z.object({
|
|
16
|
-
format: z.literal('acms-backup'),
|
|
17
|
-
version: z.number(),
|
|
18
|
-
settings: Settings.partial(),
|
|
19
|
-
pages: z.array(Page),
|
|
20
|
-
posts: z.array(Post),
|
|
21
|
-
media: z.array(Media),
|
|
22
|
-
links: z.array(Link),
|
|
23
|
-
tags: z.array(Tag),
|
|
24
|
-
navItems: z.array(NavItem),
|
|
25
|
-
users: z.array(User),
|
|
26
|
-
// added in backup version 2; older backups restore with none
|
|
27
|
-
redirects: z.array(Redirect).default([]),
|
|
28
|
-
});
|
|
6
|
+
import { BackupContent } from '../../lib/backup/content';
|
|
29
7
|
|
|
30
8
|
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
31
|
-
const sessionUserId =
|
|
9
|
+
const sessionUserId = (await validateSession(cookies.get(SESSION_COOKIE)?.value, locals));
|
|
32
10
|
if (!sessionUserId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
33
11
|
const repo = await getRepo(locals);
|
|
34
12
|
const sessionUser = sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
@@ -36,7 +14,13 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
36
14
|
return Response.json({ error: 'Only owners can restore backups' }, { status: 403 });
|
|
37
15
|
}
|
|
38
16
|
|
|
39
|
-
const
|
|
17
|
+
const origin = request.headers.get('origin');
|
|
18
|
+
if ((origin && origin !== url.origin) || request.headers.get('sec-fetch-site') === 'cross-site') {
|
|
19
|
+
return Response.json({ error: 'Cross-origin requests are not allowed.' }, { status: 403 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let form: FormData;
|
|
23
|
+
try { form = await request.formData(); } catch { return Response.json({ error: 'Invalid multipart form.' }, { status: 400 }); }
|
|
40
24
|
const file = form.get('file');
|
|
41
25
|
if (!(file instanceof File) || file.size === 0) {
|
|
42
26
|
return Response.json({ error: 'Missing backup file' }, { status: 400 });
|
|
@@ -54,25 +38,23 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
54
38
|
const contentRaw = entries.get('content.json');
|
|
55
39
|
if (!contentRaw) return Response.json({ error: 'content.json missing — not an acms backup' }, { status: 400 });
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
let raw: unknown;
|
|
42
|
+
try { raw = JSON.parse(new TextDecoder().decode(contentRaw)); } catch { return Response.json({ error: 'content.json is not valid JSON' }, { status: 400 }); }
|
|
43
|
+
const parsed = BackupContent.safeParse(raw);
|
|
58
44
|
if (!parsed.success) {
|
|
59
45
|
return Response.json({ error: 'content.json failed validation — is this from a newer acms?' }, { status: 400 });
|
|
60
46
|
}
|
|
61
47
|
const content = parsed.data;
|
|
48
|
+
delete content.settings.ai;
|
|
49
|
+
delete content.settings.agent;
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// primary binary are dropped; rows missing only variants lose the variants.
|
|
66
|
-
const mediaKeys = content.media.flatMap((m) => [m.key, ...m.variants.map((v) => v.key)]);
|
|
67
|
-
const missingBinaries = mediaKeys.filter((k) => !entries.has(`media/${k}`));
|
|
68
|
-
content.media = content.media
|
|
69
|
-
.filter((m) => entries.has(`media/${m.key}`))
|
|
70
|
-
.map((m) => ({ ...m, variants: m.variants.filter((v) => entries.has(`media/${v.key}`)) }));
|
|
51
|
+
const mediaKeys = content.media.flatMap(m => [m.key, ...m.variants.map(v => v.key)]);
|
|
52
|
+
const missingBinaries = mediaKeys.filter(k => !entries.has(`media/${k}`));
|
|
71
53
|
|
|
72
54
|
if (url.searchParams.get('dry')) {
|
|
73
55
|
return Response.json({
|
|
74
56
|
report: {
|
|
75
|
-
exportedAt:
|
|
57
|
+
exportedAt: content.exportedAt ?? null,
|
|
76
58
|
posts: content.posts.length,
|
|
77
59
|
pages: content.pages.length,
|
|
78
60
|
media: content.media.length,
|
|
@@ -80,11 +62,19 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
80
62
|
tags: content.tags.length,
|
|
81
63
|
navItems: content.navItems.length,
|
|
82
64
|
users: content.users.length,
|
|
65
|
+
events: content.events.length,
|
|
66
|
+
calendars: content.calendars.length,
|
|
67
|
+
bookings: content.bookings.length,
|
|
68
|
+
bookingRequests: content.bookingRequests?.length ?? 0,
|
|
69
|
+
retryRecordsIncluded: content.version >= 5,
|
|
70
|
+
warnings: content.version < 5 ? ['This older backup has no booking retry records. Use a new v5 backup for migration.'] : [],
|
|
83
71
|
missingBinaries: missingBinaries.length,
|
|
84
72
|
},
|
|
85
73
|
});
|
|
86
74
|
}
|
|
87
75
|
|
|
76
|
+
if (missingBinaries.length) return Response.json({ error: 'Archive is incomplete: media files are missing. Restore cancelled.' }, { status: 400 });
|
|
77
|
+
|
|
88
78
|
// media binaries first, so content never references a missing object
|
|
89
79
|
const store = getMediaStore(locals);
|
|
90
80
|
for (const m of content.media) {
|
|
@@ -98,5 +88,5 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
98
88
|
}
|
|
99
89
|
await repo.restoreAll(content);
|
|
100
90
|
|
|
101
|
-
return Response.json({ ok: true, restored: { posts: content.posts.length, pages: content.pages.length, media: content.media.length } });
|
|
91
|
+
return Response.json({ ok: true, restored: { posts: content.posts.length, pages: content.pages.length, media: content.media.length, events: content.events.length, bookings: content.bookings.length, bookingRequests: content.bookingRequests?.length ?? 0 } }, { headers: { 'Cache-Control': 'no-store' } });
|
|
102
92
|
};
|
package/src/pages/api/search.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { getRepo } from '../../lib/repo';
|
|
|
3
3
|
import { authorSlug, publicTags } from '../../lib/schema';
|
|
4
4
|
import { collectionUrl, tagUrl } from '../../lib/content';
|
|
5
5
|
import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
|
|
6
|
-
import { formatDate } from '../../lib/dates';
|
|
6
|
+
import { formatDate, siteLocale } from '../../lib/dates';
|
|
7
|
+
import { CALENDAR, eventUrl } from '../../lib/calendar/config';
|
|
8
|
+
import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../../lib/calendar/events';
|
|
7
9
|
|
|
8
10
|
// Public search API behind the site's search modal. Published-only by
|
|
9
11
|
// construction — the same contract as rss/sitemap — so it needs no auth and
|
|
@@ -11,16 +13,18 @@ import { formatDate } from '../../lib/dates';
|
|
|
11
13
|
// language to the top of page results; it never filters. Snippets are safe
|
|
12
14
|
// HTML (entity-escaped, <mark> around matches) ready for innerHTML.
|
|
13
15
|
|
|
14
|
-
const LIMITS = { posts: 10, pages: 5, tags: 5, authors: 5 };
|
|
16
|
+
const LIMITS = { posts: 10, pages: 5, events: 5, tags: 5, authors: 5 };
|
|
15
17
|
|
|
16
18
|
export const GET: APIRoute = async ({ locals, url }) => {
|
|
17
19
|
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' };
|
|
18
20
|
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
|
|
19
|
-
if (!q) return new Response(JSON.stringify({ posts: [], pages: [], tags: [], authors: [] }), { headers });
|
|
21
|
+
if (!q) return new Response(JSON.stringify({ posts: [], pages: [], events: [], tags: [], authors: [] }), { headers });
|
|
20
22
|
|
|
21
23
|
const repo = await getRepo(locals);
|
|
22
24
|
const locale = normalizePageLocale(url.searchParams.get('locale') ?? '');
|
|
23
|
-
const [results, settings] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings()]);
|
|
25
|
+
const [results, settings, calendars] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings(), repo.listCalendars()]);
|
|
26
|
+
const calMap = new Map(calendars.map((c) => [c.id, c]));
|
|
27
|
+
const now = Date.now();
|
|
24
28
|
|
|
25
29
|
const body = {
|
|
26
30
|
posts: results.posts.slice(0, LIMITS.posts).map(({ doc: p, snippet }) => ({
|
|
@@ -37,6 +41,16 @@ export const GET: APIRoute = async ({ locals, url }) => {
|
|
|
37
41
|
snippet,
|
|
38
42
|
locale: p.locale,
|
|
39
43
|
})),
|
|
44
|
+
// private-calendar events never surface publicly
|
|
45
|
+
events: (CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)) : []).slice(0, LIMITS.events).map(({ doc: e, snippet }) => {
|
|
46
|
+
const o = featuredOccurrence(e, settings, now);
|
|
47
|
+
return {
|
|
48
|
+
title: e.title,
|
|
49
|
+
url: eventUrl(e.slug),
|
|
50
|
+
snippet,
|
|
51
|
+
date: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
40
54
|
tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
|
|
41
55
|
authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
|
|
42
56
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getAvailability } from '../../../lib/mobile/calendar';
|
|
2
|
+
import { readRoute } from '../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(getAvailability, false);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { submitBooking } from '../../../lib/mobile/bookings';
|
|
4
|
+
import { failure, limitRequest, response } from '../../../lib/mobile/http';
|
|
5
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
6
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
7
|
+
export const POST: APIRoute = async context => {
|
|
8
|
+
try {
|
|
9
|
+
const repo = await getRepo(context.locals);
|
|
10
|
+
let address = 'unknown';
|
|
11
|
+
try { address = context.clientAddress || address; } catch { /* shared fallback bucket */ }
|
|
12
|
+
await limitRequest(repo, address, true);
|
|
13
|
+
const result = await submitBooking(repo, context.request, context.locals);
|
|
14
|
+
return response(result.data, result.replayed ? 200 : 201, { 'Idempotency-Replayed': String(result.replayed) });
|
|
15
|
+
} catch (error) { return failure(error); }
|
|
16
|
+
};
|
|
17
|
+
export const ALL = () => methodNotAllowed('POST, OPTIONS');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { listCalendars } from '../../../lib/mobile/calendar';
|
|
2
|
+
import { readRoute } from '../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(listCalendars, true);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getContent } from '../../../../lib/mobile/content';
|
|
2
|
+
import { readRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(getContent, true);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { listContent } from '../../../../lib/mobile/content';
|
|
2
|
+
import { readRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(listContent, true);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { listEvents } from '../../../lib/mobile/calendar';
|
|
2
|
+
import { readRoute } from '../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(listEvents, true);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { listResources } from '../../../lib/mobile/calendar';
|
|
2
|
+
import { readRoute } from '../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(listResources, true);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import PostCard from '../../components/site/PostCard.astro';
|
|
4
|
+
import Pager from '../../components/site/Pager.astro';
|
|
3
5
|
import { getRepo } from '../../lib/repo';
|
|
6
|
+
import { tagUrl } from '../../lib/content';
|
|
7
|
+
import { publicTags, slugify } from '../../lib/schema';
|
|
4
8
|
import { authorSlug } from '../../lib/schema';
|
|
5
9
|
import { formatDate } from '../../lib/dates';
|
|
6
10
|
import { CONTENT, collectionUrl } from '../../lib/content';
|
|
7
11
|
|
|
8
|
-
const PAGE_SIZE =
|
|
12
|
+
const PAGE_SIZE = 12;
|
|
9
13
|
const { slug } = Astro.params;
|
|
10
14
|
const repo = await getRepo(Astro.locals);
|
|
11
|
-
const [users, settings] = await Promise.all([repo.listUsers(), repo.getSettings()]);
|
|
15
|
+
const [users, settings, tags] = await Promise.all([repo.listUsers(), repo.getSettings(), repo.listTags()]);
|
|
12
16
|
// stored slug wins; slugified name is the fallback so profiles work untouched
|
|
13
17
|
const author = users.find((u) => authorSlug(u) === slug);
|
|
14
18
|
if (!author) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
@@ -26,41 +30,45 @@ const fmt = (ts: number | null) =>
|
|
|
26
30
|
formatDate(ts, settings, 'short');
|
|
27
31
|
const readMins = (p: (typeof posts)[number]) =>
|
|
28
32
|
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
33
|
+
const tagSlug = (name: string) =>
|
|
34
|
+
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
35
|
+
const tagLinks = (names: string[]) => publicTags(names).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
36
|
+
const layout = allPosts.length && allPosts.filter((p) => p.cover).length / allPosts.length >= 0.5 ? 'cards' : 'list';
|
|
37
|
+
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
29
38
|
---
|
|
30
39
|
|
|
31
40
|
<Site title={author.name} description={author.bio || `${CONTENT.posts.plural} by ${author.name}`}>
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
<div class="wide archive">
|
|
42
|
+
<header class="profile-head">
|
|
43
|
+
{author.avatar ? (
|
|
44
|
+
<img class="profile-avatar" src={author.avatar} alt={author.name} />
|
|
45
|
+
) : (
|
|
46
|
+
<span class="profile-avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>
|
|
47
|
+
)}
|
|
48
|
+
<div>
|
|
49
|
+
<h1>{author.name}</h1>
|
|
50
|
+
{author.bio && <p class="page-lede">{author.bio}</p>}
|
|
51
|
+
<p class="profile-count">{allPosts.length} {(allPosts.length === 1 ? CONTENT.posts.singular : CONTENT.posts.plural).toLowerCase()}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</header>
|
|
54
|
+
{posts.length === 0 && <p class="muted-note">No published {CONTENT.posts.plural.toLowerCase()} yet.</p>}
|
|
55
|
+
<div class:list={['post-grid', `is-${layout}`]}>
|
|
56
|
+
{posts.map((p) => (
|
|
57
|
+
<PostCard
|
|
58
|
+
variant={layout === 'cards' ? 'card' : 'row'}
|
|
59
|
+
headingLevel={2}
|
|
60
|
+
href={collectionUrl(p.collection, p.slug)}
|
|
61
|
+
title={p.title}
|
|
62
|
+
excerpt={p.excerpt}
|
|
63
|
+
cover={p.cover}
|
|
64
|
+
coverAlt={p.coverAlt}
|
|
65
|
+
date={fmt(p.publishedAt)}
|
|
66
|
+
datetime={iso(p.publishedAt)}
|
|
67
|
+
readMins={readMins(p)}
|
|
68
|
+
tags={tagLinks(p.tags)}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
42
71
|
</div>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<ul class="post-list">
|
|
46
|
-
{posts.map((p) => (
|
|
47
|
-
<li class="post-item">
|
|
48
|
-
{p.cover && (
|
|
49
|
-
<a class="post-thumb" href={collectionUrl(p.collection, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
|
|
50
|
-
)}
|
|
51
|
-
<div>
|
|
52
|
-
<a href={collectionUrl(p.collection, p.slug)}>{p.title}</a>
|
|
53
|
-
<div class="post-meta">{fmt(p.publishedAt)} · {readMins(p)} min read</div>
|
|
54
|
-
{p.excerpt && <p class="post-excerpt">{p.excerpt}</p>}
|
|
55
|
-
</div>
|
|
56
|
-
</li>
|
|
57
|
-
))}
|
|
58
|
-
</ul>
|
|
59
|
-
{pageCount > 1 && (
|
|
60
|
-
<nav class="pager" aria-label="Pagination">
|
|
61
|
-
{pageNum > 1 ? <a href={pageNum === 2 ? base : `${base}?page=${pageNum - 1}`}>← Newer</a> : <span />}
|
|
62
|
-
<span class="pager-status">Page {pageNum} of {pageCount}</span>
|
|
63
|
-
{pageNum < pageCount ? <a href={`${base}?page=${pageNum + 1}`}>Older →</a> : <span />}
|
|
64
|
-
</nav>
|
|
65
|
-
)}
|
|
72
|
+
<Pager base={base} page={pageNum} count={pageCount} />
|
|
73
|
+
</div>
|
|
66
74
|
</Site>
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { renderMarkdown } from '../../lib/markdown';
|
|
5
|
-
import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
|
|
5
|
+
import { expandDynamicBlocks, extractToc } from '../../lib/dynamicBlocks';
|
|
6
|
+
import TagLabel from '../../components/site/TagLabel.astro';
|
|
7
|
+
import PostCard from '../../components/site/PostCard.astro';
|
|
6
8
|
import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
7
|
-
import {
|
|
9
|
+
import { validateSession, SESSION_COOKIE } from '../../lib/auth';
|
|
8
10
|
import { slugify, authorSlug, publicTags } from '../../lib/schema';
|
|
9
11
|
import { resolveDesign } from '../../lib/theme';
|
|
12
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
10
13
|
import { siteOrigin } from '../../lib/seo';
|
|
11
14
|
import { formatDate } from '../../lib/dates';
|
|
12
15
|
import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
@@ -19,18 +22,20 @@ const { slug } = Astro.params;
|
|
|
19
22
|
const repo = await getRepo(Astro.locals);
|
|
20
23
|
const post = slug ? await repo.getPostBySlug(slug, col.key) : null;
|
|
21
24
|
|
|
22
|
-
const isAdmin =
|
|
25
|
+
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
23
26
|
if (!post || (post.status !== 'published' && !isAdmin)) {
|
|
24
27
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
25
28
|
}
|
|
26
29
|
const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
|
|
27
|
-
const [tags, allUsers, allPosts,
|
|
30
|
+
const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
|
|
28
31
|
repo.listTags(),
|
|
29
32
|
authorIds.length ? repo.listUsers() : Promise.resolve([]),
|
|
30
33
|
repo.listPublishedPosts(col.key), // related reading stays within the collection
|
|
31
34
|
repo.getSettings(),
|
|
32
35
|
post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
|
|
33
36
|
]);
|
|
37
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
38
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
34
39
|
// ordered author objects; the first is the primary (gets the author card)
|
|
35
40
|
const authors = authorIds
|
|
36
41
|
.map((id) => allUsers.find((u) => u.id === id))
|
|
@@ -69,6 +74,13 @@ const byOverlap = allPosts
|
|
|
69
74
|
.sort((a, b) => b.shared - a.shared || (b.p.publishedAt ?? 0) - (a.p.publishedAt ?? 0))
|
|
70
75
|
.map((r) => r.p);
|
|
71
76
|
const related = byOverlap.slice(0, 3);
|
|
77
|
+
const relatedReadMins = (p: (typeof allPosts)[number]) =>
|
|
78
|
+
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
79
|
+
const tagLinks = (names: string[]) => publicTags(names).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
80
|
+
// "On this page" rail: only worth it with a few sections to jump between
|
|
81
|
+
const toc = extractToc(html).filter((i) => i.level > 1);
|
|
82
|
+
const showToc = po.toc && toc.length >= 3;
|
|
83
|
+
const tocMin = toc.length ? Math.min(...toc.map((i) => i.level)) : 2;
|
|
72
84
|
|
|
73
85
|
const heroOverlay = po.cover === 'overlay' && Boolean(post.cover);
|
|
74
86
|
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
@@ -108,11 +120,10 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
108
120
|
{PostTemplate ? (
|
|
109
121
|
<PostTemplate post={post} html={html} authors={authors} settings={settings} related={related} />
|
|
110
122
|
) : (
|
|
111
|
-
|
|
123
|
+
<>
|
|
124
|
+
<article class="post">
|
|
112
125
|
{post.status !== 'published' && (
|
|
113
|
-
<p
|
|
114
|
-
Draft preview — only you can see this post.
|
|
115
|
-
</p>
|
|
126
|
+
<p class="notice is-warning">Draft preview. Only you can see this post.</p>
|
|
116
127
|
)}
|
|
117
128
|
{heroOverlay ? (
|
|
118
129
|
<header class="post-hero">
|
|
@@ -128,7 +139,7 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
128
139
|
<div class="post-hero-scrim" aria-hidden="true" />
|
|
129
140
|
<div class="post-hero-content">
|
|
130
141
|
<div class="post-kicker">
|
|
131
|
-
{visibleTags.map((t) => <
|
|
142
|
+
{visibleTags.map((t) => <TagLabel name={t} href={tagUrl(tagSlug(t))} />)}
|
|
132
143
|
</div>
|
|
133
144
|
<h1>{post.title}</h1>
|
|
134
145
|
{(metaBits || (po.byline && author)) && (
|
|
@@ -147,52 +158,80 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
147
158
|
</header>
|
|
148
159
|
) : (
|
|
149
160
|
<header class="post-header">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
{visibleTags.length > 0 && (
|
|
162
|
+
<div class="post-kicker">
|
|
163
|
+
{visibleTags.map((t) => <TagLabel name={t} href={tagUrl(tagSlug(t))} />)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
153
166
|
<h1>{post.title}</h1>
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
{post.excerpt && <p class="post-dek">{post.excerpt}</p>}
|
|
168
|
+
{(metaBits || (po.byline && author) || po.share) && (
|
|
169
|
+
<div class="post-byline-row">
|
|
170
|
+
<div class="post-byline">
|
|
171
|
+
{po.byline && authors.length > 0 && (
|
|
172
|
+
<span class="avatar-stack">
|
|
173
|
+
{authors.slice(0, 3).map((a) => a.avatar
|
|
174
|
+
? <img class="avatar" src={a.avatar} alt="" />
|
|
175
|
+
: <span class="avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>
|
|
176
|
+
)}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
<div>
|
|
180
|
+
{po.byline && authors.length > 0 && (
|
|
181
|
+
<span class="byline">{authors.map((a, i) => (
|
|
182
|
+
<>{i > 0 && (i === authors.length - 1 ? ' & ' : ', ')}<a href={`/author/${authorSlug(a)}`}>{a.name}</a></>
|
|
183
|
+
))}</span>
|
|
184
|
+
)}
|
|
185
|
+
{metaBits && (
|
|
186
|
+
<span class="post-meta">
|
|
187
|
+
{po.showDate && post.publishedAt && <time datetime={iso(post.publishedAt)}>{fmt(post.publishedAt)}</time>}
|
|
188
|
+
{po.showDate && post.publishedAt && po.showReadingTime && ' \u00b7 '}
|
|
189
|
+
{po.showReadingTime && `${readMins} min read`}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{po.share && (
|
|
195
|
+
<div class="post-share" aria-label="Share this post">
|
|
196
|
+
<a href={shareX} target="_blank" rel="noopener" aria-label="Share on X">
|
|
197
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.9 2H22l-6.8 7.8L23.2 22h-6.3l-4.9-6.4L6.4 22H3.3l7.3-8.3L1.6 2H8l4.4 5.9L18.9 2zm-1.1 18h1.7L7.1 3.7H5.3L17.8 20z"/></svg>
|
|
198
|
+
</a>
|
|
199
|
+
<a href={shareLi} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
|
|
200
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.4 20.4h-3.5v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.4V9h3.4v1.6a3.7 3.7 0 0 1 3.3-1.8c3.6 0 4.2 2.3 4.2 5.4v6.2zM5.3 7.4a2 2 0 1 1 0-4.1 2 2 0 0 1 0 4.1zM7.1 20.4H3.6V9h3.5v11.4z"/></svg>
|
|
201
|
+
</a>
|
|
202
|
+
<button type="button" class="post-share-copy" data-url={shareUrl} aria-label="Copy link">
|
|
203
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5"/></svg>
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
163
206
|
)}
|
|
164
207
|
</div>
|
|
165
208
|
)}
|
|
166
209
|
</header>
|
|
167
210
|
)}
|
|
168
211
|
{post.cover && !heroOverlay && po.cover !== 'hidden' && (
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
212
|
+
<figure class:list={['post-cover-wrap', { 'is-full': po.cover === 'full' }]}>
|
|
213
|
+
<img
|
|
214
|
+
class:list={['post-cover', { 'is-full': po.cover === 'full' }]}
|
|
215
|
+
src={post.cover}
|
|
216
|
+
alt={post.coverAlt}
|
|
217
|
+
width={coverMedia?.width || undefined}
|
|
218
|
+
height={coverMedia?.height || undefined}
|
|
219
|
+
srcset={coverSrcset}
|
|
220
|
+
sizes={coverSrcset ? (po.cover === 'full' ? '100vw' : '(max-width: 1000px) 100vw, 1000px') : undefined}
|
|
221
|
+
/>
|
|
222
|
+
</figure>
|
|
178
223
|
)}
|
|
179
224
|
|
|
180
|
-
<div class=
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
</
|
|
188
|
-
<a href={shareLi} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
|
|
189
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M20.4 20.4h-3.5v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.4V9h3.4v1.6a3.7 3.7 0 0 1 3.3-1.8c3.6 0 4.2 2.3 4.2 5.4v6.2zM5.3 7.4a2 2 0 1 1 0-4.1 2 2 0 0 1 0 4.1zM7.1 20.4H3.6V9h3.5v11.4z"/></svg>
|
|
190
|
-
</a>
|
|
191
|
-
<button type="button" class="post-share-copy" data-url={shareUrl} aria-label="Copy link">
|
|
192
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5"/></svg>
|
|
193
|
-
</button>
|
|
194
|
-
</aside>
|
|
225
|
+
<div class:list={['post-layout', { 'has-toc': showToc }]}>
|
|
226
|
+
{showToc && (
|
|
227
|
+
<aside class="post-toc">
|
|
228
|
+
<nav aria-label="On this page">
|
|
229
|
+
<span class="post-toc-title">On this page</span>
|
|
230
|
+
{toc.map((i) => <a href={`#${i.id}`} data-depth={i.level - tocMin}>{i.text}</a>)}
|
|
231
|
+
</nav>
|
|
232
|
+
</aside>
|
|
195
233
|
)}
|
|
234
|
+
<div class="post-content" set:html={html} />
|
|
196
235
|
</div>
|
|
197
236
|
|
|
198
237
|
{po.authorCard && author && (
|
|
@@ -200,34 +239,38 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
200
239
|
{author.avatar ? (
|
|
201
240
|
<img class="author-avatar" src={author.avatar} alt={author.name} />
|
|
202
241
|
) : (
|
|
203
|
-
<span class="author-avatar
|
|
242
|
+
<span class="author-avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>
|
|
204
243
|
)}
|
|
205
|
-
<div>
|
|
244
|
+
<div class="author-card-text">
|
|
206
245
|
<h2 class="author-card-name"><a href={`/author/${authorSlug(author)}`}>{author.name}</a></h2>
|
|
207
246
|
{author.bio && <p class="author-card-bio">{author.bio}</p>}
|
|
208
|
-
<a class="author-card-more" href={`/author/${authorSlug(author)}`}>More posts</a>
|
|
209
247
|
</div>
|
|
248
|
+
<a class="btn secondary sm author-card-more" href={`/author/${authorSlug(author)}`}>More posts</a>
|
|
210
249
|
</aside>
|
|
211
250
|
)}
|
|
212
|
-
|
|
213
|
-
{po.related && related.length > 0 && (
|
|
214
|
-
<section class="related">
|
|
215
|
-
<h2 class="related-title">Related reading</h2>
|
|
216
|
-
<div class="related-grid">
|
|
217
|
-
{related.map((p) => (
|
|
218
|
-
<article class="related-card">
|
|
219
|
-
{p.cover && (
|
|
220
|
-
<a class="related-thumb" href={collectionUrl(col.key, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
|
|
221
|
-
)}
|
|
222
|
-
<div class="post-meta">{p.publishedAt && <time datetime={iso(p.publishedAt)}>{fmt(p.publishedAt)}</time>}</div>
|
|
223
|
-
<h3><a href={collectionUrl(col.key, p.slug)}>{p.title}</a></h3>
|
|
224
|
-
{p.excerpt && <p class="related-excerpt">{p.excerpt}</p>}
|
|
225
|
-
</article>
|
|
226
|
-
))}
|
|
227
|
-
</div>
|
|
228
|
-
</section>
|
|
229
|
-
)}
|
|
230
251
|
</article>
|
|
252
|
+
|
|
253
|
+
{po.related && related.length > 0 && (
|
|
254
|
+
<section class="related wide">
|
|
255
|
+
<h2 class="related-title">Keep reading</h2>
|
|
256
|
+
<div class="post-grid is-cards">
|
|
257
|
+
{related.map((p) => (
|
|
258
|
+
<PostCard
|
|
259
|
+
href={collectionUrl(col.key, p.slug)}
|
|
260
|
+
title={p.title}
|
|
261
|
+
cover={p.cover}
|
|
262
|
+
coverAlt={p.coverAlt}
|
|
263
|
+
date={formatDate(p.publishedAt, settings, 'short')}
|
|
264
|
+
datetime={iso(p.publishedAt)}
|
|
265
|
+
readMins={relatedReadMins(p)}
|
|
266
|
+
tags={tagLinks(p.tags)}
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
</section>
|
|
271
|
+
)}
|
|
272
|
+
{po.progress && <div class="read-progress" aria-hidden="true"><i></i></div>}
|
|
273
|
+
</>
|
|
231
274
|
)}
|
|
232
275
|
<script>
|
|
233
276
|
document.querySelector<HTMLButtonElement>('.post-share-copy')?.addEventListener('click', async (e) => {
|
|
@@ -236,5 +279,36 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
236
279
|
btn.classList.add('is-copied');
|
|
237
280
|
setTimeout(() => btn.classList.remove('is-copied'), 1200);
|
|
238
281
|
});
|
|
282
|
+
|
|
283
|
+
// reading progress: how far the article body has scrolled past the top
|
|
284
|
+
const bar = document.querySelector<HTMLElement>('.read-progress i');
|
|
285
|
+
const body = document.querySelector<HTMLElement>('.post-content');
|
|
286
|
+
if (bar && body) {
|
|
287
|
+
const update = () => {
|
|
288
|
+
const r = body.getBoundingClientRect();
|
|
289
|
+
const total = r.height - window.innerHeight * 0.6;
|
|
290
|
+
const done = Math.min(1, Math.max(0, -r.top / Math.max(1, total)));
|
|
291
|
+
bar.style.transform = `scaleX(${done})`;
|
|
292
|
+
};
|
|
293
|
+
addEventListener('scroll', update, { passive: true });
|
|
294
|
+
addEventListener('resize', update);
|
|
295
|
+
update();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// "On this page": mark the section currently in view
|
|
299
|
+
const links = [...document.querySelectorAll<HTMLAnchorElement>('.post-toc a')];
|
|
300
|
+
if (links.length && 'IntersectionObserver' in window) {
|
|
301
|
+
const byId = new Map(links.map((a) => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
302
|
+
const visible = new Set<string>();
|
|
303
|
+
const mark = () => {
|
|
304
|
+
const first = [...byId.keys()].find((id) => visible.has(id));
|
|
305
|
+
if (first) links.forEach((a) => a.toggleAttribute('aria-current', a === byId.get(first)));
|
|
306
|
+
};
|
|
307
|
+
const io = new IntersectionObserver((entries) => {
|
|
308
|
+
entries.forEach((e) => (e.isIntersecting ? visible.add(e.target.id) : visible.delete(e.target.id)));
|
|
309
|
+
mark();
|
|
310
|
+
}, { rootMargin: '-80px 0px -55% 0px' });
|
|
311
|
+
byId.forEach((_, id) => { const el = document.getElementById(id); if (el) io.observe(el); });
|
|
312
|
+
}
|
|
239
313
|
</script>
|
|
240
314
|
</Site>
|