@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.
- package/CHANGELOG.md +28 -0
- package/cli.mjs +63 -9
- package/integration.mjs +11 -0
- package/package.json +10 -9
- 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 +10 -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/repo/migrations.ts +37 -0
- package/src/lib/repo/sql.ts +127 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +53 -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 +5 -2
- 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/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/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +3 -0
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -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(/&/g, '&').replace(/�?39;|'/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
|
-
|
|
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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
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;
|
|
@@ -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
|
-
|
|
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 =
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {}
|