@acms-subir/core 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/cli.mjs +128 -16
  3. package/integration.mjs +96 -3
  4. package/package.json +14 -12
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +122 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/plaintext.ts +22 -0
  23. package/src/lib/repo/migrations.ts +52 -0
  24. package/src/lib/repo/sql.ts +289 -30
  25. package/src/lib/repo/types.ts +40 -4
  26. package/src/lib/schema.ts +53 -0
  27. package/src/lib/search-config.ts +10 -0
  28. package/src/lib/search-i18n.ts +45 -0
  29. package/src/pages/[...slug].astro +3 -1
  30. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  31. package/src/pages/admin/c/[collection]/index.astro +1 -0
  32. package/src/pages/admin/design.astro +6 -1
  33. package/src/pages/admin/index.astro +27 -10
  34. package/src/pages/admin/pages/[id].astro +10 -2
  35. package/src/pages/admin/pages/index.astro +1 -0
  36. package/src/pages/admin/settings.astro +148 -0
  37. package/src/pages/api/search.ts +44 -0
  38. package/src/pages/blog/[slug].astro +4 -1
  39. package/src/pages/index.astro +4 -1
  40. package/src/pages/links.astro +10 -4
  41. package/src/pages/preview/[type]/[id].astro +9 -4
  42. package/src/pages/search.astro +83 -18
  43. package/src/styles/admin.css +4 -0
  44. package/src/styles/cards.css +23 -0
  45. package/src/styles/editor.css +53 -1
  46. package/src/styles/site.css +88 -0
  47. package/src/lib/repo/search.ts +0 -14
@@ -0,0 +1,10 @@
1
+ // Search feature toggle, configured per project via the integration:
2
+ // acms({ search: { enabled: false } })
3
+ // integration.mjs validates and fills defaults before serializing; when
4
+ // disabled it also skips injecting /search and /api/search, so this flag
5
+ // only decides whether the layout renders the trigger + modal.
6
+ import { userConfig } from 'virtual:acms-config';
7
+
8
+ export const SEARCH = {
9
+ enabled: userConfig.search?.enabled ?? true,
10
+ };
@@ -0,0 +1,45 @@
1
+ // UI strings for the search modal and /search page. There is no site-wide
2
+ // translation catalog yet — search is the first public surface with
3
+ // per-locale chrome, so the dict lives here until one exists. Locales fall
4
+ // back to English.
5
+ export interface SearchStrings {
6
+ label: string;
7
+ placeholder: string;
8
+ posts: string;
9
+ pages: string;
10
+ tags: string;
11
+ authors: string;
12
+ empty: string;
13
+ result: string;
14
+ results: string;
15
+ button: string;
16
+ }
17
+
18
+ const STRINGS: Record<string, SearchStrings> = {
19
+ en: {
20
+ label: 'Search',
21
+ placeholder: 'Search posts, pages, tags and authors',
22
+ posts: 'Posts',
23
+ pages: 'Pages',
24
+ tags: 'Tags',
25
+ authors: 'Authors',
26
+ empty: 'Nothing found for',
27
+ result: 'result',
28
+ results: 'results',
29
+ button: 'Search',
30
+ },
31
+ es: {
32
+ label: 'Buscar',
33
+ placeholder: 'Busca artículos, páginas, etiquetas y autores',
34
+ posts: 'Artículos',
35
+ pages: 'Páginas',
36
+ tags: 'Etiquetas',
37
+ authors: 'Autores',
38
+ empty: 'No hay resultados para',
39
+ result: 'resultado',
40
+ results: 'resultados',
41
+ button: 'Buscar',
42
+ },
43
+ };
44
+
45
+ export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
@@ -6,6 +6,7 @@ import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
6
6
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
7
  import { highlightCodeHtml } from '../lib/highlightCode';
8
8
  import { resolveDesign } from '../lib/theme';
9
+ import { effectiveSettings } from '../lib/designPreview';
9
10
 
10
11
  const { slug } = Astro.params;
11
12
  const repo = await getRepo(Astro.locals);
@@ -17,7 +18,8 @@ if (!page || (page.status !== 'published' && !isAdmin)) {
17
18
  return new Response(null, { status: 404, statusText: 'Not found' });
18
19
  }
19
20
  const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
