@acms-subir/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli.mjs +63 -9
  3. package/integration.mjs +11 -0
  4. package/package.json +10 -9
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +10 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/repo/migrations.ts +37 -0
  23. package/src/lib/repo/sql.ts +127 -10
  24. package/src/lib/repo/types.ts +21 -1
  25. package/src/lib/schema.ts +53 -0
  26. package/src/pages/[...slug].astro +3 -1
  27. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  28. package/src/pages/admin/c/[collection]/index.astro +1 -0
  29. package/src/pages/admin/design.astro +6 -1
  30. package/src/pages/admin/index.astro +5 -2
  31. package/src/pages/admin/pages/[id].astro +10 -2
  32. package/src/pages/admin/pages/index.astro +1 -0
  33. package/src/pages/admin/settings.astro +148 -0
  34. package/src/pages/blog/[slug].astro +4 -1
  35. package/src/pages/index.astro +4 -1
  36. package/src/pages/links.astro +10 -4
  37. package/src/pages/preview/[type]/[id].astro +9 -4
  38. package/src/styles/admin.css +4 -0
  39. package/src/styles/cards.css +23 -0
  40. package/src/styles/editor.css +53 -1
  41. package/src/styles/site.css +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c568c1d: Manual publish: nothing goes live until you press Update
8
+
9
+ - Posts & pages (incl. the homepage): editing a live (published or scheduled)
10
+ document no longer pushes autosaves to the public site. Edits stage in a
11
+ private pending draft (new `draft` column, migration v22); the editor shows
12
+ "Published · edited — Saved, not live yet", the publish popover gains
13
+ **Update — push changes live** and **Discard pending changes**, and admin
14
+ lists mark pending documents with an `edited` pill. `/preview/…` (and share
15
+ links) render the pending version — exactly what Update will publish.
16
+ Deferred side-effects: the slug-rename 301 and the search index only update
17
+ at Update time, so staged edits can never leak through search or move a
18
+ live URL early. Unpublishing folds pending edits into the document; the
19
+ scheduled-publish sweep publishes the live copy and keeps pending edits
20
+ pending. Drafts survive backup/restore.
21
+ - Design screen: theme, token, logo, favicon and share-image changes stage in
22
+ `settings.draftDesign` (migration v23). The preview iframe renders the
23
+ staged design via the admin-only `?designPreview=1` flag while visitors
24
+ keep the live one; **Publish design** applies it, **Discard** reverts.
25
+ - Navigation & Links managers: the auto-save debounce is gone. Edits stay
26
+ local with an "Unsaved changes — not live yet" bar; **Save changes**
27
+ commits everything at once (add/edit/delete/reorder + links profile),
28
+ **Discard** restores the saved state, and leaving the page with unsaved
29
+ edits warns first.
30
+
3
31
  ## 0.1.0
4
32
 
5
33
  ### Minor Changes
package/cli.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // `acms` — tiny maintenance CLI installed with @acms-subir/core.
3
3
  // acms update check the registry, install the latest core, print what's new
4
4
  // acms update --check just report whether an update exists (exit 1 if behind)
5
+ // acms update --next follow the canary channel (`next` dist-tag); sticky once installed
6
+ // acms update --latest leave the canary channel and go back to stable
5
7
  // acms doctor sanity-check the project setup
6
8
  // acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
7
9
  // acms add docker retrofit self-hosting (Dockerfile, compose.yml)
@@ -23,16 +25,39 @@ const installedVersion = () => {
23
25
  const p = installedPkgJson();
24
26
  try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
25
27
  };
