gazetta 0.3.0 → 0.5.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 (72) hide show
  1. package/admin-dist/assets/index-BZAFKsUp.js +608 -0
  2. package/admin-dist/assets/index-BpRotMuK.css +1 -0
  3. package/admin-dist/assets/vendor-primevue-BnR1c_bQ.js +2097 -0
  4. package/admin-dist/assets/vendor-vue-DSjyxCX6.js +1 -0
  5. package/admin-dist/index.html +4 -4
  6. package/dist/admin-api/index.d.ts +8 -2
  7. package/dist/admin-api/index.d.ts.map +1 -1
  8. package/dist/admin-api/index.js +31 -5
  9. package/dist/admin-api/index.js.map +1 -1
  10. package/dist/admin-api/routes/compare.d.ts +2 -1
  11. package/dist/admin-api/routes/compare.d.ts.map +1 -1
  12. package/dist/admin-api/routes/compare.js +6 -1
  13. package/dist/admin-api/routes/compare.js.map +1 -1
  14. package/dist/admin-api/routes/fragments.d.ts +2 -1
  15. package/dist/admin-api/routes/fragments.d.ts.map +1 -1
  16. package/dist/admin-api/routes/fragments.js +3 -1
  17. package/dist/admin-api/routes/fragments.js.map +1 -1
  18. package/dist/admin-api/routes/pages.d.ts +2 -1
  19. package/dist/admin-api/routes/pages.d.ts.map +1 -1
  20. package/dist/admin-api/routes/pages.js +3 -1
  21. package/dist/admin-api/routes/pages.js.map +1 -1
  22. package/dist/admin-api/routes/publish.d.ts +36 -1
  23. package/dist/admin-api/routes/publish.d.ts.map +1 -1
  24. package/dist/admin-api/routes/publish.js +216 -53
  25. package/dist/admin-api/routes/publish.js.map +1 -1
  26. package/dist/cli/index.js +228 -29
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/compare.d.ts +14 -0
  29. package/dist/compare.d.ts.map +1 -1
  30. package/dist/compare.js +44 -38
  31. package/dist/compare.js.map +1 -1
  32. package/dist/concurrency.d.ts +63 -0
  33. package/dist/concurrency.d.ts.map +1 -0
  34. package/dist/concurrency.js +134 -0
  35. package/dist/concurrency.js.map +1 -0
  36. package/dist/editor/mount.js +71 -71
  37. package/dist/editor/mount.js.map +1 -1
  38. package/dist/hash.d.ts +15 -0
  39. package/dist/hash.d.ts.map +1 -1
  40. package/dist/hash.js +47 -7
  41. package/dist/hash.js.map +1 -1
  42. package/dist/publish-rendered.d.ts +6 -5
  43. package/dist/publish-rendered.d.ts.map +1 -1
  44. package/dist/publish-rendered.js +39 -44
  45. package/dist/publish-rendered.js.map +1 -1
  46. package/dist/publish.d.ts +38 -0
  47. package/dist/publish.d.ts.map +1 -1
  48. package/dist/publish.js +154 -14
  49. package/dist/publish.js.map +1 -1
  50. package/dist/sidecars.d.ts +56 -0
  51. package/dist/sidecars.d.ts.map +1 -0
  52. package/dist/sidecars.js +141 -0
  53. package/dist/sidecars.js.map +1 -0
  54. package/dist/site-loader.d.ts.map +1 -1
  55. package/dist/site-loader.js +13 -10
  56. package/dist/site-loader.js.map +1 -1
  57. package/dist/source-sidecars.d.ts +13 -0
  58. package/dist/source-sidecars.d.ts.map +1 -0
  59. package/dist/source-sidecars.js +52 -0
  60. package/dist/source-sidecars.js.map +1 -0
  61. package/dist/targets.d.ts.map +1 -1
  62. package/dist/targets.js +21 -6
  63. package/dist/targets.js.map +1 -1
  64. package/dist/types.d.ts +10 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js +4 -0
  67. package/dist/types.js.map +1 -1
  68. package/package.json +1 -1
  69. package/admin-dist/assets/index-C-xOOPUK.js +0 -604
  70. package/admin-dist/assets/index-CKOOkleR.css +0 -1
  71. package/admin-dist/assets/vendor-primevue-BNJTiXAs.js +0 -1670
  72. package/admin-dist/assets/vendor-vue-GhBrpzPN.js +0 -1