20
- const settings = await repo.getSettings();
21
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
22
+ const settings = effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies);
21
23
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
24
  const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
23
25
  ---
@@ -5,7 +5,8 @@ import { createPreviewToken } from '../../../../lib/preview';
5
5
  import { resolveDesign } from '../../../../lib/theme';
6
6
  import { env } from '../../../../lib/env';
7
7
  import { BRAND } from '../../../../lib/brand';
8
- import { CONTENT } from '../../../../lib/content';
8
+ import { CONTENT, collectionUrl } from '../../../../lib/content';
9
+ import { mergePostDraft } from '../../../../lib/drafts';
9
10
  import PostEditor from '../../../../components/editor/PostEditor';
10
11
  import '../../../../styles/admin.css';
11
12
  import '../../../../styles/editor.css';
@@ -22,9 +23,14 @@ const post = await repo.getPost(id);
22
23
  if (!post) return new Response(null, { status: 404 });
23
24
  if (post.collection !== key) return Astro.redirect(`/admin/c/${post.collection}/${post.id}`);
24
25
 
26
+ // The editor hydrates from the pending draft when one exists (live posts
27
+ // stage their edits until Update); the live URL stays on the live slug.
28
+ const editable = mergePostDraft(post);
29
+ const livePath = collectionUrl(post.collection, post.slug);
30
+
25
31
  // Canonical content is the TipTap doc (as HTML). Legacy markdown posts are
26
32
  // converted on first open and become rich content on next save.
27
- const initialHTML = post.bodyHtml || (post.body ? renderMarkdown(post.body) : '');
33
+ const initialHTML = editable.bodyHtml || (editable.body ? renderMarkdown(editable.body) : '');
28
34
  const previewPath = `/preview/post/${post.id}`;
29
35
  const sharePath = `${previewPath}?token=${createPreviewToken('post', post.id)}`;
30
36
  const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
@@ -45,11 +51,12 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
45
51
  <body>
46
52
  <PostEditor
47
53
  client:only="react"
48
- post={post}
54
+ post={editable}
49
55
  initialHTML={initialHTML}
50
56
  unsplashEnabled={unsplashEnabled}
51
57
  previewPath={previewPath}
52
58
  sharePath={sharePath}
59
+ livePath={livePath}
53
60
  templates={templates}
54
61
  />
55
62
  </body>
