@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,121 @@
1
+ // Analytics providers (Umami, Meta Pixel, TikTok Pixel): builds the script
2
+ // markup Site.astro renders into the public <head> via set:html. Because the
3
+ // output bypasses Astro's escaping, nothing is emitted unless the stored
4
+ // ID/URL passes its validator — a bad value renders nothing, never broken or
5
+ // injected markup. The admin form rejects invalid values with the same rules.
6
+ import type { Settings } from './schema';
7
+
8
+ export const UMAMI_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
9
+ export const META_PIXEL_ID_RE = /^\d{5,20}$/;
10
+ export const TIKTOK_PIXEL_ID_RE = /^[A-Z0-9_-]{8,40}$/i;
11
+
12
+ // https only (self-hosted instances included); returns the normalized href,
13
+ // whose serialization percent-encodes quotes/angle brackets, so the value is
14
+ // attribute-safe. Belt and braces: reject anything still risky post-encode.
15
+ export function normalizeScriptUrl(url: string): string {
16
+ try {
17
+ const href = new URL(url).href;
18
+ if (new URL(url).protocol !== 'https:' || /["'<>\s]/.test(href)) return '';
19
+ return href;
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ type Analytics = Settings['analytics'];
26
+
27
+ function umamiTag(a: Analytics['umami']): string {
28
+ const src = normalizeScriptUrl(a.scriptUrl);
29
+ if (!a.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
30
+ return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}"></script>`;
31
+ }
32
+
33
+ // The replay recorder lives next to the tracker on the same instance:
34
+ // swap the URL's last path segment for recorder.js and re-validate.
35
+ export function umamiRecorderUrl(scriptUrl: string): string {
36
+ const src = normalizeScriptUrl(scriptUrl);
37
+ if (!src) return '';
38
+ const url = new URL(src);
39
+ url.pathname = url.pathname.replace(/[^/]*$/, 'recorder.js');
40
+ return normalizeScriptUrl(url.href);
41
+ }
42
+
43
+ // Session replay (Umami v3.1+): a second tag mirroring the dashboard-generated
44
+ // snippet. Attributes are re-clamped here because, like the rest of this file,
45
+ // the output bypasses Astro escaping — schema-invalid values render defaults,
46
+ // never broken markup.
47
+ function umamiReplayTag(a: Analytics['umami']): string {
48
+ const src = umamiRecorderUrl(a.scriptUrl);
49
+ if (!a.enabled || !a.replay.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
50
+ const rate =
51
+ Number.isFinite(a.replay.sampleRate) && a.replay.sampleRate >= 0.01 && a.replay.sampleRate <= 1
52
+ ? a.replay.sampleRate
53
+ : 0.15;
54
+ const mask = a.replay.maskLevel === 'strict' ? 'strict' : 'moderate';
55
+ const duration =
56
+ Number.isInteger(a.replay.maxDuration) && a.replay.maxDuration > 0 ? a.replay.maxDuration : 300000;
57
+ return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}" data-sample-rate="${rate}" data-mask-level="${mask}" data-max-duration="${duration}"></script>`;
58
+ }
59
+
60
+ // Meta's standard base code: fbq loader + PageView, with the no-JS pixel
61
+ // image fallback. The ID is digits-only by validation, so safe to inline.
62
+ function metaPixelTag(a: Analytics['metaPixel']): string {
63
+ if (!a.enabled || !META_PIXEL_ID_RE.test(a.pixelId)) return '';
64
+ return `<script>
65
+ !function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
66
+ n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
67
+ n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
68
+ t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
69
+ document,'script','https://connect.facebook.net/en_US/fbevents.js');
70
+ fbq('init', '${a.pixelId}');
71
+ fbq('track', 'PageView');
72
+ </script>
73
+ <noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=${a.pixelId}&ev=PageView&noscript=1"/></noscript>`;
74
+ }
75
+
76
+ // TikTok's published base code (experimental — untested live): ttq loader +
77
+ // page(). ID is restricted to [A-Z0-9_-] by validation.
78
+ function tiktokPixelTag(a: Analytics['tiktokPixel']): string {
79
+ if (!a.enabled || !TIKTOK_PIXEL_ID_RE.test(a.pixelId)) return '';
80
+ return `<script>
81
+ !function (w, d, t) {
82
+ w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<e.length;n++)ttq.setAndDefer(e[n],ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
83
+ ttq.load('${a.pixelId}');
84
+ ttq.page();
85
+ }(window, document, 'ttq');
86
+ </script>`;
87
+ }
88
+
89
+ export function analyticsHead(settings: Settings): string {
90
+ const a = settings.analytics;
91
+ return [umamiTag(a.umami), umamiReplayTag(a.umami), metaPixelTag(a.metaPixel), tiktokPixelTag(a.tiktokPixel)]
92
+ .filter(Boolean)
93
+ .join('\n');
94
+ }
95
+
96
+ // Event names are author-typed; collapse whitespace so "Sign up " and
97
+ // "Sign up" count as one event in the dashboards.
98
+ export function cleanEventName(name: string): string {
99
+ return name.replace(/\s+/g, ' ').trim();
100
+ }
101
+
102
+ // Click-event bridge: elements carrying data-umami-event (editor cards, the
103
+ // links page) are counted by Umami natively — this dispatcher only exists to
104
+ // forward the same clicks to the ad pixels, so it is emitted solely when one
105
+ // of them is actually enabled. Umami-only sites ship zero extra JS.
106
+ export function analyticsBody(settings: Settings): string {
107
+ const a = settings.analytics;
108
+ const meta = a.metaPixel.enabled && META_PIXEL_ID_RE.test(a.metaPixel.pixelId);
109
+ const tiktok = a.tiktokPixel.enabled && TIKTOK_PIXEL_ID_RE.test(a.tiktokPixel.pixelId);
110
+ if (!meta && !tiktok) return '';
111
+ return `<script>
112
+ document.addEventListener('click', function (e) {
113
+ var el = e.target && e.target.closest && e.target.closest('[data-umami-event]');
114
+ if (!el) return;
115
+ var name = el.getAttribute('data-umami-event');
116
+ if (!name) return;
117
+ if (window.fbq) window.fbq('trackCustom', name);
118
+ if (window.ttq) window.ttq.track(name);
119
+ });
120
+ </script>`;
121
+ }
@@ -0,0 +1,14 @@
1
+ import type { AstroCookies } from 'astro';
2
+ import type { Settings } from './schema';
3
+ import { mergeDesignDraft } from './drafts';
4
+ import { verifySessionToken, SESSION_COOKIE } from './auth';
5
+
6
+ // The Design screen's preview iframe appends ?designPreview=1 so admins see
7
+ // the STAGED design (settings.draftDesign) while every normal request keeps
8
+ // rendering the live one. Session-gated: the flag is inert for visitors.
9
+ export function effectiveSettings(settings: Settings, url: URL, cookies: AstroCookies): Settings {
10
+ if (!settings.draftDesign) return settings;
11
+ if (!url.searchParams.has('designPreview')) return settings;
12
+ if (!verifySessionToken(cookies.get(SESSION_COOKIE)?.value)) return settings;
13
+ return mergeDesignDraft(settings);
14
+ }
@@ -0,0 +1,77 @@
1
+ import type { Page, Post, Settings } from './schema';
2
+
3
+ // Pending-edit ("draft") snapshots for live documents. A published or
4
+ // scheduled post/page never receives content edits directly: they accumulate
5
+ // in the row's `draft` JSON column and only reach the live columns when the
6
+ // user hits Update (repo.applyPostDraft / applyPageDraft).
7
+ //
8
+ // The snapshot stores the same camelCase field names the save actions accept,
9
+ // plus `savedAt`. Status transitions, schedule times and the publish sweep
10
+ // never live in a snapshot — those stay deliberate, immediate actions.
11
+
12
+ export const POST_DRAFT_KEYS = [
13
+ 'title', 'slug', 'collection', 'body', 'bodyJson', 'bodyHtml', 'excerpt',
14
+ 'tags', 'cover', 'coverAlt', 'seoTitle', 'seoDesc', 'featured',
15
+ 'authorIds', 'template', 'publishedAt',
16
+ ] as const;
17
+
18
+ export const PAGE_DRAFT_KEYS = [
19
+ 'title', 'slug', 'locale', 'isHome', 'seoTitle', 'seoDesc', 'template',
20
+ 'bodyJson', 'bodyHtml', 'blocks',
21
+ ] as const;
22
+
23
+ type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
24
+ type PageDraftKey = (typeof PAGE_DRAFT_KEYS)[number];
25
+
26
+ function pick(draft: Record<string, unknown>, keys: readonly string[]) {
27
+ const out: Record<string, unknown> = {};
28
+ for (const k of keys) if (draft[k] !== undefined) out[k] = draft[k];
29
+ return out;
30
+ }
31
+
32
+ // Only known editable fields survive the pick — a hand-edited or legacy
33
+ // snapshot can never smuggle status/id/timestamps into an UPDATE.
34
+ export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post, PostDraftKey>> {
35
+ return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
36
+ }
37
+ export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
38
+ return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
39
+ }
40
+
41
+ // The document as it will look after Update — what the editor hydrates from
42
+ // and what /preview renders. Live rows pass through untouched.
43
+ export function mergePostDraft(post: Post): Post {
44
+ if (!post.draft) return post;
45
+ const staged = pickPostDraft(post.draft);
46
+ const merged = { ...post, ...staged };
47
+ if (staged.authorIds) merged.authorId = staged.authorIds[0] ?? null;
48
+ return merged;
49
+ }
50
+ export function mergePageDraft(page: Page): Page {
51
+ if (!page.draft) return page;
52
+ return { ...page, ...pickPageDraft(page.draft) };
53
+ }
54
+
55
+ export function draftSavedAt(doc: Pick<Post | Page, 'draft'>): number | null {
56
+ const ts = doc.draft?.savedAt;
57
+ return typeof ts === 'number' ? ts : null;
58
+ }
59
+
60
+ // Design-screen staging: the same shape, stored in settings.draftDesign and
61
+ // applied by the "Publish design" button.
62
+ export const DESIGN_DRAFT_KEYS = [
63
+ 'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover',
64
+ ] as const;
65
+
66
+ type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
67
+
68
+ export function pickDesignDraft(draft: Record<string, unknown>): Partial<Pick<Settings, DesignDraftKey>> {
69
+ return pick(draft, DESIGN_DRAFT_KEYS) as Partial<Pick<Settings, DesignDraftKey>>;
70
+ }
71
+
72
+ // Settings as they will look after Publish design — what ?designPreview=1
73
+ // renders for admins while visitors keep the live design.
74
+ export function mergeDesignDraft(settings: Settings): Settings {
75
+ if (!settings.draftDesign) return settings;
76
+ return { ...settings, ...pickDesignDraft(settings.draftDesign) };
77
+ }
@@ -14,8 +14,9 @@ import { renderCalendarBlock } from './calendar/render';
14
14
  // data-layout="grid" data-limit="3" data-show-image="1" data-show-excerpt="1"></div>
15
15
  // <div data-dyn="calendar" data-sources="[…ics urls]" data-view="month"
16
16
  // data-title="…" data-subtitle="…" data-limit="6" data-open-link="1"></div>
17
+ // <div data-dyn="toc" data-title="In this article"></div>
17
18
 
18
- const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar)"[^>]*>\s*<\/div>/g;
19
+ const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc)"[^>]*>\s*<\/div>/g;
19
20
 
20
21
  const esc = (s: string) =>
21
22
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -51,6 +52,41 @@ function renderList(posts: Post[], layout: string, showImage: boolean, showExcer
51
52
  return `<section class="dyn-postlist" data-layout="${esc(layout)}">${items}</section>`;
52
53
  }
53
54
 
55
+ // Heading anchors: body h1–h3 get stable slugified ids so sections are
56
+ // deep-linkable and the TOC block has targets. Headings that already carry an
57
+ // id (e.g. hand-written in an HTML card) are left alone.
58
+ export function stampHeadingAnchors(html: string): string {
59
+ const seen = new Map<string, number>();
60
+ return html.replace(/<h([1-3])(\s[^>]*)?>([\s\S]*?)<\/h\1>/g, (m, lvl: string, attrs: string | undefined, inner: string) => {
61
+ if (attrs && /\bid=/.test(attrs)) return m;
62
+ const text = inner.replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&#0?39;|&apos;/g, "'");
63
+ const base = slugify(text);
64
+ const n = (seen.get(base) ?? 0) + 1;
65
+ seen.set(base, n);
66
+ const id = n > 1 ? `${base}-${n}` : base;
67
+ return `<h${lvl}${attrs ?? ''} id="${id}">${inner}</h${lvl}>`;
68
+ });
69
+ }
70
+
71
+ // Table of contents: rebuilt from the body's (already anchored) headings on
72
+ // every request, so it can never go stale. Depth is relative to the shallowest
73
+ // heading present.
74
+ function renderToc(html: string, title: string): string {
75
+ const items: { level: number; id: string; text: string }[] = [];
76
+ const re = /<h([1-3])[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g;
77
+ let m: RegExpExecArray | null;
78
+ while ((m = re.exec(html))) {
79
+ const text = m[3].replace(/<[^>]+>/g, '').trim();
80
+ if (text) items.push({ level: Number(m[1]), id: m[2], text });
81
+ }
82
+ if (!items.length) return '';
83
+ const min = Math.min(...items.map((i) => i.level));
84
+ const links = items
85
+ .map((i) => `<a class="pc-toc-link" data-depth="${i.level - min}" href="#${esc(i.id)}">${i.text}</a>`)
86
+ .join('');
87
+ return `<nav class="pc-toc">${title ? `<span class="pc-toc-title">${esc(title)}</span>` : ''}${links}</nav>`;
88
+ }
89
+
54
90
  function calendarAttrs(tag: string) {
55
91
  let sources: string[] = [];
56
92
  try {
@@ -71,9 +107,11 @@ function calendarAttrs(tag: string) {
71
107
  }
72
108
 
73
109
  export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
110
+ html = stampHeadingAnchors(html);
74
111
  const hasPostList = html.includes('data-dyn="post-list"');
75
112
  const hasCalendar = html.includes('data-dyn="calendar"');
76
- if (!hasPostList && !hasCalendar) return html;
113
+ const hasToc = html.includes('data-dyn="toc"');
114
+ if (!hasPostList && !hasCalendar && !hasToc) return html;
77
115
  const [posts, settings] = await Promise.all([
78
116
  hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
79
117
  repo.getSettings(),
@@ -83,15 +121,17 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
83
121
  // rendered up front, then spliced in during the synchronous pass.
84
122
  const calendarHtml = new Map<string, string>();
85
123
  if (hasCalendar) {
86
- const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter((t) => t.includes('data-dyn="calendar"')))];
124
+ const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter((t) => t.includes('data-dyn="calendar"')))];
87
125
  const now = Date.now();
88
126
  await Promise.all(
89
127
  tags.map(async (tag) => calendarHtml.set(tag, await renderCalendarBlock(calendarAttrs(tag), settings, now)))
90
128
  );
91
129
  }
92
130
 
131
+ const anchored = html;
93
132
  return html.replace(PLACEHOLDER, (tag) => {
94
133
  if (tag.includes('data-dyn="calendar"')) return calendarHtml.get(tag) ?? '';
134
+ if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
95
135
  const source = attr(tag, 'source') || 'latest';
96
136
  const tagName = attr(tag, 'tag');
97
137
  // which collection the block draws from; 'posts' unless the card says otherwise
@@ -0,0 +1,22 @@
1
+ // Searchable plain text from rendered body_html: the FTS index stores this
2
+ // instead of a posts column, derived once per write. Dynamic-block
3
+ // placeholders (<div data-dyn>) carry no text, so tag-stripping drops them;
4
+ // script/style bodies are removed explicitly. Entity decoding covers what
5
+ // the editor emits — the named basics plus numeric escapes.
6
+ const codePoint = (n: number) => (n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ' ');
7
+
8
+ export function htmlToPlaintext(html: string): string {
9
+ if (!html) return '';
10
+ return html
11
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
12
+ .replace(/<[^>]*>/g, ' ')
13
+ .replace(/&#x([0-9a-f]+);/gi, (_, h: string) => codePoint(parseInt(h, 16)))
14
+ .replace(/&#(\d+);/g, (_, n: string) => codePoint(Number(n)))
15
+ .replace(/&nbsp;/gi, ' ')
16
+ .replace(/&amp;/gi, '&')
17
+ .replace(/&lt;/gi, '<')
18
+ .replace(/&gt;/gi, '>')
19
+ .replace(/&quot;/gi, '"')
20
+ .replace(/\s+/g, ' ')
21
+ .trim();
22
+ }
@@ -226,6 +226,58 @@ export const MIGRATIONS: Migration[] = [
226
226
  version: 20,
227
227
  statements: [`ALTER TABLE pages ADD COLUMN locale TEXT NOT NULL DEFAULT ''`],
228
228
  },
229
+ {
230
+ // Public search: one FTS5 row per published post/page (title, excerpt,
231
+ // stripped body text, public tag names). Rows are synced by the repo on
232
+ // every write — no SQL triggers — and backfilled in code right after
233
+ // this version applies, since plaintext can't be derived in SQL.
234
+ // remove_diacritics makes "cafe" match "café" (matters for es content).
235
+ version: 21,
236
+ statements: [
237
+ `CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
238
+ kind UNINDEXED, ref_id UNINDEXED, locale UNINDEXED,
239
+ title, excerpt, plaintext, tags,
240
+ tokenize = 'unicode61 remove_diacritics 2'
241
+ )`,
242
+ ],
243
+ },
244
+ {
245
+ // Manual publish: live (published/scheduled) documents stage their edits
246
+ // in a `draft` JSON snapshot; the live columns only change when the user
247
+ // hits Update. NULL = no pending edits.
248
+ version: 22,
249
+ statements: [
250
+ `ALTER TABLE posts ADD COLUMN draft TEXT`,
251
+ `ALTER TABLE pages ADD COLUMN draft TEXT`,
252
+ ],
253
+ },
254
+ {
255
+ // Manual publish for the Design screen: theme/token/logo changes stage in
256
+ // a `draft_design` JSON snapshot (previewed with ?designPreview=1) until
257
+ // the user hits Publish design. NULL = no staged design.
258
+ version: 23,
259
+ statements: [`ALTER TABLE settings ADD COLUMN draft_design TEXT`],
260
+ },
261
+ {
262
+ // Snippets: named, reusable fragments of editor content ("Save as
263
+ // snippet" on a selection; re-inserted from the "/" menu).
264
+ version: 24,
265
+ statements: [
266
+ `CREATE TABLE IF NOT EXISTS snippets (
267
+ id TEXT PRIMARY KEY,
268
+ name TEXT NOT NULL,
269
+ content_json TEXT NOT NULL,
270
+ created_at INTEGER NOT NULL
271
+ )`,
272
+ ],
273
+ },
274
+ {
275
+ // Analytics providers (Umami, Meta Pixel, TikTok Pixel): structured
276
+ // per-provider settings rendered into the public <head>. JSON object,
277
+ // '{}' = every provider disabled (schema defaults).
278
+ version: 25,
279
+ statements: [`ALTER TABLE settings ADD COLUMN analytics TEXT DEFAULT '{}'`],
280
+ },
229
281
  ];
230
282
 
231
283
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;