package/dist/cli/index.js CHANGED
@@ -26,6 +26,80 @@ const c = {
26
26
  };
27
27
  const args = process.argv.slice(2);
28
28
  const command = args[0];
29
+ // Served to /admin/* requests during dev-server startup before Vite middleware
30
+ // is attached. Polls /admin/ping every 500ms and reloads when the admin becomes
31
+ // reachable. See #132 and cli/index.ts for why this is needed.
32
+ const LOADER_HTML = `<!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <title>Loading Gazetta admin…</title>
37
+ <style>
38
+ /* Neutral tones — the loader is a transitional state and we don't yet know
39
+ whether the user has customized the admin theme. Mid-gray reads as
40
+ "loading" on any conceivable admin background. */
41
+ html, body { height: 100%; margin: 0; overflow: hidden; }
42
+ body { font-family: system-ui, -apple-system, sans-serif; background: #262626; color: #a3a3a3; transition: opacity 200ms ease; position: relative; }
43
+ body.light { background: #f5f5f5; color: #525252; }
44
+ body.leaving { opacity: 0; }
45
+
46
+ /* Watermark — full-width brand fill, faint so it reads as background. */
47
+ .brand {
48
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
49
+ font-weight: 800; letter-spacing: -0.04em; color: currentColor; opacity: 0.08;
50
+ font-size: clamp(6rem, 22vw, 18rem); line-height: 1;
51
+ pointer-events: none; user-select: none;
52
+ }
53
+
54
+ /* Status pill — top-left, fades in only if startup is slow. */
55
+ .progress {
56
+ position: absolute; top: 1rem; left: 1rem;
57
+ display: flex; align-items: center; gap: 0.5rem;
58
+ font-size: 0.8125rem; opacity: 0; transition: opacity 300ms ease;
59
+ }
60
+ .progress.shown { opacity: 0.75; }
61
+ .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
62
+ @keyframes spin { to { transform: rotate(360deg); } }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="brand" aria-hidden="true">GAZETTA</div>
67
+ <div class="progress" role="status" aria-live="polite">
68
+ <div class="spinner" aria-hidden="true"></div>
69
+ <span class="label">Starting admin…</span>
70
+ </div>
71
+ <script>
72
+ // Match the user's saved admin theme if present; fall back to dark default
73
+ // (admin defaults to dark on first load).
74
+ try {
75
+ var saved = localStorage.getItem('gazetta_theme')
76
+ if (saved === 'light') document.body.classList.add('light')
77
+ } catch (e) { /* ignore */ }
78
+
79
+ // Delay the spinner + label by 400ms so warm restarts never paint them.
80
+ // The brand wordmark is always visible — it's safe; nothing about it
81
+ // implies "this is taking a while".
82
+ var progress = document.querySelector('.progress')
83
+ var showTimer = setTimeout(function () { progress.classList.add('shown') }, 400)
84
+
85
+ ;(function poll() {
86
+ fetch('/admin/ping', { cache: 'no-store' })
87
+ .then(function (r) { return r.ok ? r.json() : null })
88
+ .then(function (j) {
89
+ if (j && j.ready) {
90
+ clearTimeout(showTimer)
91
+ // Fade body out, then reload — softer handoff than an instant swap
92
+ document.body.classList.add('leaving')
93
+ setTimeout(function () { window.location.reload() }, 200)
94
+ } else {
95
+ setTimeout(poll, 500)
96
+ }
97
+ })
98
+ .catch(function () { setTimeout(poll, 500) })
99
+ })()
100
+ </script>
101
+ </body>
102
+ </html>`;
29
103
  /**
30
104
  * Detect the project root from a site directory.
31
105
  * Walks up from siteDir looking for a parent that contains templates/.
@@ -64,6 +138,7 @@ function printHelp() {
64
138
 
65
139
  Options:
66
140
  --port, -p <port> Server port (default: 3000)
141
+ --force, -f Publish all items (skip unchanged check)
67
142
 
68
143
  Auto-detection:
69
144
  Site is auto-detected from sites/ directory. If multiple sites exist,
@@ -85,15 +160,19 @@ function printHelp() {
85
160
  function parseArgs(input) {
86
161
  const positional = [];
87
162
  let port;
163
+ let force = false;
88
164
  for (let i = 0; i < input.length; i++) {
89
165
  if (input[i] === '--port' || input[i] === '-p') {
90
166
  port = parseInt(input[++i], 10);
91
167
  }
168
+ else if (input[i] === '--force' || input[i] === '-f') {
169
+ force = true;
170
+ }
92
171
  else if (!input[i].startsWith('-')) {
93
172
  positional.push(input[i]);
94
173
  }
95
174
  }
96
- return { positional, port };
175
+ return { positional, port, force };
97
176
  }
98
177
  /**
99
178
  * Resolve the site directory from positional args or auto-detection.
@@ -334,7 +413,7 @@ export default template
334
413
  const cdStep = dir !== '.' ? `cd ${dir} && ` : '';
335
414
  outro(`Done! Run: ${c.cyan(`${cdStep}npx gazetta dev`)}`);
336
415
  }
337
- async function runPublish(siteDir, targetName) {
416
+ async function runPublish(siteDir, targetName, opts = {}) {
338
417
  const storage = createFilesystemProvider();
339
418
  const projectRoot = detectProjectRoot(siteDir);
340
419
  const templatesDir = join(projectRoot, 'templates');
@@ -396,11 +475,40 @@ async function runPublish(siteDir, targetName) {
396
475
  console.log(` ${c.bold(name)} ${c.dim(`(${publishMode})`)}`);
397
476
  let totalFiles = 0;
398
477
  let totalRemoved = 0;
478
+ // Incremental: compare source hashes against target sidecars, skip
479
+ // items whose hash already matches the target. --force bypasses.
480
+ const unchanged = new Set();
481
+ if (!opts.force) {
482
+ const { compareTargets } = await import('../compare.js');
483
+ const cmp = await compareTargets({
484
+ source: storage,
485
+ target: targetStorage,
486
+ siteDir,
487
+ templatesDir,
488
+ projectRoot,
489
+ publishMode,
490
+ scanTemplates: async () => templateInfos,
491
+ });
492
+ for (const item of cmp.unchanged)
493
+ unchanged.add(item);
494
+ }
495
+ let skipped = 0;
399
496
  if (isStatic) {
400
- // Static mode — fully assembled HTML, no fragments needed separately
497
+ // Static mode — fully assembled HTML, no fragments needed separately.
498
+ // Page hash must include fragment hashes so a fragment change
499
+ // invalidates every page that bakes it in (compareTargets uses the
500
+ // same combination on the local side).
501
+ const fragmentHashes = new Map();
502
+ for (const [fragName, frag] of site.fragments) {
503
+ fragmentHashes.set(fragName, hashManifest(frag, { templateHashes }));
504
+ }
401
505
  for (const [pageName, page] of site.pages) {
402
- const manifestHash = hashManifest(page, { templateHashes });
403
- const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir, manifestHash);
506
+ if (unchanged.has(`pages/${pageName}`)) {
507
+ skipped++;
508
+ continue;
509
+ }
510
+ const manifestHash = hashManifest(page, { templateHashes, fragmentHashes });
511
+ const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir, manifestHash, site);
404
512
  totalFiles += files;
405
513
  console.log(` ${c.green('✓')} ${pageName}`);
406
514
  }
@@ -408,23 +516,33 @@ async function runPublish(siteDir, targetName) {
408
516
  else {
409
517
  // ESI mode — fragments separate, pages with placeholders
410
518
  for (const [fragName, frag] of site.fragments) {
519
+ if (unchanged.has(`fragments/${fragName}`)) {
520
+ skipped++;
521
+ continue;
522
+ }
411
523
  const manifestHash = hashManifest(frag, { templateHashes });
412
- const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir, manifestHash);
524
+ const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir, manifestHash, site);
413
525
  totalFiles += files;
414
526
  totalRemoved += removed;
415
527
  console.log(` ${c.green('✓')} @${fragName}`);
416
528
  }
417
529
  for (const [pageName, page] of site.pages) {
530
+ if (unchanged.has(`pages/${pageName}`)) {
531
+ skipped++;
532
+ continue;
533
+ }
418
534
  const manifestHash = hashManifest(page, { templateHashes });
419
- const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir, manifestHash);
535
+ const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir, manifestHash, site);
420
536
  totalFiles += files;
421
537
  totalRemoved += removed;
422
538
  console.log(` ${c.green('✓')} ${pageName}`);
423
539
  }
424
540
  }
541
+ if (skipped > 0)
542
+ console.log(` ${c.dim(`· ${skipped} unchanged (skipped)`)}`);
425
543
  // Site manifest + fragment index
426
- await publishSiteManifest(storage, siteDir, targetStorage);
427
- await publishFragmentIndex(storage, siteDir, targetStorage);
544
+ await publishSiteManifest(storage, siteDir, targetStorage, site);
545
+ await publishFragmentIndex(storage, siteDir, targetStorage, site);
428
546
  totalFiles += 2;
429
547
  const removedMsg = totalRemoved > 0 ? c.dim(` (${totalRemoved} old files cleaned)`) : '';
430
548
  console.log(`\n ${c.green('✓')} ${c.bold(name)}: ${totalFiles} files published${removedMsg}\n`);
@@ -943,13 +1061,16 @@ async function runDev(siteDir, port) {
943
1061
  const cmsWebDir = findCmsDir();
944
1062
  const cmsStaticDir = findCmsStaticDir();
945
1063
  const isDevMode = cmsWebDir !== null;
1064
+ // Admin Hono instance — captured so the template file watcher can
1065
+ // invalidate its memoized template-scan cache on .ts/.tsx changes.
1066
+ let cmsApp = null;
946
1067
  if (isDevMode) {
947
1068
  // Dev mode: mount CMS API inline (same process = shared template cache)
948
- await setupCmsApi(app, siteDir, storage, templatesDir, adminDir);
1069
+ cmsApp = await setupCmsApi(app, siteDir, storage, templatesDir, adminDir);
949
1070
  }
950
1071
  else if (cmsStaticDir) {
951
1072
  // Production mode: inline CMS API + static files
952
- await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
1073
+ cmsApp = await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
953
1074
  }
954
1075
  // ---- 404 ----
955
1076
  app.notFound((c) => {
@@ -972,6 +1093,38 @@ async function runDev(siteDir, port) {
972
1093
  console.log(` ${c.dim('┃')} Pages ${[...site.pages.entries()].map(([n, p]) => `${c.dim(p.route)} ${c.dim('→')} ${n}`).join(c.dim(', '))}`);
973
1094
  console.log(` ${c.dim('┃')} Frags ${c.dim([...site.fragments.keys()].join(', ') || '(none)')}`);
974
1095
  if (isDevMode && cmsWebDir) {
1096
+ // While Vite is spinning up (compiling, scanning deps, attaching
1097
+ // middleware), any /admin/* request falls through to the site's 404
1098
+ // handler showing a raw page list. Intercept early and serve a loader
1099
+ // page that polls /admin/ping until ready, then reloads (#132).
1100
+ let cmsReady = false;
1101
+ const httpServer = nodeServer;
1102
+ const originalListeners = httpServer.listeners('request').slice();
1103
+ httpServer.removeAllListeners('request');
1104
+ const loaderHandler = (req, res) => {
1105
+ const url = req.url ?? '';
1106
+ if (url === '/admin/ping' || url.startsWith('/admin/ping?')) {
1107
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1108
+ res.end(JSON.stringify({ ready: cmsReady }));
1109
+ return true;
1110
+ }
1111
+ if (url === '/admin' || url.startsWith('/admin/') || url.startsWith('/@')) {
1112
+ res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Retry-After': '2' });
1113
+ res.end(LOADER_HTML);
1114
+ return true;
1115
+ }
1116
+ return false;
1117
+ };
1118
+ // During startup: route /admin/* to the loader, everything else to Hono
1119
+ httpServer.on('request', (req, res) => {
1120
+ if (cmsReady)
1121
+ return; // real handler installed below will run
1122
+ if (loaderHandler(req, res))
1123
+ return;
1124
+ for (const l of originalListeners) {
1125
+ l(req, res);
1126
+ }
1127
+ });
975
1128
  try {
976
1129
  const { createServer: createViteServer } = await import('vite');
977
1130
  const { searchForWorkspaceRoot } = await import('vite');
@@ -1023,17 +1176,22 @@ async function runDev(siteDir, port) {
1023
1176
  fs: { allow: [searchForWorkspaceRoot(cmsWebDir), siteDir] },
1024
1177
  },
1025
1178
  });
1026
- const httpServer = nodeServer;
1027
- const originalListeners = httpServer.listeners('request').slice();
1028
- httpServer.removeAllListeners('request');
1029
1179
  const honoHandler = (req, res) => {
1030
1180
  for (const listener of originalListeners) {
1031
1181
  listener(req, res);
1032
1182
  }
1033
1183
  };
1184
+ httpServer.removeAllListeners('request');
1034
1185
  httpServer.on('request', (req, res) => {
1035
1186
  const url = req.url ?? '';
1036
- if (url.startsWith('/admin/api') || url.startsWith('/admin/preview')) {
1187
+ // Keep /admin/ping live so the loader page can continue polling —
1188
+ // useful if the server hot-reloads and Vite rebinds.
1189
+ if (url === '/admin/ping' || url.startsWith('/admin/ping?')) {
1190
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1191
+ res.end(JSON.stringify({ ready: true }));
1192
+ return;
1193
+ }
1194
+ if (url.startsWith('/admin/api') || url.startsWith('/admin/preview') || url === '/admin/theme.css' || url.startsWith('/admin/theme.css?')) {
1037
1195
  honoHandler(req, res);
1038
1196
  }
1039
1197
  else if (url.startsWith('/admin') || url.startsWith('/@')) {
@@ -1043,6 +1201,7 @@ async function runDev(siteDir, port) {
1043
1201
  honoHandler(req, res);
1044
1202
  }
1045
1203
  });
1204
+ cmsReady = true;
1046
1205
  }
1047
1206
  catch (err) {
1048
1207
  console.warn(` Warning: CMS UI failed to start: ${err.message}`);
@@ -1051,19 +1210,34 @@ async function runDev(siteDir, port) {
1051
1210
  console.log();
1052
1211
  });
1053
1212
  // ---- File watching ----
1054
- // Watch site dir for content changes (JSON manifests + site.yaml config)
1055
- watch(siteDir, { recursive: true }, (_event, filename) => {
1213
+ // Watch site dir for content changes (JSON manifests + site.yaml config).
1214
+ // Swallow FSWatcher 'error' events Node's recursive watcher throws ENOENT
1215
+ // when a watched subdir disappears (rm -rf during publish, git checkout).
1216
+ // Letting it crash would take the whole dev server down; logging a warning
1217
+ // is enough, the watcher recovers for still-existing paths.
1218
+ const siteWatcher = watch(siteDir, { recursive: true }, (_event, filename) => {
1056
1219
  if (!filename)
1057
1220
  return;
1058
1221
  if (filename.endsWith('.json') || filename.endsWith('.yaml')) {
1059
1222
  console.log(` Manifest changed: ${filename}`);
1060
1223
  invalidateAllTemplates();
1224
+ // Refresh source sidecars for external edits (git pull, direct file
1225
+ // edit). PUT routes already handle their own writes — this catches
1226
+ // everything outside the admin UI.
1227
+ const norm = filename.replace(/\\/g, '/');
1228
+ const pageMatch = /^pages\/(.+)\/page\.json$/.exec(norm);
1229
+ const fragMatch = /^fragments\/(.+)\/fragment\.json$/.exec(norm);
1230
+ if (pageMatch)
1231
+ cmsApp?.writeSourceSidecar('page', pageMatch[1]).catch(() => { });
1232
+ else if (fragMatch)
1233
+ cmsApp?.writeSourceSidecar('fragment', fragMatch[1]).catch(() => { });
1061
1234
  notifyReload();
1062
1235
  }
1063
1236
  });
1237
+ siteWatcher.on('error', (err) => console.warn(` File watcher warning (site): ${err.message}`));
1064
1238
  // Watch templates dir for template source changes
1065
1239
  if (existsSync(templatesDir)) {
1066
- watch(templatesDir, { recursive: true }, (_event, filename) => {
1240
+ const tplWatcher = watch(templatesDir, { recursive: true }, (_event, filename) => {
1067
1241
  if (!filename)
1068
1242
  return;
1069
1243
  if (filename.endsWith('.ts') || filename.endsWith('.tsx')) {
@@ -1071,13 +1245,39 @@ async function runDev(siteDir, port) {
1071
1245
  if (parts.length >= 1) {
1072
1246
  console.log(` Template changed: ${parts[0]}`);
1073
1247
  invalidateTemplate(parts[0]);
1248
+ // Drop the admin-api's cached scan so next compare/publish
1249
+ // rehashes. Cheap (the scan is what's slow, not invalidation).
1250
+ cmsApp?.invalidateTemplatesCache();
1251
+ cmsApp?.invalidateSourceSidecars();
1074
1252
  notifyReload();
1075
1253
  }
1076
1254
  }
1077
1255
  });
1256
+ tplWatcher.on('error', (err) => console.warn(` File watcher warning (templates): ${err.message}`));
1078
1257
  }
1079
1258
  }
1080
1259
  // ---- Mount CMS API on the main Hono app (shared process = shared template cache) ----
1260
+ /**
1261
+ * Mount a Hono route serving the user's admin/theme.css. 404 if not present.
1262
+ * Cache-Control no-cache so devs see edits immediately.
1263
+ *
1264
+ * The link tag is added at runtime by main.ts (after PrimeVue + tokens.css)
1265
+ * so user declarations win the cascade. See #134 and css-theming.md.
1266
+ */
1267
+ function mountUserThemeRoute(cmsApp, adminDir) {
1268
+ cmsApp.get('/theme.css', (c) => {
1269
+ const themePath = join(adminDir, 'theme.css');
1270
+ c.header('Content-Type', 'text/css; charset=utf-8');
1271
+ c.header('Cache-Control', 'no-cache');
1272
+ // When the user hasn't authored a theme.css, return an empty 200 rather
1273
+ // than a 404. The link tag in main.ts always references this URL; a 404
1274
+ // would log a browser console error on every cold load, polluting error
1275
+ // reports and failing strict console-error checks.
1276
+ if (!existsSync(themePath))
1277
+ return c.body('');
1278
+ return c.body(readFileSync(themePath, 'utf-8'));
1279
+ });
1280
+ }
1081
1281
  async function setupCmsApi(app, siteDir, storage, templatesDir, adminDir) {
1082
1282
  const siteYamlPath = join(siteDir, 'site.yaml');
1083
1283
  let targetConfigs;
@@ -1086,7 +1286,9 @@ async function setupCmsApi(app, siteDir, storage, templatesDir, adminDir) {
1086
1286
  targetConfigs = siteYaml.targets;
1087
1287
  }
1088
1288
  const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, targetConfigs });
