@acms-subir/core 0.1.0 → 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 (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli.mjs +63 -9
  3. package/integration.mjs +11 -0
  4. package/package.json +10 -9
  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 +10 -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/repo/migrations.ts +37 -0
  23. package/src/lib/repo/sql.ts +127 -10
  24. package/src/lib/repo/types.ts +21 -1
  25. package/src/lib/schema.ts +53 -0
  26. package/src/pages/[...slug].astro +3 -1
  27. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  28. package/src/pages/admin/c/[collection]/index.astro +1 -0
  29. package/src/pages/admin/design.astro +6 -1
  30. package/src/pages/admin/index.astro +5 -2
  31. package/src/pages/admin/pages/[id].astro +10 -2
  32. package/src/pages/admin/pages/index.astro +1 -0
  33. package/src/pages/admin/settings.astro +148 -0
  34. package/src/pages/blog/[slug].astro +4 -1
  35. package/src/pages/index.astro +4 -1
  36. package/src/pages/links.astro +10 -4
  37. package/src/pages/preview/[type]/[id].astro +9 -4
  38. package/src/styles/admin.css +4 -0
  39. package/src/styles/cards.css +23 -0
  40. package/src/styles/editor.css +53 -1
  41. package/src/styles/site.css +3 -0
@@ -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
@@ -241,6 +241,43 @@ export const MIGRATIONS: Migration[] = [
241
241
  )`,
242
242
  ],
243
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
+ },
244
281
  ];
245
282
 
246
283
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,5 +1,6 @@
1
1
  import type { ContentRepository, SearchResults, Stats } from './types';
2
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
2
+ import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
3
+ import { pickDesignDraft, pickPageDraft, pickPostDraft } from '../drafts';
3
4
  import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
4
5
  import { htmlToPlaintext } from '../plaintext';
5
6
  import { SEED } from '../brand';
@@ -24,6 +25,17 @@ export interface SqlDriver {
24
25
  batch(stmts: SqlStatement[]): Promise<void>;
25
26
  }
26
27
 
28
+ // a corrupt snapshot must never make the whole row unreadable
29
+ function parseDraft(raw: unknown): Record<string, unknown> | null {
30
+ if (!raw) return null;
31
+ try {
32
+ const d = JSON.parse(String(raw));
33
+ return d && typeof d === 'object' && !Array.isArray(d) ? d : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
27
39
  function rowToPage(r: Row): Page {
28
40
  return Page.parse({
29
41
  id: r.id, slug: r.slug, title: r.title, status: r.status,
@@ -31,7 +43,7 @@ function rowToPage(r: Row): Page {
31
43
  blocks: JSON.parse(String(r.blocks ?? '[]')),
32
44
  bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
33
45
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
34
- template: r.template ?? '',
46
+ template: r.template ?? '', draft: parseDraft(r.draft),
35
47
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
36
48
  });
37
49
  }
@@ -43,12 +55,17 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
43
55
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
44
56
  featured: Boolean(r.featured), authorId: r.author_id ?? null,
45
57
  authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
46
- template: r.template ?? '',
58
+ template: r.template ?? '', draft: parseDraft(r.draft),
47
59
  publishedAt: r.published_at === null ? null : Number(r.published_at),
48
60
  scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
49
61
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
50
62
  });
51
63
  }
64
+ function rowToSnippet(r: Row): Snippet {
65
+ return Snippet.parse({
66
+ id: r.id, name: r.name, contentJson: r.content_json ?? '[]', createdAt: Number(r.created_at),
67
+ });
68
+ }
52
69
  function rowToTag(r: Row): Tag {
53
70
  return Tag.parse({ id: r.id, name: r.name, slug: r.slug, description: r.description ?? '' });
54
71
  }
@@ -258,7 +275,9 @@ export class SqlRepository implements ContentRepository {
258
275
  }
259
276
  async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
260
277
  await this.ready;
261
- const sets: string[] = ['updated_at = ?'];
278
+ // a direct live write supersedes any staged edits (applyPageDraft routes
279
+ // through here too, so applying clears the snapshot in the same UPDATE)
280
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
262
281
  const args: unknown[] = [Date.now()];
263
282
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
264
283
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -286,6 +305,29 @@ export class SqlRepository implements ContentRepository {
286
305
  await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
287
306
  await this.syncPageIndex(id);
288
307
  }
308
+ // stage edits on a live page: only the `draft` JSON changes — live columns,
309
+ // updated_at (public auto-nav order) and the search index stay untouched
310
+ async savePageDraft(id: string, data: Parameters<ContentRepository['savePageDraft']>[1]) {
311
+ await this.ready;
312
+ const r = await this.first('SELECT draft FROM pages WHERE id = ?', [id]);
313
+ if (!r) return;
314
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
315
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
316
+ merged.savedAt = Date.now();
317
+ await this.driver.run('UPDATE pages SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
318
+ }
319
+ async applyPageDraft(id: string) {
320
+ await this.ready;
321
+ const r = await this.first('SELECT draft FROM pages WHERE id = ?', [id]);
322
+ const draft = parseDraft(r?.draft);
323
+ if (!draft) return;
324
+ // updatePage handles is_home displacement, FTS sync and clears the draft
325
+ await this.updatePage(id, pickPageDraft(draft));
326
+ }
327
+ async discardPageDraft(id: string) {
328
+ await this.ready;
329
+ await this.driver.run('UPDATE pages SET draft = NULL WHERE id = ?', [id]);
330
+ }
289
331
  async deletePage(id: string) {
290
332
  await this.ready;
291
333
  await this.driver.batch([
@@ -356,7 +398,9 @@ export class SqlRepository implements ContentRepository {
356
398
  await this.ready;
357
399
  const current = await this.getPost(id);
358
400
  if (!current) return;
359
- const sets: string[] = ['updated_at = ?'];
401
+ // a direct live write supersedes any staged edits (applyPostDraft routes
402
+ // through here too, so applying clears the snapshot in the same UPDATE)
403
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
360
404
  const args: unknown[] = [Date.now()];
361
405
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
362
406
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -404,6 +448,29 @@ export class SqlRepository implements ContentRepository {
404
448
  }
405
449
  await this.syncPostIndex(id);
406
450
  }
451
+ // stage edits on a live post: only the `draft` JSON changes — live columns,
452
+ // author joins and the search index stay untouched until Update
453
+ async savePostDraft(id: string, data: Parameters<ContentRepository['savePostDraft']>[1]) {
454
+ await this.ready;
455
+ const r = await this.first('SELECT draft FROM posts WHERE id = ?', [id]);
456
+ if (!r) return;
457
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
458
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
459
+ merged.savedAt = Date.now();
460
+ await this.driver.run('UPDATE posts SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
461
+ }
462
+ async applyPostDraft(id: string) {
463
+ await this.ready;
464
+ const r = await this.first('SELECT draft FROM posts WHERE id = ?', [id]);
465
+ const draft = parseDraft(r?.draft);
466
+ if (!draft) return;
467
+ // updatePost handles author joins, FTS sync and clears the draft
468
+ await this.updatePost(id, pickPostDraft(draft));
469
+ }
470
+ async discardPostDraft(id: string) {
471
+ await this.ready;
472
+ await this.driver.run('UPDATE posts SET draft = NULL WHERE id = ?', [id]);
473
+ }
407
474
  async deletePost(id: string) {
408
475
  await this.ready;
409
476
  await this.driver.batch([
@@ -564,6 +631,26 @@ export class SqlRepository implements ContentRepository {
564
631
  }
565
632
  }
566
633
 
634
+ // ---- snippets ----
635
+ async listSnippets() {
636
+ await this.ready;
637
+ const rows = await this.driver.all('SELECT * FROM snippets ORDER BY name COLLATE NOCASE ASC');
638
+ return rows.map(rowToSnippet);
639
+ }
640
+ async createSnippet({ name, contentJson }: { name: string; contentJson: string }) {
641
+ await this.ready;
642
+ const id = crypto.randomUUID();
643
+ await this.driver.run('INSERT INTO snippets (id, name, content_json, created_at) VALUES (?, ?, ?, ?)', [
644
+ id, name, contentJson, Date.now(),
645
+ ]);
646
+ const r = await this.first('SELECT * FROM snippets WHERE id = ?', [id]);
647
+ return rowToSnippet(r!);
648
+ }
649
+ async deleteSnippet(id: string) {
650
+ await this.ready;
651
+ await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
652
+ }
653
+
567
654
  // ---- users ----
568
655
  async listUsers() {
569
656
  await this.ready;
@@ -728,14 +815,14 @@ export class SqlRepository implements ContentRepository {
728
815
  // p.locale defaults to '' via the zod schema, so pre-v20 backups
729
816
  // restore cleanly as default-locale pages
730
817
  ...content.pages.map((p) => ({
731
- sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
732
- args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
818
+ sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, draft, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
819
+ args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.draft ? JSON.stringify(p.draft) : null, p.createdAt, p.updatedAt],
733
820
  })),
734
821
  // p.collection defaults to 'posts' via the zod schema, so pre-v19
735
822
  // backups restore cleanly into the built-in collection
736
823
  ...content.posts.map((p) => ({
737
- sql: 'INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, published_at, scheduled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
738
- args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? p.authorId, p.template, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
824
+ sql: 'INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, draft, published_at, scheduled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
825
+ args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? p.authorId, p.template, p.draft ? JSON.stringify(p.draft) : null, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
739
826
  })),
740
827
  ...content.posts.flatMap((p) =>
741
828
  (p.authorIds.length ? p.authorIds : p.authorId ? [p.authorId] : []).map((aid, i) => ({
@@ -816,17 +903,23 @@ export class SqlRepository implements ContentRepository {
816
903
  siteLogo: r?.site_logo ?? '',
817
904
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
818
905
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
906
+ analytics: JSON.parse(String(r?.analytics ?? '{}')),
819
907
  headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
820
908
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
821
909
  draftsApiKey: r?.drafts_api_key ?? '',
822
910
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
911
+ draftDesign: parseDraft(r?.draft_design),
823
912
  });
824
913
  }
825
914
  async updateSettings(data: Partial<Settings>) {
826
915
  await this.ready;
827
916
  const current = await this.getSettings();
917
+ // draft_design: undefined = leave the staged design alone (a settings
918
+ // save must not clobber it); null/value = explicit write (restore)
919
+ const draftDesign =
920
+ data.draftDesign === undefined ? current.draftDesign : data.draftDesign;
828
921
  await this.driver.run(
829
- 'UPDATE settings SET site_title = ?, tagline = ?, footer_text = ?, links_name = ?, links_bio = ?, links_avatar = ?, active_theme = ?, design_overrides = ?, site_logo = ?, site_url = ?, site_favicon = ?, site_cover = ?, announcement = ?, head_code = ?, footer_code = ?, timezone = ?, locale = ?, drafts_api_key = ?, drafts_allowed_origins = ? WHERE id = 1',
922
+ 'UPDATE settings SET site_title = ?, tagline = ?, footer_text = ?, links_name = ?, links_bio = ?, links_avatar = ?, active_theme = ?, design_overrides = ?, site_logo = ?, site_url = ?, site_favicon = ?, site_cover = ?, announcement = ?, analytics = ?, head_code = ?, footer_code = ?, timezone = ?, locale = ?, drafts_api_key = ?, drafts_allowed_origins = ?, draft_design = ? WHERE id = 1',
830
923
  [
831
924
  data.siteTitle ?? current.siteTitle, data.tagline ?? current.tagline,
832
925
  data.footerText ?? current.footerText,
@@ -839,15 +932,39 @@ export class SqlRepository implements ContentRepository {
839
932
  data.siteFavicon ?? current.siteFavicon,
840
933
  data.siteCover ?? current.siteCover,
841
934
  JSON.stringify(data.announcement ?? current.announcement),
935
+ JSON.stringify(data.analytics ?? current.analytics),
842
936
  data.headCode ?? current.headCode,
843
937
  data.footerCode ?? current.footerCode,
844
938
  data.timezone ?? current.timezone,
845
939
  data.locale ?? current.locale,
846
940
  data.draftsApiKey ?? current.draftsApiKey,
847
941
  JSON.stringify(data.draftsAllowedOrigins ?? current.draftsAllowedOrigins),
942
+ draftDesign ? JSON.stringify(draftDesign) : null,
848
943
  ]
849
944
  );
850
945
  }
946
+ // Design-screen staging: only the draft_design JSON changes on save — the
947
+ // live design columns wait for applyDesignDraft ("Publish design").
948
+ async saveDesignDraft(data: Parameters<ContentRepository['saveDesignDraft']>[0]) {
949
+ await this.ready;
950
+ const r = await this.first('SELECT draft_design FROM settings WHERE id = 1');
951
+ const merged: Record<string, unknown> = parseDraft(r?.draft_design) ?? {};
952
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
953
+ merged.savedAt = Date.now();
954
+ await this.driver.run('UPDATE settings SET draft_design = ? WHERE id = 1', [JSON.stringify(merged)]);
955
+ }
956
+ async applyDesignDraft() {
957
+ await this.ready;
958
+ const r = await this.first('SELECT draft_design FROM settings WHERE id = 1');
959
+ const draft = parseDraft(r?.draft_design);
960
+ if (!draft) return;
961
+ await this.updateSettings(pickDesignDraft(draft));
962
+ await this.discardDesignDraft();
963
+ }
964
+ async discardDesignDraft() {
965
+ await this.ready;
966
+ await this.driver.run('UPDATE settings SET draft_design = NULL WHERE id = 1');
967
+ }
851
968
  async getStats(): Promise<Stats> {
852
969
  await this.ready;
853
970
  const r = await this.first(`SELECT
@@ -1,4 +1,4 @@
1
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, Role, WidgetInstance } from '../schema';
1
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance } from '../schema';
2
2
 