@@ -116,6 +116,7 @@ const filterHref = (patch: Record<string, string>) => {
116
116
  </td>
117
117
  <td>
118
118
  <span class={`pill ${p.status}`}>{p.status}</span>
119
+ {p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
119
120
  {p.status === 'scheduled' && p.scheduledAt && (
120
121
  <div class="t-sub" style="margin-top:2px">
121
122
  {new Date(p.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
@@ -4,12 +4,16 @@ import { getRepo } from '../../lib/repo';
4
4
  import { env } from '../../lib/env';
5
5
  import DesignManager from '../../components/DesignManager';
6
6
  import { listThemes, FONT_PAIRINGS } from '../../lib/theme';
7
+ import { mergeDesignDraft } from '../../lib/drafts';
7
8
  import { postUrl } from '../../lib/content';
8
9
  // ImagePicker (logo upload) styles live in editor.css
9
10
  import '../../styles/editor.css';
10
11
 
11
12
  const repo = await getRepo(Astro.locals);
12
- const settings = await repo.getSettings();
13
+ const liveSettings = await repo.getSettings();
14
+ // the manager edits the staged design when one exists; visitors keep the live one
15
+ const settings = mergeDesignDraft(liveSettings);
16
+ const pendingDesign = Boolean(liveSettings.draftDesign);
13
17
  // most recent published post powers the preview's "Post" tab
14
18
  const [latestPost] = await repo.listPublishedPosts();
15
19
  const postPreviewPath = latestPost ? postUrl(latestPost.slug) : null;
@@ -34,6 +38,7 @@ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
34
38
  <DesignManager
35
39
  client:only="react"
36
40
  settings={{ ...settings, draftsApiKey: '' }}
41
+ pendingDesign={pendingDesign}
37
42
  themes={themes}
38
43
  pairings={[...FONT_PAIRINGS]}
39
44
  unsplashEnabled={unsplashEnabled}
@@ -57,18 +57,21 @@ function ago(ts: number) {
57
57
  <div id="acms-update" hidden data-current={coreVersion} class="card card-pad" style="display:none;align-items:center;gap:10px;margin-bottom:14px;border-left:3px solid var(--accent)">
58
58
  <div>
59
59
  <b>ACMS <span id="acms-update-latest"></span> is available</b>
60
- <span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>, then <code>npm run deploy</code>.</span>
60
+ <span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>; it prints the deploy steps for your setup.</span>
61
61
  </div>
62
- <a class="btn sm ghost" style="margin-left:auto" href="https://github.com/zurielbm/acms-subir/releases" target="_blank" rel="noopener">What's new</a>
62
+ <a id="acms-update-notes" class="btn sm ghost" style="margin-left:auto" href="https://www.npmjs.com/package/@acms-subir/core" target="_blank" rel="noopener">What's new</a>
63
+ <button id="acms-update-dismiss" class="btn sm ghost" type="button" aria-label="Dismiss until the next release" title="Dismiss until the next release">×</button>
63
64
  </div>
64
65
  <script>
65
66
  // Ghost-style update nudge: npm registry check, cached a day, silent on
66
- // any failure (offline, unpublished package, CORS).
67
+ // any failure (offline, unpublished package, CORS). Dismissal is stored
68
+ // per-version, so the banner returns on the next release.
67
69
  (async () => {
68
70
  const el = document.getElementById('acms-update');
69
71
  if (!el) return;
70
72
  const current = el.dataset.current ?? '';
71
73
  const KEY = 'acms-update-check';
74
+ const DISMISSED = 'acms-update-dismissed';
72
75
  let latest = '';
73
76
  try {
74
77
  const cached = JSON.parse(localStorage.getItem(KEY) ?? 'null');
@@ -82,16 +85,30 @@ function ago(ts: number) {
82
85
  }
83
86
  } catch { return; }
84
87
  const lt = (a: string, b: string) => {
85
- const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
88
+ // prerelease-blind on purpose except for the base-equal case: a site
89
+ // pinned to a canary (0.1.1-next.x) should be nudged once the real
90
+ // 0.1.1 lands on the latest tag, and never before
91
+ const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
86
92
  for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
87
- return false;
93
+ return a.includes('-') && !b.includes('-');
88
94
  };
89
- if (latest && lt(current, latest)) {
90
- const slot = document.getElementById('acms-update-latest');
91
- if (slot) slot.textContent = latest;
92
- el.hidden = false;
93
- el.style.display = 'flex';
95
+ if (!latest || !lt(current, latest)) return;
96
+ try { if (localStorage.getItem(DISMISSED) === latest) return; } catch {}
97
+ const slot = document.getElementById('acms-update-latest');
98
+ if (slot) slot.textContent = latest;
99
+ // deep-link to the version's npm page — the repository is self-hosted and
100
+ // private, so npm is the only public place this release exists
101
+ const notes = document.getElementById('acms-update-notes');
102
+ if (notes instanceof HTMLAnchorElement) {
103
+ notes.href = `https://www.npmjs.com/package/@acms-subir/core/v/${encodeURIComponent(latest)}`;
94
104
  }
105
+ document.getElementById('acms-update-dismiss')?.addEventListener('click', () => {
106
+ try { localStorage.setItem(DISMISSED, latest); } catch {}
107
+ el.hidden = true;
108
+ el.style.display = 'none';
109
+ });
110
+ el.hidden = false;
111
+ el.style.display = 'flex';
95
112
  })();
96
113
  </script>
97
114
 
@@ -5,6 +5,8 @@ import { createPreviewToken } from '../../../lib/preview';
5
5
  import { resolveDesign } from '../../../lib/theme';
6
6
  import { env } from '../../../lib/env';
7
7
  import { BRAND } from '../../../lib/brand';
8
+ import { mergePageDraft } from '../../../lib/drafts';
9
+ import { localeHomeUrl } from '../../../lib/i18n';
8
10
  import PostEditor from '../../../components/editor/PostEditor';
9
11
  import '../../../styles/admin.css';
10
12
  import '../../../styles/editor.css';
@@ -14,9 +16,14 @@ const id = Astro.params.id!;
14
16
  const page = await repo.getPage(id);
15
17
  if (!page) return new Response(null, { status: 404 });
16
18
 
19
+ // The editor hydrates from the pending draft when one exists (live pages
20
+ // stage their edits until Update); the live URL stays on the live slug.
21
+ const editable = mergePageDraft(page);
22
+ const livePath = page.isHome ? localeHomeUrl(page.locale) : `/${page.slug}`;
23
+
17
24
  // Canonical content is the TipTap doc (as HTML). Legacy widget-block pages
18
25
  // are converted on first open and become rich content on next save.
19
- const initialHTML = page.bodyHtml || blocksToHtml(page.blocks);
26
+ const initialHTML = editable.bodyHtml || blocksToHtml(editable.blocks);
20
27
  const previewPath = `/preview/page/${page.id}`;
21
28
  const sharePath = `${previewPath}?token=${createPreviewToken('page', page.id)}`;
22
29
  const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
@@ -38,11 +45,12 @@ const templates = Object.keys(resolveDesign(settings).theme.pageTemplates);
38
45
  <PostEditor
39
46
  client:only="react"
40
47
  kind="page"
41
- page={page}
48
+ page={editable}
42
49
  initialHTML={initialHTML}
43
50
  unsplashEnabled={unsplashEnabled}
44
51
  previewPath={previewPath}
45
52
  sharePath={sharePath}
53
+ livePath={livePath}
46
54
  templates={templates}
47
55
  />
48
56
  </body>
@@ -70,6 +70,7 @@ const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 's
70
70
  </td>
71
71
  <td>
72
72
  <span class={`pill ${p.status}`}>{p.status}</span>
73
+ {p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
73
74
  {p.isHome && <span class="pill home">home</span>}
74
75
  {I18N.enabled && <span class="pill lang">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>}
75
76
  </td>
@@ -4,6 +4,7 @@ import { getRepo } from '../../lib/repo';
4
4
  import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
5
5
  import { invalidateRedirectCache } from '../../middleware';
6
6
  import { CONTENT } from '../../lib/content';
7
+ import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
7
8
 
8
9
  const repo = await getRepo(Astro.locals);
9
10
 
@@ -77,6 +78,57 @@ if (Astro.request.method === 'POST') {
77
78
  });
78
79
  flash = 'Code injection saved.';
79
80
  }
81
+ } else if (action === 'analytics') {
82
+ if (!isOwner) {
83
+ flashError = 'Only owners can edit analytics.';
84
+ } else {
85
+ // replay sample rate is edited as a percent, stored as a fraction
86
+ const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
87
+ const replayDuration = Number(String(form.get('umamiReplayMaxDuration') ?? '300000').trim());
88
+ const umami = {
89
+ enabled: form.get('umamiEnabled') === 'on',
90
+ websiteId: String(form.get('umamiWebsiteId') ?? '').trim(),
91
+ scriptUrl: String(form.get('umamiScriptUrl') ?? '').trim() || 'https://cloud.umami.is/script.js',
92
+ replay: {
93
+ enabled: form.get('umamiReplayEnabled') === 'on',
94
+ sampleRate: Math.round(replayPct) / 100,
95
+ maskLevel: (form.get('umamiReplayMaskLevel') === 'strict' ? 'strict' : 'moderate') as 'strict' | 'moderate',
96
+ maxDuration: replayDuration,
97
+ },
98
+ };
99
+ const metaPixel = {
100
+ enabled: form.get('metaEnabled') === 'on',
101
+ pixelId: String(form.get('metaPixelId') ?? '').trim(),
102
+ };
103
+ const tiktokPixel = {
104
+ enabled: form.get('tiktokEnabled') === 'on',
105
+ pixelId: String(form.get('tiktokPixelId') ?? '').trim(),
106
+ };
107
+ // reject bad values even while disabled — a stored ID must always be
108
+ // renderable, since these end up in set:html on every public page
109
+ if (umami.websiteId && !UMAMI_ID_RE.test(umami.websiteId)) {
110
+ flashError = 'Umami website ID should be a UUID — copy it from your Umami dashboard (Settings → Websites → Edit).';
111
+ } else if (!normalizeScriptUrl(umami.scriptUrl)) {
112
+ flashError = 'Umami script URL must be an https:// URL, e.g. https://cloud.umami.is/script.js';
113
+ } else if (!Number.isFinite(replayPct) || replayPct < 1 || replayPct > 100) {
114
+ flashError = 'Replay sample rate must be between 1 and 100 percent.';
115
+ } else if (!Number.isInteger(replayDuration) || replayDuration <= 0) {
116
+ flashError = 'Replay max duration is invalid.';
117
+ } else if (metaPixel.pixelId && !META_PIXEL_ID_RE.test(metaPixel.pixelId)) {
118
+ flashError = 'Meta Pixel ID should be numbers only — find it in Meta Events Manager.';
119
+ } else if (tiktokPixel.pixelId && !TIKTOK_PIXEL_ID_RE.test(tiktokPixel.pixelId)) {
120
+ flashError = 'TikTok Pixel ID should be letters and numbers only — find it in TikTok Events Manager.';
121
+ } else if (umami.enabled && !umami.websiteId) {
122
+ flashError = 'Enter the Umami website ID to enable it.';
123
+ } else if (metaPixel.enabled && !metaPixel.pixelId) {
124
+ flashError = 'Enter the Meta Pixel ID to enable it.';
125
+ } else if (tiktokPixel.enabled && !tiktokPixel.pixelId) {
126
+ flashError = 'Enter the TikTok Pixel ID to enable it.';
127
+ } else {
128
+ await repo.updateSettings({ analytics: { umami, metaPixel, tiktokPixel } });
129
+ flash = 'Analytics saved.';
130
+ }
131
+ }
80
132
  } else if (action === 'announcement') {
81
133
  // link must be a relative path or http(s) URL — never javascript: etc.
82
134
  const rawLink = String(form.get('annLink') ?? '').trim();
@@ -314,6 +366,102 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
314
366
  </div>
315
367
  )}
316
368
 
369
+ {isOwner && (
370
+ <div class="card card-pad">
371
+ <p class="card-title">Analytics</p>
372
+ <p style="margin:0">
373
+ Traffic and ad tracking on every public page. Only the ID is stored — the tracking code itself
374
+ is rendered by the CMS. The admin is never tracked. Mind visitor-consent rules (GDPR) if you
375
+ enable the ad pixels.
376
+ </p>
377
+ <form method="post">
378
+ <input type="hidden" name="action" value="analytics" />
379
+
380
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
381
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
382
+ <input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} />
383
+ <span style="font-weight:600">Umami</span>
384
+ </label>
385
+ <div class="field-row">
386
+ <label class="field">
387
+ <span>Website ID</span>
388
+ <input type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" style="font-family:monospace;font-size:12.5px" />
389
+ </label>
390
+ <label class="field">
391
+ <span>Script URL</span>
392
+ <input type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" style="font-family:monospace;font-size:12.5px" />
393
+ </label>
394
+ </div>
395
+ <p class="hint" style="margin:0">
396
+ Privacy-friendly stats: pageviews, referrers, devices, countries, visit duration.
397
+ Umami Cloud by default — point Script URL at your own instance if self-hosting.
398
+ </p>
399
+ <div style="margin:10px 0 0 24px;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px">
400
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
401
+ <input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} />
402
+ <span style="font-weight:600">Session replay</span>
403
+ </label>
404
+ <div class="field-row">
405
+ <label class="field">
406
+ <span>Sample rate (%)</span>
407
+ <input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} />
408
+ </label>
409
+ <label class="field">
410
+ <span>Mask level</span>
411
+ <select name="umamiReplayMaskLevel">
412
+ <option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
413
+ <option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
414
+ </select>
415
+ </label>
416
+ <label class="field">
417
+ <span>Max duration</span>
418
+ <select name="umamiReplayMaxDuration">
419
+ <option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
420
+ <option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
421
+ <option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
422
+ <option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
423
+ </select>
424
+ </label>
425
+ </div>
426
+ <p class="hint" style="margin:0">
427
+ Records a sample of visitor sessions for playback in Umami (v3.1+, Behavior → Replays).
428
+ Umami keeps replays for 30 days. Inputs are masked — mind visitor-consent rules (GDPR) before enabling.
429
+ </p>
430
+ </div>
431
+ </div>
432
+
433
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
434
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
435
+ <input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} />
436
+ <span style="font-weight:600">Meta Pixel</span>
437
+ </label>
438
+ <label class="field">
439
+ <span>Pixel ID</span>
440
+ <input type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" style="font-family:monospace;font-size:12.5px;max-width:320px" />
441
+ </label>
442
+ <p class="hint" style="margin:0">Tracks page views for Facebook/Instagram ad attribution and retargeting audiences.</p>
443
+ </div>
444
+
445
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
446
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
447
+ <input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} />
448
+ <span style="font-weight:600">TikTok Pixel <span style="display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;padding:2px 8px;border-radius:999px;background:var(--amber-soft);color:var(--amber);vertical-align:1px">Experimental</span></span>
449
+ </label>
450
+ <label class="field">
451
+ <span>Pixel ID</span>
452
+ <input type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" style="font-family:monospace;font-size:12.5px;max-width:320px" />
453
+ </label>
454
+ <p class="hint" style="margin:0">
455
+ Untested integration — the snippet follows TikTok’s published base code, but verify events
456
+ in TikTok Events Manager before relying on it.
457
+ </p>
458
+ </div>
459
+
460
+ <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
461
+ </form>
462
+ </div>
463
+ )}
464
+
317
465
  <div class="card card-pad">