26
- const latestVersion = async () => {
27
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, {
28
+ const latestVersion = async (tag = 'latest') => {
29
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/${tag}`, {
28
30
  signal: AbortSignal.timeout(10_000),
29
31
  });
32
+ if (res.status === 404 && tag !== 'latest') {
33
+ throw Object.assign(new Error(`the '${tag}' channel has no releases yet`), { noTag: true });
34
+ }
30
35
  if (!res.ok) throw new Error(`registry responded ${res.status}`);
31
36
  return (await res.json()).version;
32
37
  };
38
+ const splitPre = (v) => {
39
+ const i = v.indexOf('-');
40
+ return i === -1 ? [v, ''] : [v.slice(0, i), v.slice(i + 1)];
41
+ };
42
+ const isCanary = (v) => splitPre(v)[1] !== '';
33
43
  const semverLt = (a, b) => {
34
- const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
44
+ const [aBase, aPre] = splitPre(a), [bBase, bPre] = splitPre(b);
45
+ const pa = aBase.split('.').map(Number), pb = bBase.split('.').map(Number);
35
46
  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; }
47
+ // equal bases: a release outranks any of its prereleases; two prereleases
48
+ // compare identifier by identifier, numerically where both sides are numbers
49
+ if (!aPre) return false;
50
+ if (!bPre) return true;
51
+ const as = aPre.split('.'), bs = bPre.split('.');
52
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
53
+ const x = as[i], y = bs[i];
54
+ if (x === undefined) return true;
55
+ if (y === undefined) return false;
56
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
57
+ if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) < Number(y); }
58
+ else if (nx !== ny) return nx;
59
+ else if (x !== y) return x < y;
60
+ }
36
61
  return false;
37
62
  };
38
63
 
@@ -65,18 +90,32 @@ if (cmd === 'update') {
65
90
  const checkOnly = process.argv.includes('--check');
66
91
  const current = installedVersion();
67
92
  if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
93
+ // Channel: --next opts into canary prereleases and is sticky — once a canary
94
+ // is installed, a plain `acms update` keeps following the next tag until
95
+ // --latest switches back to stable.
96
+ const backToStable = process.argv.includes('--latest') && isCanary(current);
97
+ const channel = process.argv.includes('--latest') ? 'latest'
98
+ : process.argv.includes('--next') || isCanary(current) ? 'next'
99
+ : 'latest';
68
100
  let latest;
69
- try { latest = await latestVersion(); } catch (e) { console.error(`✗ Could not reach the npm registry (${e.message}).`); process.exit(1); }
70
- if (!semverLt(current, latest)) {
71
- console.log(`✓ Already up to date (${current}).`);
101
+ try { latest = await latestVersion(channel); } catch (e) {
102
+ console.error(e.noTag ? `✗ ${e.message[0].toUpperCase()}${e.message.slice(1)}.` : `✗ Could not reach the npm registry (${e.message}).`);
103
+ process.exit(1);
104
+ }
105
+ // Leaving the canary channel is usually a downgrade (0.1.1-next.x → 0.1.0),
106
+ // so "am I behind" is the wrong question there — switch unconditionally.
107
+ if (!backToStable && !semverLt(current, latest)) {
108
+ console.log(`✓ Already up to date (${current}${channel === 'next' ? ', canary channel' : ''}).`);
72
109
  process.exit(0);
73
110
  }
74
111
  if (checkOnly) {
75
- console.log(`Update available: ${current} → ${latest}. Run: npx acms update`);
112
+ console.log(`Update available: ${current} → ${latest}. Run: npx acms update${channel === 'next' ? ' --next' : backToStable ? ' --latest' : ''}`);
76
113
  process.exit(1);
77
114
  }
78
- console.log(`Updating ${PKG} ${current} → ${latest}…`);
79
- const res = spawnSync('npm', ['install', `${PKG}@latest`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
115
+ console.log(backToStable
116
+ ? `Returning to the stable channel: ${PKG} ${current} → ${latest}…`
117
+ : `Updating ${PKG} ${current} → ${latest}${channel === 'next' ? ' (canary)' : ''}…`);
118
+ const res = spawnSync('npm', ['install', `${PKG}@${channel}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
80
119
  if (res.status !== 0) process.exit(res.status ?? 1);
81
120
  console.log(`\n✓ Updated to ${latest}.`);
82
121
  const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
@@ -86,9 +125,14 @@ if (cmd === 'update') {
86
125
  console.log(`\n ${PKG} ${r.version}`);
87
126
  if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
88
127
  }
128
+ } else if (channel === 'next') {
129
+ console.log(`\nWhat's new: canary build — notes arrive with the next stable release.`);
89
130
  } else {
90
131
  console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
91
132
  }
133
+ if (channel === 'next') {
134
+ console.log(`\nYou're on the canary channel; \`npx acms update --latest\` returns to stable.`);
135
+ }
92
136
  const t = deployTargets();
93
137
  const steps = [
94
138
  ...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
@@ -269,6 +313,14 @@ data
269
313
  .dev.vars
270
314
  .wrangler
271
315
  .git
316
+ `);
317
+ writeNew('.env.example', `# Copy to .env and fill in real values — compose.yml reads .env automatically.
318
+ # .env itself is gitignored; this file is the committed record of what to set.
319
+ ADMIN_PASSWORD=change-me
320
+ # Random secret used to sign the session cookie (openssl rand -hex 32)
321
+ SESSION_SECRET=
322
+ # SQLite file used by the Node target (a volume mounts /app/data in Docker)
323
+ SQLITE_URL=file:./data/acms.db
272
324
  `);
273
325
  setScript('build:node', 'DEPLOY_TARGET=node astro build');
274
326
  writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
@@ -309,6 +361,8 @@ data
309
361
  Usage:
310
362
  acms update Update the CMS core to the latest release
311
363
  acms update --check Report whether an update exists (exit 1 if behind)
364
+ acms update --next Follow the canary channel (prereleases); later updates stay on it
365
+ acms update --latest Leave the canary channel and return to stable releases
312
366
  acms doctor Check this project's setup
313
367
  acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
314
368
  acms add docker Add Docker self-hosting to this project`);
package/integration.mjs CHANGED
@@ -357,6 +357,17 @@ export default function acms(options = {}) {
357
357
  vite: { resolve: { alias: { 'react-dom/server': 'react-dom/server.edge' } } },
358
358
  });
359
359
  }
