@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
@@ -1,7 +1,8 @@
1
- import type { ContentRepository, Stats } from './types';
2
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, normalizeRedirectPath, slugify, type WidgetInstance } from '../schema';
1
+ import type { ContentRepository, SearchResults, Stats } from './types';
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
- import { rankByQuery } from './search';
5
+ import { htmlToPlaintext } from '../plaintext';
5
6
  import { SEED } from '../brand';
6
7
  import { CONTENT, postsIndexUrl } from '../content';
7
8
 
@@ -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
  }
@@ -75,6 +92,28 @@ function rowToLink(r: Row): Link {
75
92
  });
76
93
  }
77
94
 
95
+ // FTS5 MATCH expression from user input: each whitespace token becomes a
96
+ // quoted phrase (operators like AND / OR / - stay inert), the last one gets
97
+ // a * for search-as-you-type prefix matching.
98
+ function ftsQuery(query: string): string {
99
+ const tokens = query.split(/\s+/).filter(Boolean).slice(0, 8);
100
+ return tokens.map((t, i) => `"${t.replace(/"/g, '""')}"${i === tokens.length - 1 ? '*' : ''}`).join(' ');
101
+ }
102
+
103
+ // snippet() brackets matches with private-use-area chars so the raw text can
104
+ // be entity-escaped before the markers become <mark> — the result is safe to
105
+ // render with set:html / innerHTML.
106
+ const MARK_OPEN = '\uE000';
107
+ const MARK_CLOSE = '\uE001';
108
+ const SNIPPET = `snippet(search_index, 5, '${MARK_OPEN}', '${MARK_CLOSE}', '…', 12)`;
109
+ // column weights: kind, ref_id, locale (unindexed), title, excerpt, plaintext, tags
110
+ const RANK = 'bm25(search_index, 0, 0, 0, 8.0, 4.0, 1.0, 2.0)';
111
+ function safeSnippet(raw: unknown): string {
112
+ return String(raw ?? '')
113
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
114
+ .replaceAll(MARK_OPEN, '<mark>').replaceAll(MARK_CLOSE, '</mark>');
115
+ }
116
+
78
117
  export class SqlRepository implements ContentRepository {
79
118
  private driver!: SqlDriver;
80
119
  private ready: Promise<void>;
@@ -106,12 +145,16 @@ export class SqlRepository implements ContentRepository {
106
145
  applied = 1;
107
146
  }
108
147
  }
109
- for (const m of MIGRATIONS.filter((m) => m.version > applied)) {
148
+ const pending = MIGRATIONS.filter((m) => m.version > applied);
149
+ for (const m of pending) {
110
150
  await this.driver.batch([
111
151
  ...m.statements.map((sql) => ({ sql })),
112
152
  { sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
113
153
  ]);
114
154
  }
155
+ // v21 introduced the search index; plaintext can't be derived in SQL, so
156
+ // the backfill runs here once, right after that version lands.
157
+ if (pending.some((m) => m.version === 21)) await this.reindexSearch();
115
158
  }
116
159
 
117
160
  private async seed() {
@@ -136,6 +179,65 @@ export class SqlRepository implements ContentRepository {
136
179
  { sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 0)', args: [uuid(), 'GitHub', 'https://github.com'] },
137
180
  { sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 1)', args: [uuid(), 'Astro docs', 'https://docs.astro.build'] },
138
181
  ]);
182
+ // seed rows are inserted with raw SQL, after migrate()'s backfill ran
183
+ await this.reindexSearch();
184
+ }
185
+
186
+ // ---- search index ----
187
+ // One FTS5 row per published post/page. Sync lives here at the repo layer
188
+ // so every write path — saves, scheduled publishes, restores, seeds —
189
+ // flows through the same statements.
190
+ private postIndexStatements(r: Row): SqlStatement[] {
191
+ const stmts: SqlStatement[] = [
192
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [r.id] },
193
+ ];
194
+ if (r.status === 'published') {
195
+ stmts.push({
196
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('post', ?, '', ?, ?, ?, ?)",
197
+ args: [
198
+ r.id, r.title, r.excerpt ?? '',
199
+ htmlToPlaintext(String(r.body_html ?? '')) || String(r.body ?? ''),
200
+ publicTags(JSON.parse(String(r.tags ?? '[]'))).join(' '),
201
+ ],
202
+ });
203
+ }
204
+ return stmts;
205
+ }
206
+ private pageIndexStatements(r: Row): SqlStatement[] {
207
+ const stmts: SqlStatement[] = [
208
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [r.id] },
209
+ ];
210
+ if (r.status === 'published') {
211
+ stmts.push({
212
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('page', ?, ?, ?, ?, ?, '')",
213
+ args: [r.id, r.locale ?? '', r.title, r.seo_desc ?? '', htmlToPlaintext(String(r.body_html ?? ''))],
214
+ });
215
+ }
216
+ return stmts;
217
+ }
218
+ private async syncPostIndex(id: string) {
219
+ const r = await this.first('SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE id = ?', [id]);
220
+ await this.driver.batch(
221
+ r ? this.postIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] }]
222
+ );
223
+ }
224
+ private async syncPageIndex(id: string) {
225
+ const r = await this.first('SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE id = ?', [id]);
226
+ await this.driver.batch(
227
+ r ? this.pageIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] }]
228
+ );
229
+ }
230
+ // full rebuild: the v21 backfill, restores, seeds
231
+ private async reindexSearch() {
232
+ const [posts, pages] = await Promise.all([
233
+ this.driver.all("SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE status = 'published'"),
234
+ this.driver.all("SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE status = 'published'"),
235
+ ]);
236
+ await this.driver.batch([
237
+ { sql: 'DELETE FROM search_index' },
238
+ ...posts.flatMap((r) => this.postIndexStatements(r)),
239
+ ...pages.flatMap((r) => this.pageIndexStatements(r)),
240
+ ]);
139
241
  }