318
466
  <p class="card-title">Announcement bar</p>
319
467
  <form method="post">
@@ -0,0 +1,44 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { authorSlug, publicTags } from '../../lib/schema';
4
+ import { collectionUrl, tagUrl } from '../../lib/content';
5
+ import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
6
+ import { formatDate } from '../../lib/dates';
7
+
8
+ // Public search API behind the site's search modal. Published-only by
9
+ // construction — the same contract as rss/sitemap — so it needs no auth and
10
+ // the middleware already leaves /api alone. `locale` boosts the visitor's
11
+ // language to the top of page results; it never filters. Snippets are safe
12
+ // HTML (entity-escaped, <mark> around matches) ready for innerHTML.
13
+
14
+ const LIMITS = { posts: 10, pages: 5, tags: 5, authors: 5 };
15
+
16
+ export const GET: APIRoute = async ({ locals, url }) => {
17
+ const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' };
18
+ const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
19
+ if (!q) return new Response(JSON.stringify({ posts: [], pages: [], tags: [], authors: [] }), { headers });
20
+
21
+ const repo = await getRepo(locals);
22
+ const locale = normalizePageLocale(url.searchParams.get('locale') ?? '');
23
+ const [results, settings] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings()]);
24
+
25
+ const body = {
26
+ posts: results.posts.slice(0, LIMITS.posts).map(({ doc: p, snippet }) => ({
27
+ title: p.title,
28
+ url: collectionUrl(p.collection, p.slug),
29
+ excerpt: p.excerpt,
30
+ snippet,
31
+ date: p.publishedAt ? formatDate(p.publishedAt, settings) : '',
32
+ tags: publicTags(p.tags),
33
+ })),
34
+ pages: results.pages.slice(0, LIMITS.pages).map(({ doc: p, snippet }) => ({
35
+ title: p.title,
36
+ url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`,
37
+ snippet,
38
+ locale: p.locale,
39
+ })),
40
+ tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
41
+ authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
42
+ };
43
+ return new Response(JSON.stringify(body), { headers });
44
+ };
@@ -7,6 +7,7 @@ import { highlightCodeHtml } from '../../lib/highlightCode';
7
7
  import { verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
8
8
  import { slugify, authorSlug, publicTags } from '../../lib/schema';
9
9
  import { resolveDesign } from '../../lib/theme';
10
+ import { effectiveSettings } from '../../lib/designPreview';
10
11
  import { siteOrigin } from '../../lib/seo';
11
12
  import { formatDate } from '../../lib/dates';
12
13
  import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
@@ -24,13 +25,15 @@ if (!post || (post.status !== 'published' && !isAdmin)) {
24
25
  return new Response(null, { status: 404, statusText: 'Not found' });
25
26
  }
26
27
  const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
27
- const [tags, allUsers, allPosts, settings, allMedia] = await Promise.all([
28
+ const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
28
29
  repo.listTags(),
29
30
  authorIds.length ? repo.listUsers() : Promise.resolve([]),
30
31
  repo.listPublishedPosts(col.key), // related reading stays within the collection
31
32
  repo.getSettings(),
32
33
  post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
33
34
  ]);
35
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
36
+ const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
34
37
  // ordered author objects; the first is the primary (gets the author card)
35
38
  const authors = authorIds
36
39
  .map((id) => allUsers.find((u) => u.id === id))
@@ -3,6 +3,7 @@ import Site from '../layouts/Site.astro';
3
3
  import WidgetRenderer from '../components/WidgetRenderer.astro';
4
4
  import { getRepo } from '../lib/repo';
5
5
  import { resolveDesign } from '../lib/theme';
6
+ import { effectiveSettings } from '../lib/designPreview';
6
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
8
  import { I18N, localeFromPath } from '../lib/i18n';
8
9
 
@@ -11,10 +12,12 @@ const repo = await getRepo(Astro.locals);
11
12
  // resolved from the request path (injectRoute can't pass props)
12
13
  const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
13
14
  const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
14
- let [home, settings] = await Promise.all([
15
+ let [home, rawSettings] = await Promise.all([
15
16
  repo.getHomePage(wantsLocaleHome ? locale : undefined),
16
17
  repo.getSettings(),
17
18
  ]);
19
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
20
+ const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
18
21
  // a locale home that isn't authored yet falls back to the default home —
19
22
  // /es must never 404 mid-setup
20
23
  if (wantsLocaleHome && !home) home = await repo.getHomePage();
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import Site from '../layouts/Site.astro';
3
3
  import { getRepo } from '../lib/repo';
4
+ import { cleanEventName } from '../lib/analytics';
4
5
 
5
6
  const repo = await getRepo(Astro.locals);
6
7
  const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
@@ -11,6 +12,11 @@ const profileBio = settings.linksBio || settings.tagline;
11
12
 
12
13
  const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
13
14
 
15
+ // every link button is auto-stamped with an analytics event so per-link click
16
+ // counts (sponsors!) show up in Umami alongside the first-party /go counter
17
+ const linkEvent = (label: string): string | undefined =>
18
+ cleanEventName(`Link: ${label}`) === 'Link:' ? undefined : cleanEventName(`Link: ${label}`);
19
+
14
20
  // icon column convention: '<svg…' inline markup, http/​/ image URL,
15
21
  // 'favicon' derive from the link's host, anything else = emoji/text
16
22
  function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
@@ -96,13 +102,13 @@ const visible = links.filter((l) => {
96
102
  case 'html':
97
103
  return <div class="link-html" set:html={l.html} />;
98
104
  case 'email':
99
- return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
105
+ return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
100
106
  case 'phone':
101
- return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
107
+ return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
102
108
  case 'vcard':
103
- return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
109
+ return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`} data-umami-event={linkEvent(l.label || 'Save contact')}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
104
110
  default:
105
- return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener">{icon}<span class="link-label">{l.label}</span></a>;
111
+ return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener" data-umami-event={linkEvent(l.label)}>{icon}<span class="link-label">{l.label}</span></a>;
106
112
  }
107
113
  })}
