@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.
- package/CHANGELOG.md +75 -0
- package/cli.mjs +128 -16
- package/integration.mjs +96 -3
- package/package.json +14 -12
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +122 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +52 -0
- package/src/lib/repo/sql.ts +289 -30
- package/src/lib/repo/types.ts +40 -4
- package/src/lib/schema.ts +53 -0
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +27 -10
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/api/search.ts +44 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/pages/search.astro +83 -18
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +88 -0
- package/src/lib/repo/search.ts +0 -14
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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 {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
327
|
-
"SELECT
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
563
|
-
|
|
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
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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 —
|
|
105
|
-
// excerpt > body)
|
|
106
|
-
|
|
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
|
|