140
242
 
141
243
  // ---- pages ----
@@ -173,7 +275,9 @@ export class SqlRepository implements ContentRepository {
173
275
  }
174
276
  async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
175
277
  await this.ready;
176
- 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'];
177
281
  const args: unknown[] = [Date.now()];
178
282
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
179
283
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -199,10 +303,37 @@ export class SqlRepository implements ContentRepository {
199
303
  }
200
304
  args.push(id);
201
305
  await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
306
+ await this.syncPageIndex(id);
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]);
202
330
  }
203
331
  async deletePage(id: string) {
204
332
  await this.ready;
205
- await this.driver.run('DELETE FROM pages WHERE id = ?', [id]);
333
+ await this.driver.batch([
334
+ { sql: 'DELETE FROM pages WHERE id = ?', args: [id] },
335
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] },
336
+ ]);
206
337
  }
207
338
 
208
339
  // ---- posts ----
@@ -267,7 +398,9 @@ export class SqlRepository implements ContentRepository {
267
398
  await this.ready;
268
399
  const current = await this.getPost(id);
269
400
  if (!current) return;
270
- 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'];
271
404
  const args: unknown[] = [Date.now()];
272
405
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
273
406
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -313,27 +446,52 @@ export class SqlRepository implements ContentRepository {
313
446
  })),
314
447
  ]);
315
448
  }
449
+ await this.syncPostIndex(id);
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]);
316
473
  }
317
474
  async deletePost(id: string) {
318
475
  await this.ready;
319
476
  await this.driver.batch([
320
477
  { sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
321
478
  { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
479
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] },
322
480
  ]);
323
481
  }
324
482
  async publishDuePosts(now: number) {
325
483
  await this.ready;
326
- const due = await this.first(
327
- "SELECT COUNT(*) AS n FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
484
+ const due = await this.driver.all(
485
+ "SELECT id FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
328
486
  [now]
329
487
  );
330
- const n = Number(due?.n ?? 0);
331
- if (!n) return 0;
488
+ if (!due.length) return 0;
332
489
  await this.driver.run(
333
490
  "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
334
491
  [now, now]
335
492
  );
336
- return n;
493
+ for (const r of due) await this.syncPostIndex(String(r.id));
494
+ return due.length;
337
495
  }
338
496
 
339
497
  // ---- media ----
@@ -473,6 +631,26 @@ export class SqlRepository implements ContentRepository {
473
631
  }
474
632
  }
475
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
+
476
654
  // ---- users ----