3
3
  export interface Stats {
4
4
  publishedPages: number;
@@ -36,6 +36,12 @@ export interface ContentRepository {
36
36
  blocks?: WidgetInstance[];
37
37
  }
38
38
  ): Promise<void>;
39
+ // pending edits on a live page: save stages into the `draft` JSON column
40
+ // (live columns untouched), apply copies staged → live and clears, discard
41
+ // just clears. See lib/drafts.ts for the snapshot shape.
42
+ savePageDraft(id: string, data: Omit<Parameters<ContentRepository['updatePage']>[1], 'status'>): Promise<void>;
43
+ applyPageDraft(id: string): Promise<void>;
44
+ discardPageDraft(id: string): Promise<void>;
39
45
  deletePage(id: string): Promise<void>;
40
46
 
41
47
  // posts
@@ -60,6 +66,10 @@ export interface ContentRepository {
60
66
  >
61
67
  >
62
68
  ): Promise<void>;
69
+ // pending edits on a live post — same contract as the page variants above
70
+ savePostDraft(id: string, data: Omit<Parameters<ContentRepository['updatePost']>[1], 'status' | 'scheduledAt'>): Promise<void>;
71
+ applyPostDraft(id: string): Promise<void>;
72
+ discardPostDraft(id: string): Promise<void>;
63
73
  deletePost(id: string): Promise<void>;