1289
+ mountUserThemeRoute(cmsApp, adminDir);
1089
1290
  app.route('/admin', cmsApp);
1291
+ return cmsApp;
1090
1292
  }
1091
1293
  // ---- Production mode: inline CMS API + static files from admin-dist/ ----
1092
1294
  async function setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir) {
@@ -1099,27 +1301,24 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir, template
1099
1301
  }
1100
1302
  // Mount CMS API inline at /admin (production mode — bundled editors/fields)
1101
1303
  const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, production: true, targetConfigs });
1304
+ mountUserThemeRoute(cmsApp, adminDir);
1102
1305
  app.route('/admin', cmsApp);
1103
1306
  // Serve pre-built CMS static files (includes bundled editors/fields)
1104
1307
  app.use('/admin/*', serveStatic({
1105
1308
  root: cmsStaticDir,
1106
1309
  rewriteRequestPath: (path) => path.replace(/^\/admin/, ''),
1107
1310
  }));
1108
- // SPA fallback: serve index.html for unmatched /admin routes
1109
- app.get('/admin/*', (c) => {
1311
+ // SPA fallback: serve index.html for /admin and unmatched /admin/* routes
1312
+ const serveIndex = (c) => {
1110
1313
  const indexPath = join(cmsStaticDir, 'index.html');
1111
1314
  if (existsSync(indexPath)) {
1112
1315
  return c.html(readFileSync(indexPath, 'utf-8'));
1113
1316
  }
1114
1317
  return c.text('CMS admin UI not found', 404);
1115
- });
1116
- app.get('/admin', (c) => {
1117
- const indexPath = join(cmsStaticDir, 'index.html');
1118
- if (existsSync(indexPath)) {
1119
- return c.html(readFileSync(indexPath, 'utf-8'));
1120
- }
1121
- return c.text('CMS admin UI not found', 404);
1122
- });
1318
+ };
1319
+ app.get('/admin/*', serveIndex);
1320
+ app.get('/admin', serveIndex);
1321
+ return cmsApp;
1123
1322
  }
1124
1323
  /** Find apps/admin source dir (monorepo dev mode) */
1125
1324
  function findCmsDir() {
@@ -1209,7 +1408,7 @@ async function main() {
1209
1408
  }
1210
1409
  switch (command) {
1211
1410
  case 'publish':
1212
- await runPublish(siteDir, targetName);
1411
+ await runPublish(siteDir, targetName, { force: parsed.force });
1213
1412
  break;
1214
1413
  case 'serve':
1215
1414
  await runServe(siteDir, parsed.port ?? 3000, targetName);