477
655
  async listUsers() {
478
656
  await this.ready;
@@ -556,25 +734,75 @@ export class SqlRepository implements ContentRepository {
556
734
  }
557
735
 
558
736
  // ---- search ----
559
- async searchPublished(query: string) {
560
- await this.ready;
737
+ async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
738
+ await this.ready;
739
+ const trimmed = query.trim();
740
+ if (!trimmed) return { posts: [], pages: [], tags: [], authors: [] };
741
+ const locale = opts?.locale ?? '';
742
+ let postRows: Row[];
743
+ let pageRows: Row[];
744
+ try {
745
+ [postRows, pageRows] = await Promise.all([
746
+ this.driver.all(
747
+ `SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
748
+ WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
749
+ ORDER BY ${RANK} LIMIT 50`,
750
+ [ftsQuery(trimmed)]
751
+ ),
752
+ // locale is a boost, never a filter: the visitor's language sorts
753
+ // first, every locale stays visible (en/es cover different topics)
754
+ this.driver.all(
755
+ `SELECT g.*, ${SNIPPET} AS snip FROM search_index JOIN pages g ON g.id = search_index.ref_id
756
+ WHERE search_index MATCH ? AND search_index.kind = 'page' AND g.status = 'published'
757
+ ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT 50`,
758
+ [ftsQuery(trimmed), locale]
759
+ ),
760
+ ]);
761
+ } catch {
762
+ // FTS5 unavailable or a query form it rejects — degrade to the old
763
+ // LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
764
+ [postRows, pageRows] = await this.likeSearch(trimmed, locale);
765
+ }
766
+ const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
767
+ const [authors, tagRows, userRows] = await Promise.all([
768
+ this.authorMap(),
769
+ this.driver.all('SELECT * FROM tags WHERE name LIKE ? OR slug LIKE ? ORDER BY name COLLATE NOCASE ASC LIMIT 20', [like, like]),
770
+ this.driver.all('SELECT * FROM users WHERE name LIKE ? OR bio LIKE ? ORDER BY name COLLATE NOCASE ASC LIMIT 20', [like, like]),
771
+ ]);
772
+ const tags = tagRows.map(rowToTag).filter((t) => !isInternalTag(t.name));
773
+ let tagCounts = new Map<string, number>();
774
+ if (tags.length) {
775
+ const published = await this.driver.all("SELECT tags FROM posts WHERE status = 'published'");
776
+ for (const r of published) {
777
+ for (const name of JSON.parse(String(r.tags ?? '[]')) as string[]) {
778
+ const key = name.toLowerCase();
779
+ tagCounts.set(key, (tagCounts.get(key) ?? 0) + 1);
780
+ }
781
+ }
782
+ }
783
+ return {
784
+ posts: postRows.map((r) => ({
785
+ doc: rowToPost(r, authors.get(String(r.id))),
786
+ snippet: safeSnippet(r.snip ?? r.excerpt ?? ''),
787
+ })),
788
+ pages: pageRows.map((r) => ({ doc: rowToPage(r), snippet: safeSnippet(r.snip ?? r.seo_desc ?? '') })),
789
+ tags: tags.map((tag) => ({ tag, count: tagCounts.get(tag.name.toLowerCase()) ?? 0 })),
790
+ authors: userRows.map(rowToUser),
791
+ };
792
+ }
793
+ private async likeSearch(query: string, locale: string): Promise<[Row[], Row[]]> {
561
794
  const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
562
- if (q === '%%') return { posts: [], pages: [] };
563
- const [posts, pages, authors] = await Promise.all([
795
+ if (q === '%%') return [[], []];
796
+ return Promise.all([
564
797
  this.driver.all(
565
798
  "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
566
799
  [q, q, q, q]
567
800
  ),
568
801
  this.driver.all(
569
- "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY updated_at DESC LIMIT 50",
570
- [q, q]
802
+ "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
803
+ [q, q, locale]
571
804
  ),
572
- this.authorMap(),
573
805
  ]);
574
- return {
575
- posts: rankByQuery(posts.map((r) => rowToPost(r, authors.get(String(r.id)))), query),
576
- pages: pages.map(rowToPage),
577
- };
578
806
  }
579
807
 
580
808
  // ---- restore ----
@@ -587,14 +815,14 @@ export class SqlRepository implements ContentRepository {
587
815
  // p.locale defaults to '' via the zod schema, so pre-v20 backups
588
816
  // restore cleanly as default-locale pages
589
817
  ...content.pages.map((p) => ({
590
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
591
- 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],
592
820
  })),
593
821
  // p.collection defaults to 'posts' via the zod schema, so pre-v19
594
822
  // backups restore cleanly into the built-in collection
595
823
  ...content.posts.map((p) => ({
596
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
597
- 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],
598
826
  })),
599
827
  ...content.posts.flatMap((p) =>
600
828
  (p.authorIds.length ? p.authorIds : p.authorId ? [p.authorId] : []).map((aid, i) => ({
@@ -630,6 +858,7 @@ export class SqlRepository implements ContentRepository {
630
858
  ];
631
859
  await this.driver.batch(stmts);
632
860
  await this.updateSettings(content.settings);
861
+ await this.reindexSearch();
633
862
  }
634
863
 
635
864
  // ---- redirects ----
@@ -674,17 +903,23 @@ export class SqlRepository implements ContentRepository {
674
903
  siteLogo: r?.site_logo ?? '',
675
904
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
676
905
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
906
+ analytics: JSON.parse(String(r?.analytics ?? '{}')),
677
907
  headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
678
908
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
679
909
  draftsApiKey: r?.drafts_api_key ?? '',
680
910
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
911
+ draftDesign: parseDraft(r?.draft_design),
681
912
  });
682
913
  }
683
914
  async updateSettings(data: Partial<Settings>) {
684
915
  await this.ready;
685
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;
686
921
  await this.driver.run(
687
- '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',
688
923
  [
689
924
  data.siteTitle ?? current.siteTitle, data.tagline ?? current.tagline,
690
925
  data.footerText ?? current.footerText,
@@ -697,15 +932,39 @@ export class SqlRepository implements ContentRepository {
697
932
  data.siteFavicon ?? current.siteFavicon,
698
933
  data.siteCover ?? current.siteCover,
699
934
  JSON.stringify(data.announcement ?? current.announcement),
935
+ JSON.stringify(data.analytics ?? current.analytics),
700
936
  data.headCode ?? current.headCode,
701
937
  data.footerCode ?? current.footerCode,
702
938
  data.timezone ?? current.timezone,
703
939
  data.locale ?? current.locale,
704
940
  data.draftsApiKey ?? current.draftsApiKey,
705
941
  JSON.stringify(data.draftsAllowedOrigins ?? current.draftsAllowedOrigins),
942
+ draftDesign ? JSON.stringify(draftDesign) : null,
706
943
  ]
707
944
  );
708
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
+ }
709
968
  async getStats(): Promise<Stats> {
710
969
  await this.ready;
711
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;
@@ -7,6 +7,19 @@ export interface Stats {
7
7
  linkClicks: number;
8
8
  }
9
9
 
10
+ // snippet is safe HTML: entity-escaped text with <mark> around matched terms
11
+ export interface SearchHit<T> {
12
+ doc: T;
13
+ snippet: string;
14
+ }
15
+
16
+ export interface SearchResults {
17
+ posts: SearchHit<Post>[];
18
+ pages: SearchHit<Page>[];
19
+ tags: { tag: Tag; count: number }[];
20
+ authors: User[];
21
+ }
22
+
10
23
  // The single contract both backends implement. The app only ever imports
11
24
  // this — never a concrete adapter.
12
25
  export interface ContentRepository {
@@ -23,6 +36,12 @@ export interface ContentRepository {
23
36
  blocks?: WidgetInstance[];
24
37
  }
25
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>;
26
45
  deletePage(id: string): Promise<void>;
27
46
 
28
47
  // posts
@@ -47,6 +66,10 @@ export interface ContentRepository {
47
66
  >
48
67
  >
49
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>;
50
73
  deletePost(id: string): Promise<void>;
51
74
  // flips scheduled posts whose time has passed to published; returns count
52
75
  publishDuePosts(now: number): Promise<number>;
@@ -85,6 +108,11 @@ export interface ContentRepository {
85
108
  // auto-register any names missing from the registry (called on post save)
86
109
  ensureTags(names: string[]): Promise<void>;
87
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
+
88
116
  // users — passHash stays inside the repo; getUserAuth is for login only
89
117
  listUsers(): Promise<User[]>;
90
118
  getUser(id: string): Promise<User | null>;
@@ -101,9 +129,12 @@ export interface ContentRepository {
101
129
  reorderNavItems(ids: string[]): Promise<void>;
102
130
  deleteNavItem(id: string): Promise<void>;
103
131
 
104
- // public search — ranked LIKE over published posts and pages (title >
105
- // excerpt > body). Behind this method so FTS can replace it transparently.
106
- searchPublished(query: string): Promise<{ posts: Post[]; pages: Page[] }>;
132
+ // public search — FTS5 over published posts and pages (bm25: title >
133
+ // excerpt > tags > body) plus name matches from the tag registry (internal
134
+ // #tags excluded) and author profiles. opts.locale is a ranking boost,
135
+ // never a filter: pages in the visitor's locale sort first, every locale
136
+ // stays visible (en/es cover different topics, not translations).
137
+ searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults>;
107
138
 
108
139
  // backup restore — id-preserving replace-all from a backup's content.json.
109
140
  // Users restore WITHOUT password hashes (backups never contain them).
@@ -128,5 +159,10 @@ export interface ContentRepository {
128
159
  // settings & dashboard
129
160
  getSettings(): Promise<Settings>;
130
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>;
131
167
  getStats(): Promise<Stats>;
132
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