360
+ // The Docker runtime image installs only @libsql/client, so the
361
+ // standalone bundle must carry every other dependency. Left to its
362
+ // default, Vite externalizes package imports (react-dom, astro's
363
+ // runtime, zod, marked, …) and the server crashes on its first
364
+ // import in the container. @libsql/client stays external: it is
365
+ // dynamically imported and ships native bindings.
366
+ if (target === 'node' && command === 'build') {
367
+ updateConfig({
368
+ vite: { ssr: { noExternal: true, external: ['@libsql/client'] } },
369
+ });
370
+ }
360
371
  },
361
372
  // fires only for `astro dev` — builds and previews never phone anywhere
362
373
  'astro:server:start': ({ logger }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -22,7 +22,7 @@
22
22
  "workers"
23
23
  ],
24
24
  "bin": {
25
- "acms": "./cli.mjs"
25
+ "acms": "cli.mjs"
26
26
  },
27
27
  "exports": {
28
28
  ".": "./integration.mjs",
@@ -48,13 +48,14 @@
48
48
  "@dnd-kit/sortable": "^10.0.0",
49
49
  "@dnd-kit/utilities": "^3.2.2",
50
50
  "@libsql/client": "^0.15.0",
51
- "@tiptap/extension-code-block-lowlight": "^3.29.2",
52
- "@tiptap/extension-table": "^3.29.2",
53
- "@tiptap/extensions": "^3.29.2",
54
- "@tiptap/pm": "^3.29.2",
55
- "@tiptap/react": "^3.29.2",
56
- "@tiptap/starter-kit": "^3.29.2",
57
- "@tiptap/suggestion": "^3.29.2",
51
+ "@tiptap/extension-code-block-lowlight": "^3.30.1",
52
+ "@tiptap/extension-highlight": "3.30.1",
53
+ "@tiptap/extension-table": "^3.30.1",
54
+ "@tiptap/extensions": "^3.30.1",
55
+ "@tiptap/pm": "^3.30.1",
56
+ "@tiptap/react": "^3.30.1",
57
+ "@tiptap/starter-kit": "^3.30.1",
58
+ "@tiptap/suggestion": "^3.30.1",
58
59
  "hast-util-to-html": "^9.0.5",
59
60
  "lowlight": "^3.3.0",
60
61
  "marked": "^15.0.0",
@@ -12,7 +12,7 @@ import { isPrivateHost } from '../lib/net';
12
12
  import { normalizeCalendarSrc } from '../lib/calendar/src';
13
13
  import { getCalendarEvents } from '../lib/calendar/ics';
14
14
  import { siteLocale } from '../lib/dates';
15
- import { normalizePageLocale } from '../lib/i18n';
15
+ import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
16
16
 
17
17
  // All admin mutations live here. The middleware only guards /admin pages,
18
18
  // so every action re-checks the session itself.
@@ -71,8 +71,17 @@ export const server = {
71
71
  if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
72
72
  throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
73
73
  }
74
- const movesUrl = data.slug !== undefined || data.collection !== undefined;
75
- const before = movesUrl ? await repo.getPost(id) : null;
74
+ const before = await repo.getPost(id);
75
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
76
+ // A live (published/scheduled) post never takes content edits
77
+ // directly: they stage into the pending draft until the user hits
78
+ // Update. Status transitions stay deliberate, immediate writes.
79
+ const transitions = data.status !== undefined && data.status !== before.status;
80
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
81
+ const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
82
+ await repo.savePostDraft(id, staged);
83
+ return { post: (await repo.getPost(id))! };
84
+ }
76
85
  await repo.updatePost(id, data);