64
74
  // flips scheduled posts whose time has passed to published; returns count
65
75
  publishDuePosts(now: number): Promise<number>;
@@ -98,6 +108,11 @@ export interface ContentRepository {
98
108
  // auto-register any names missing from the registry (called on post save)
99
109
  ensureTags(names: string[]): Promise<void>;
100
110
 
111
+ // snippets — named, reusable editor-content fragments
112
+ listSnippets(): Promise<Snippet[]>;
113
+ createSnippet(data: { name: string; contentJson: string }): Promise<Snippet>;
114
+ deleteSnippet(id: string): Promise<void>;
115
+
101
116
  // users — passHash stays inside the repo; getUserAuth is for login only
102
117
  listUsers(): Promise<User[]>;
103
118
  getUser(id: string): Promise<User | null>;
@@ -144,5 +159,10 @@ export interface ContentRepository {
144
159
  // settings & dashboard
145
160
  getSettings(): Promise<Settings>;
146
161
  updateSettings(data: Partial<Settings>): Promise<void>;
162
+ // Design-screen staging: save stages into settings.draftDesign, apply
163
+ // copies staged → live design fields and clears, discard just clears
164
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover'>>): Promise<void>;
165
+ applyDesignDraft(): Promise<void>;
166
+ discardDesignDraft(): Promise<void>;
147
167
  getStats(): Promise<Stats>;
148
168
  }
package/src/lib/schema.ts CHANGED
@@ -78,6 +78,8 @@ export const Page = z.object({
78
78
  seoDesc: z.string().default(''),
79
79
  // theme template name (templates/page-<name>.astro); empty = default layout
80
80
  template: z.string().default(''),
81
+ // pending edits on a live page (see lib/drafts.ts); null = none staged
82
+ draft: z.record(z.any()).nullable().default(null),
81
83
  createdAt: z.number(),
82
84
  updatedAt: z.number(),
83
85
  });
@@ -110,6 +112,8 @@ export const Post = z.object({
110
112
  authorIds: z.array(z.string()).default([]),
111
113
  // theme template name (templates/post-<name>.astro); empty = default layout
112
114
  template: z.string().default(''),
115
+ // pending edits on a live post (see lib/drafts.ts); null = none staged
116
+ draft: z.record(z.any()).nullable().default(null),
113
117
  publishedAt: z.number().nullable(),
114
118
  scheduledAt: z.number().nullable().default(null),
115
119
  createdAt: z.number(),
@@ -152,6 +156,17 @@ export const Tag = z.object({
152
156
  });
153
157
  export type Tag = z.infer<typeof Tag>;
154
158
 
159
+ // Snippets: named, reusable fragments of editor content, saved from a
160
+ // selection and re-inserted from the "/" menu. contentJson is a JSON array of
161
+ // ProseMirror nodes.
162
+ export const Snippet = z.object({
163
+ id: z.string(),
164
+ name: z.string(),
165
+ contentJson: z.string(),
166
+ createdAt: z.number(),
167
+ });
168
+ export type Snippet = z.infer<typeof Snippet>;
169
+
155
170
  export const Role = z.enum(['owner', 'editor']);
156
171
  export type Role = z.infer<typeof Role>;
157
172
 
@@ -268,11 +283,49 @@ export const Settings = z.object({
268
283
  dismissible: z.boolean().default(true),
269
284
  })
270
285
  .default({}),
286
+ // analytics providers, rendered into the public <head> by lib/analytics.ts
287
+ // (owner-only; IDs are re-validated there before any markup is built)
288
+ analytics: z
289
+ .object({
290
+ umami: z
291
+ .object({
292
+ enabled: z.boolean().default(false),
293
+ websiteId: z.string().default(''),
294
+ scriptUrl: z.string().default('https://cloud.umami.is/script.js'),
295
+ // session replay (Umami v3.1+): a second recorder.js tag, derived
296
+ // from scriptUrl. Defaults mirror Umami's own dashboard defaults.
297
+ replay: z
298
+ .object({
299
+ enabled: z.boolean().default(false),
300
+ sampleRate: z.number().min(0.01).max(1).default(0.15),
301
+ maskLevel: z.enum(['moderate', 'strict']).default('moderate'),
302
+ maxDuration: z.number().int().positive().default(300000),
303
+ })
304
+ .default({}),
305
+ })
306
+ .default({}),
307
+ metaPixel: z
308
+ .object({
309
+ enabled: z.boolean().default(false),
310
+ pixelId: z.string().default(''),
311
+ })
312
+ .default({}),
313
+ // experimental: same code path as the others, but not verified live yet
314
+ tiktokPixel: z
315
+ .object({
316
+ enabled: z.boolean().default(false),
317
+ pixelId: z.string().default(''),
318
+ })
319
+ .default({}),
320
+ })
321
+ .default({}),
271
322
  // external drafts API: bearer key for POST /api/drafts (empty = endpoint
272
323
  // off) and the browser origins allowed to call it ('*' entry allows all,
273
324
  // 'http://localhost:*' matches any port). Managed in /admin/settings.
274
325
  draftsApiKey: z.string().default(''),
275
326
  draftsAllowedOrigins: z.array(z.string()).default([]),
327
+ // staged Design-screen changes (see lib/drafts.ts); null = none pending
328
+ draftDesign: z.record(z.any()).nullable().default(null),
276
329
  });
277
330
  export type Settings = z.infer<typeof Settings>;
278
331
 
@@ -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}
@@ -85,9 +85,12 @@ function ago(ts: number) {
85
85
  }
86
86
  } catch { return; }
87
87
  const lt = (a: string, b: string) => {
88
- 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);
89
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; }
90
- return false;
93
+ return a.includes('-') && !b.includes('-');
91
94
  };
92
95
  if (!latest || !lt(current, latest)) return;
93
96
  try { if (localStorage.getItem(DISMISSED) === latest) return; } catch {}