108
114
  {visible.length === 0 && <p>No links yet.</p>}
@@ -7,9 +7,12 @@ import { verifyPreviewToken } from '../../../lib/preview';
7
7
  import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
8
8
  import { highlightCodeHtml } from '../../../lib/highlightCode';
9
9
  import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
10
+ import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
10
11
 
11
12
  // Draft preview, any status. Reachable by a logged-in admin (the editor's
12
- // preview iframe) or by anyone holding a signed share token.
13
+ // preview iframe) or by anyone holding a signed share token. Live documents
14
+ // with pending edits render the PENDING version — exactly what Update will
15
+ // publish — while the public URL keeps serving the live one.
13
16
  const { type, id } = Astro.params;
14
17
  if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
15
18
 
@@ -20,9 +23,11 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
20
23
  }
21
24
 
22
25
  const repo = await getRepo(Astro.locals);
23
- const post = type === 'post' ? await repo.getPost(id!) : null;
24
- const page = type === 'page' ? await repo.getPage(id!) : null;
25
- if (!post && !page) return new Response(null, { status: 404 });
26
+ const livePost = type === 'post' ? await repo.getPost(id!) : null;
27
+ const livePage = type === 'page' ? await repo.getPage(id!) : null;
28
+ if (!livePost && !livePage) return new Response(null, { status: 404 });
29
+ const post = livePost ? mergePostDraft(livePost) : null;
30
+ const page = livePage ? mergePageDraft(livePage) : null;
26
31
 
27
32
  const html = post ? highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo)) : '';
28
33
  const pageHtml = page?.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';