77
86
  // any new tag names become registry entries with slugs for archives
78
87
  if (data.tags !== undefined) await repo.ensureTags(data.tags);
@@ -80,7 +89,32 @@ export const server = {
80
89
  if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
81
90
  // a published post whose slug or collection changed leaves a 301
82
91
  // behind so old links keep working
83
- if (before && before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
92
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
93
+ await repo
94
+ .createRedirect({
95
+ fromPath: collectionUrl(before.collection, before.slug),
96
+ toPath: collectionUrl(post.collection, post.slug),
97
+ })
98
+ .catch(() => {});
99
+ invalidateRedirectCache();
100
+ }
101
+ return { post };
102
+ },
103
+ }),
104
+ // Update button: copy the pending draft onto the live columns. The
105
+ // deferred side-effects (tag registry, slug-change 301) happen here —
106
+ // the moment the change actually goes live.
107
+ applyUpdate: defineAction({
108
+ input: z.object({ id: z.string() }),
109
+ handler: async ({ id }, ctx) => {
110
+ requireAdmin(ctx);
111
+ const repo = await getRepo(ctx.locals);
112
+ const before = await repo.getPost(id);
113
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
114
+ await repo.applyPostDraft(id);
115
+ const post = (await repo.getPost(id))!;
116
+ if (post.tags.length) await repo.ensureTags(post.tags);
117
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
84
118
  await repo
85
119
  .createRedirect({
86
120
  fromPath: collectionUrl(before.collection, before.slug),
@@ -92,6 +126,17 @@ export const server = {
92
126
  return { post };
93
127
  },
94
128
  }),
129
+ discardChanges: defineAction({
130
+ input: z.object({ id: z.string() }),
131
+ handler: async ({ id }, ctx) => {
132
+ requireAdmin(ctx);
133
+ const repo = await getRepo(ctx.locals);
134
+ await repo.discardPostDraft(id);
135
+ const post = await repo.getPost(id);
136
+ if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
137
+ return { post };
138
+ },
139
+ }),
95
140
  delete: defineAction({
96
141
  input: z.object({ id: z.string() }),
97
142
  handler: async ({ id }, ctx) => {
@@ -125,24 +170,59 @@ export const server = {
125
170
  if (data.slug !== undefined) data.slug = slugify(data.slug);
126
171
  // default locale stores as '', unknown codes fall back to it
127
172
  if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
128
- const before = data.slug !== undefined ? await repo.getPage(id) : null;
173
+ const before = await repo.getPage(id);
174
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
129
175
  const cleanBlocks =
130
176
  blocks === undefined
131
177
  ? undefined
132
178
  : blocks
133
179
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
134
180
  .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
181
+ // live pages stage their edits until Update, same as posts
182
+ const transitions = data.status !== undefined && data.status !== before.status;
183
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
184
+ const { status: _status, ...staged } = data;
185
+ await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
186
+ return { page: (await repo.getPage(id))! };
187
+ }
135
188
  await repo.updatePage(id, { ...data, blocks: cleanBlocks });
136
189
  const page = await repo.getPage(id);
137
190
  if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
138
191
  // renaming a published slug leaves a 301 behind so old links keep working
139
- if (before && before.status === 'published' && before.slug !== page.slug && !page.isHome) {
192
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
140
193
  await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
141
194
  invalidateRedirectCache();
142
195
  }
143
196
  return { page };
144
197
  },
145
198
  }),
199
+ applyUpdate: defineAction({
200
+ input: z.object({ id: z.string() }),
201
+ handler: async ({ id }, ctx) => {
202
+ requireAdmin(ctx);
203
+ const repo = await getRepo(ctx.locals);
204
+ const before = await repo.getPage(id);
205
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
206
+ await repo.applyPageDraft(id);
207
+ const page = (await repo.getPage(id))!;
208
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
209
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
210
+ invalidateRedirectCache();
211
+ }
212
+ return { page };
213
+ },
214
+ }),
215
+ discardChanges: defineAction({
216
+ input: z.object({ id: z.string() }),
217
+ handler: async ({ id }, ctx) => {
218
+ requireAdmin(ctx);
219
+ const repo = await getRepo(ctx.locals);
220
+ await repo.discardPageDraft(id);
221
+ const page = await repo.getPage(id);
222
+ if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
223
+ return { page };
224
+ },
225
+ }),
146
226
  delete: defineAction({
147
227
  input: z.object({ id: z.string() }),
148
228
  handler: async ({ id }, ctx) => {
@@ -309,6 +389,41 @@ export const server = {
309
389
  return { ok: true };
310
390
  },
311
391
  }),
392
+ // Design-screen staging: edits stage in settings.draftDesign (previewed
393
+ // via ?designPreview=1) until "Publish design" applies them live.
394
+ saveDesignDraft: defineAction({
395
+ input: z.object({
396
+ activeTheme: z.string().optional(),
397
+ designOverrides: z.record(z.any()).optional(),
398
+ siteLogo: z.string().optional(),
399
+ siteFavicon: z.string().optional(),
400
+ siteCover: z.string().optional(),
401
+ }),
402
+ handler: async (data, ctx) => {
403
+ requireAdmin(ctx);
404
+ const repo = await getRepo(ctx.locals);
405
+ await repo.saveDesignDraft(data);
406
+ return { ok: true };
407
+ },
408
+ }),
409
+ applyDesign: defineAction({
410
+ input: z.object({}),
411
+ handler: async (_input, ctx) => {
412
+ requireAdmin(ctx);
413
+ const repo = await getRepo(ctx.locals);
414
+ await repo.applyDesignDraft();
415
+ return { ok: true };
416
+ },
417
+ }),
418
+ discardDesign: defineAction({
419
+ input: z.object({}),
420
+ handler: async (_input, ctx) => {
421
+ requireAdmin(ctx);
422
+ const repo = await getRepo(ctx.locals);
423
+ await repo.discardDesignDraft();
424
+ return { ok: true };
425
+ },
426
+ }),
312
427
  },
313
428
 
314
429
  tags: {
@@ -322,6 +437,41 @@ export const server = {
322
437
  }),
323
438
  },
324
439
 
440
+ snippets: {
441
+ list: defineAction({
442
+ input: z.object({}),
443
+ handler: async (_input, ctx) => {
444
+ requireAdmin(ctx);
445
+ const repo = await getRepo(ctx.locals);
446
+ return { snippets: await repo.listSnippets() };
447
+ },
448
+ }),
449
+ create: defineAction({
450
+ input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
451
+ handler: async ({ name, contentJson }, ctx) => {
452
+ requireAdmin(ctx);
453
+ // must be a JSON array of ProseMirror nodes — reject anything else
454
+ // before it can break the editor on re-insert
455
+ let parsed: unknown;
456
+ try { parsed = JSON.parse(contentJson); } catch { parsed = null; }
457
+ if (!Array.isArray(parsed) || !parsed.length) {
458
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'Select some content first.' });
459
+ }
460
+ const repo = await getRepo(ctx.locals);
461
+ return { snippet: await repo.createSnippet({ name, contentJson }) };
462
+ },
463
+ }),
464
+ delete: defineAction({
465
+ input: z.object({ id: z.string() }),
466
+ handler: async ({ id }, ctx) => {
467
+ requireAdmin(ctx);
468
+ const repo = await getRepo(ctx.locals);
469
+ await repo.deleteSnippet(id);
470
+ return { ok: true };
471
+ },
472
+ }),
473
+ },
474
+
325
475
  users: {
326
476
  list: defineAction({
327
477
  input: z.object({}),
@@ -333,6 +483,34 @@ export const server = {
333
483
  }),
334
484
  },
335
485
 
486
+ linkTargets: {
487
+ // ⌘K internal link search: published posts + pages matched by title or
488
+ // URL. Drafts are excluded on purpose — linking to one would 404 until
489
+ // it's published.
490
+ search: defineAction({
491
+ input: z.object({ query: z.string().min(1) }),
492
+ handler: async ({ query }, ctx) => {
493
+ requireAdmin(ctx);
494
+ const repo = await getRepo(ctx.locals);
495
+ const q = query.toLowerCase();
496
+ const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
497
+ const targets = [
498
+ ...pages
499
+ .filter((p) => p.status === 'published')
500
+ .map((p) => ({ kind: 'Page', title: p.title, url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}` })),
501
+ ...posts
502
+ .filter((p) => p.status === 'published')
503
+ .map((p) => ({
504
+ kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
505
+ title: p.title,
506
+ url: collectionUrl(p.collection, p.slug),
507
+ })),
508
+ ].filter((t) => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q));
509
+ return { targets: targets.slice(0, 8) };
510
+ },
511
+ }),
512
+ },
513
+
336
514
  bookmark: {
337
515
  // Server-side link-metadata fetch for the bookmark card (no CORS in the
338
516
  // browser). Fetches the page, scrapes OG/meta tags, returns a compact
@@ -7,6 +7,10 @@ import { ImagePicker } from './editor/ImagePicker';
7
7
  // token overrides on top (saved to settings.designOverrides — the theme files
8
8
  // are never written to), set the site logo, and export the effective design
9
9
  // as a committable theme.json.
10
+ //
11
+ // Every change stages into settings.draftDesign — the preview iframe renders
12
+ // the staged design (?designPreview=1, admin-only) while visitors keep the
13
+ // live one until "Publish design" applies it.
10
14
 
11
15
  type SaveState = 'saved' | 'saving' | 'error';
12
16
 
@@ -33,6 +37,8 @@ interface Pairing { key: string; label: string; headingFont: string; bodyFont: s
33
37
 
34
38
  interface Props {
35
39
  settings: Settings;
40
+ // true when settings.draftDesign held staged changes at load time
41
+ pendingDesign: boolean;
36
42
  themes: ThemeInfo[];
37
43
  pairings: Pairing[];
38
44
  unsplashEnabled: boolean;
@@ -80,7 +86,8 @@ const DEVICES = [
80
86
  ] as const;
81
87
  type DeviceKey = (typeof DEVICES)[number]['key'];
82
88
 
83
- export default function DesignManager({ settings, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
89
+ export default function DesignManager({ settings, pendingDesign, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
90
+ const [pending, setPending] = useState(pendingDesign);
84
91
  const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
85
92
  const [overrides, setOverrides] = useState<Record<string, unknown>>(
86
93
  (settings.designOverrides as Record<string, unknown>) ?? {}
@@ -113,22 +120,45 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
113
120
 
114
121
  const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
115
122
 
123
+ // saves stage into settings.draftDesign — the live site is untouched
124
+ const doSave = async () => {
125
+ const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
126
+ const { error } = await actions.settings.saveDesignDraft({
127
+ activeTheme,
128
+ designOverrides: overrides,
129
+ siteLogo,
130
+ siteFavicon,
131
+ siteCover,
132
+ });
133
+ if (error) { setSaveState('error'); return false; }
134
+ setSaveState('saved');
135
+ setPending(true);
136
+ setTick((t) => t + 1);
137
+ return true;
138
+ };
139
+
116
140
  const scheduleSave = () => {
117
141
  setSaveState('saving');
118
142
  if (timer.current) clearTimeout(timer.current);
119
- timer.current = setTimeout(async () => {
120
- const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
121
- const { error } = await actions.settings.update({
122
- activeTheme,
123
- designOverrides: overrides,
124
- siteLogo,
125
- siteFavicon,
126
- siteCover,
127
- });
128
- if (error) { setSaveState('error'); return; }
129
- setSaveState('saved');
130
- setTick((t) => t + 1);
131
- }, 500);
143
+ timer.current = setTimeout(doSave, 500);
144
+ };
145
+
146
+ // Publish design: flush any queued save into the staged blob, then apply it
147
+ const publishDesign = async () => {
148
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
149
+ if (!(await doSave())) return;
150
+ const { error } = await actions.settings.applyDesign({});
151
+ if (error) { setSaveState('error'); return; }
152
+ setPending(false);
153
+ setTick((t) => t + 1);
154
+ };
155
+
156
+ const discardDesign = async () => {
157
+ if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
158
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
159
+ const { error } = await actions.settings.discardDesign({});
160
+ if (error) { setSaveState('error'); return; }
161
+ location.reload(); // rehydrate the controls from the live design
132
162
  };
133
163
 
134
164
  const setOv = (key: string, value: unknown) => {
@@ -186,7 +216,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
186
216
  <div className="dm-cardhead">
187
217
  <p className="dm-sub">
188
218
  Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code> —
189
- values below override it. Overrides apply instantly.
219
+ values below override it. Changes stage privately in the preview; press <b>Publish design</b> to go live.
190
220
  </p>
191
221
  <div className="dm-headactions">
192
222
  {overrideCount > 0 && (
@@ -195,8 +225,14 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
195
225
  </button>
196
226
  )}
197
227
  <span className={`lm-savestate ${saveState}`}>
198
- {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : 'Saved'}
228
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : pending ? 'Staged — not live yet' : 'Live design'}
199
229
  </span>
230
+ {pending && (
231
+ <>
232
+ <button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
233
+ <button className="btn sm primary" type="button" onClick={publishDesign}>Publish design</button>
234
+ </>
235
+ )}
200
236
  </div>
201
237
  </div>
202
238
 
@@ -494,7 +530,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
494
530
  <div className="dm-frame" ref={frameRef}>
495
531
  <iframe
496
532
  key={`${previewPage}-${tick}`}
497
- src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?design=${tick}`}
533
+ src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?designPreview=1&design=${tick}`}
498
534
  title="Site preview"
499
535
  style={{
500
536
  width: device.width,