gazetta 0.4.0 → 0.6.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 (240) hide show
  1. package/admin-dist/assets/index-B6pVot0Y.css +1 -0
  2. package/admin-dist/assets/index-DniLwxJA.js +609 -0
  3. package/admin-dist/assets/{vendor-primevue-BnR1c_bQ.js → vendor-primevue-C0Q_YTCb.js} +330 -431
  4. package/admin-dist/assets/vendor-vue-D3wBSmDf.js +1 -0
  5. package/admin-dist/index.html +4 -4
  6. package/dist/admin-api/index.d.ts +26 -5
  7. package/dist/admin-api/index.d.ts.map +1 -1
  8. package/dist/admin-api/index.js +175 -13
  9. package/dist/admin-api/index.js.map +1 -1
  10. package/dist/admin-api/routes/compare.d.ts +3 -1
  11. package/dist/admin-api/routes/compare.d.ts.map +1 -1
  12. package/dist/admin-api/routes/compare.js +34 -24
  13. package/dist/admin-api/routes/compare.js.map +1 -1
  14. package/dist/admin-api/routes/fields.d.ts +2 -2
  15. package/dist/admin-api/routes/fields.d.ts.map +1 -1
  16. package/dist/admin-api/routes/fields.js +10 -3
  17. package/dist/admin-api/routes/fields.js.map +1 -1
  18. package/dist/admin-api/routes/fragments.d.ts +2 -2
  19. package/dist/admin-api/routes/fragments.d.ts.map +1 -1
  20. package/dist/admin-api/routes/fragments.js +94 -19
  21. package/dist/admin-api/routes/fragments.js.map +1 -1
  22. package/dist/admin-api/routes/history.d.ts +23 -0
  23. package/dist/admin-api/routes/history.d.ts.map +1 -0
  24. package/dist/admin-api/routes/history.js +143 -0
  25. package/dist/admin-api/routes/history.js.map +1 -0
  26. package/dist/admin-api/routes/pages.d.ts +2 -2
  27. package/dist/admin-api/routes/pages.d.ts.map +1 -1
  28. package/dist/admin-api/routes/pages.js +120 -20
  29. package/dist/admin-api/routes/pages.js.map +1 -1
  30. package/dist/admin-api/routes/preview.d.ts +2 -2
  31. package/dist/admin-api/routes/preview.d.ts.map +1 -1
  32. package/dist/admin-api/routes/preview.js +50 -15
  33. package/dist/admin-api/routes/preview.js.map +1 -1
  34. package/dist/admin-api/routes/publish.d.ts +3 -1
  35. package/dist/admin-api/routes/publish.d.ts.map +1 -1
  36. package/dist/admin-api/routes/publish.js +306 -65
  37. package/dist/admin-api/routes/publish.js.map +1 -1
  38. package/dist/admin-api/routes/site.d.ts +2 -2
  39. package/dist/admin-api/routes/site.d.ts.map +1 -1
  40. package/dist/admin-api/routes/site.js +27 -4
  41. package/dist/admin-api/routes/site.js.map +1 -1
  42. package/dist/admin-api/routes/templates.d.ts +2 -2
  43. package/dist/admin-api/routes/templates.d.ts.map +1 -1
  44. package/dist/admin-api/routes/templates.js +19 -9
  45. package/dist/admin-api/routes/templates.js.map +1 -1
  46. package/dist/admin-api/schemas/compare.d.ts +29 -0
  47. package/dist/admin-api/schemas/compare.d.ts.map +1 -0
  48. package/dist/admin-api/schemas/compare.js +30 -0
  49. package/dist/admin-api/schemas/compare.js.map +1 -0
  50. package/dist/admin-api/schemas/dependents.d.ts +15 -0
  51. package/dist/admin-api/schemas/dependents.d.ts.map +1 -0
  52. package/dist/admin-api/schemas/dependents.js +14 -0
  53. package/dist/admin-api/schemas/dependents.js.map +1 -0
  54. package/dist/admin-api/schemas/fetch.d.ts +12 -0
  55. package/dist/admin-api/schemas/fetch.d.ts.map +1 -0
  56. package/dist/admin-api/schemas/fetch.js +11 -0
  57. package/dist/admin-api/schemas/fetch.js.map +1 -0
  58. package/dist/admin-api/schemas/fields.d.ts +11 -0
  59. package/dist/admin-api/schemas/fields.d.ts.map +1 -0
  60. package/dist/admin-api/schemas/fields.js +11 -0
  61. package/dist/admin-api/schemas/fields.js.map +1 -0
  62. package/dist/admin-api/schemas/fragments.d.ts +27 -0
  63. package/dist/admin-api/schemas/fragments.d.ts.map +1 -0
  64. package/dist/admin-api/schemas/fragments.js +26 -0
  65. package/dist/admin-api/schemas/fragments.js.map +1 -0
  66. package/dist/admin-api/schemas/history.d.ts +73 -0
  67. package/dist/admin-api/schemas/history.d.ts.map +1 -0
  68. package/dist/admin-api/schemas/history.js +35 -0
  69. package/dist/admin-api/schemas/history.js.map +1 -0
  70. package/dist/admin-api/schemas/index.d.ts +32 -0
  71. package/dist/admin-api/schemas/index.d.ts.map +1 -0
  72. package/dist/admin-api/schemas/index.js +32 -0
  73. package/dist/admin-api/schemas/index.js.map +1 -0
  74. package/dist/admin-api/schemas/pages.d.ts +46 -0
  75. package/dist/admin-api/schemas/pages.d.ts.map +1 -0
  76. package/dist/admin-api/schemas/pages.js +47 -0
  77. package/dist/admin-api/schemas/pages.js.map +1 -0
  78. package/dist/admin-api/schemas/publish.d.ts +67 -0
  79. package/dist/admin-api/schemas/publish.d.ts.map +1 -0
  80. package/dist/admin-api/schemas/publish.js +60 -0
  81. package/dist/admin-api/schemas/publish.js.map +1 -0
  82. package/dist/admin-api/schemas/site.d.ts +28 -0
  83. package/dist/admin-api/schemas/site.d.ts.map +1 -0
  84. package/dist/admin-api/schemas/site.js +24 -0
  85. package/dist/admin-api/schemas/site.js.map +1 -0
  86. package/dist/admin-api/schemas/targets.d.ts +36 -0
  87. package/dist/admin-api/schemas/targets.d.ts.map +1 -0
  88. package/dist/admin-api/schemas/targets.js +19 -0
  89. package/dist/admin-api/schemas/targets.js.map +1 -0
  90. package/dist/admin-api/schemas/templates.d.ts +17 -0
  91. package/dist/admin-api/schemas/templates.d.ts.map +1 -0
  92. package/dist/admin-api/schemas/templates.js +16 -0
  93. package/dist/admin-api/schemas/templates.js.map +1 -0
  94. package/dist/admin-api/source-context.d.ts +165 -0
  95. package/dist/admin-api/source-context.d.ts.map +1 -0
  96. package/dist/admin-api/source-context.js +95 -0
  97. package/dist/admin-api/source-context.js.map +1 -0
  98. package/dist/app.js +1 -1
  99. package/dist/app.js.map +1 -1
  100. package/dist/assemble.d.ts.map +1 -1
  101. package/dist/assemble.js +4 -1
  102. package/dist/assemble.js.map +1 -1
  103. package/dist/cli/bootstrap.d.ts +48 -0
  104. package/dist/cli/bootstrap.d.ts.map +1 -0
  105. package/dist/cli/bootstrap.js +85 -0
  106. package/dist/cli/bootstrap.js.map +1 -0
  107. package/dist/cli/history.d.ts +45 -0
  108. package/dist/cli/history.d.ts.map +1 -0
  109. package/dist/cli/history.js +165 -0
  110. package/dist/cli/history.js.map +1 -0
  111. package/dist/cli/index.js +691 -113
  112. package/dist/cli/index.js.map +1 -1
  113. package/dist/compare.d.ts +15 -5
  114. package/dist/compare.d.ts.map +1 -1
  115. package/dist/compare.js +83 -45
  116. package/dist/compare.js.map +1 -1
  117. package/dist/concurrency.d.ts +63 -0
  118. package/dist/concurrency.d.ts.map +1 -0
  119. package/dist/concurrency.js +134 -0
  120. package/dist/concurrency.js.map +1 -0
  121. package/dist/content-root.d.ts +38 -0
  122. package/dist/content-root.d.ts.map +1 -0
  123. package/dist/content-root.js +29 -0
  124. package/dist/content-root.js.map +1 -0
  125. package/dist/editor/mount.d.ts +1 -1
  126. package/dist/editor/mount.d.ts.map +1 -1
  127. package/dist/editor/mount.js +61 -29
  128. package/dist/editor/mount.js.map +1 -1
  129. package/dist/hash.d.ts +47 -1
  130. package/dist/hash.d.ts.map +1 -1
  131. package/dist/hash.js +107 -10
  132. package/dist/hash.js.map +1 -1
  133. package/dist/history-provider.d.ts +49 -0
  134. package/dist/history-provider.d.ts.map +1 -0
  135. package/dist/history-provider.js +226 -0
  136. package/dist/history-provider.js.map +1 -0
  137. package/dist/history-recorder.d.ts +98 -0
  138. package/dist/history-recorder.d.ts.map +1 -0
  139. package/dist/history-recorder.js +160 -0
  140. package/dist/history-recorder.js.map +1 -0
  141. package/dist/history-restorer.d.ts +46 -0
  142. package/dist/history-restorer.d.ts.map +1 -0
  143. package/dist/history-restorer.js +105 -0
  144. package/dist/history-restorer.js.map +1 -0
  145. package/dist/history.d.ts +111 -0
  146. package/dist/history.d.ts.map +1 -0
  147. package/dist/history.js +25 -0
  148. package/dist/history.js.map +1 -0
  149. package/dist/index.d.ts +26 -4
  150. package/dist/index.d.ts.map +1 -1
  151. package/dist/index.js +16 -3
  152. package/dist/index.js.map +1 -1
  153. package/dist/locale.d.ts +74 -0
  154. package/dist/locale.d.ts.map +1 -0
  155. package/dist/locale.js +150 -0
  156. package/dist/locale.js.map +1 -0
  157. package/dist/manifest.d.ts.map +1 -1
  158. package/dist/manifest.js +16 -1
  159. package/dist/manifest.js.map +1 -1
  160. package/dist/providers/azure-blob.d.ts.map +1 -1
  161. package/dist/providers/azure-blob.js.map +1 -1
  162. package/dist/providers/r2.d.ts.map +1 -1
  163. package/dist/providers/r2.js +7 -4
  164. package/dist/providers/r2.js.map +1 -1
  165. package/dist/providers/s3.d.ts.map +1 -1
  166. package/dist/providers/s3.js +23 -15
  167. package/dist/providers/s3.js.map +1 -1
  168. package/dist/publish-locale.d.ts +44 -0
  169. package/dist/publish-locale.d.ts.map +1 -0
  170. package/dist/publish-locale.js +103 -0
  171. package/dist/publish-locale.js.map +1 -0
  172. package/dist/publish-rendered.d.ts +17 -5
  173. package/dist/publish-rendered.d.ts.map +1 -1
  174. package/dist/publish-rendered.js +114 -66
  175. package/dist/publish-rendered.js.map +1 -1
  176. package/dist/publish.d.ts +39 -3
  177. package/dist/publish.d.ts.map +1 -1
  178. package/dist/publish.js +166 -17
  179. package/dist/publish.js.map +1 -1
  180. package/dist/renderer.d.ts +14 -4
  181. package/dist/renderer.d.ts.map +1 -1
  182. package/dist/renderer.js +35 -23
  183. package/dist/renderer.js.map +1 -1
  184. package/dist/resolver.d.ts +7 -2
  185. package/dist/resolver.d.ts.map +1 -1
  186. package/dist/resolver.js +66 -15
  187. package/dist/resolver.js.map +1 -1
  188. package/dist/robots.d.ts +22 -0
  189. package/dist/robots.d.ts.map +1 -0
  190. package/dist/robots.js +25 -0
  191. package/dist/robots.js.map +1 -0
  192. package/dist/seo.d.ts +56 -0
  193. package/dist/seo.d.ts.map +1 -0
  194. package/dist/seo.js +72 -0
  195. package/dist/seo.js.map +1 -0
  196. package/dist/serve.d.ts +41 -3
  197. package/dist/serve.d.ts.map +1 -1
  198. package/dist/serve.js +206 -65
  199. package/dist/serve.js.map +1 -1
  200. package/dist/sidecars.d.ts +60 -0
  201. package/dist/sidecars.d.ts.map +1 -0
  202. package/dist/sidecars.js +231 -0
  203. package/dist/sidecars.js.map +1 -0
  204. package/dist/site-loader.d.ts +74 -6
  205. package/dist/site-loader.d.ts.map +1 -1
  206. package/dist/site-loader.js +149 -36
  207. package/dist/site-loader.js.map +1 -1
  208. package/dist/sitemap.d.ts +45 -0
  209. package/dist/sitemap.d.ts.map +1 -0
  210. package/dist/sitemap.js +67 -0
  211. package/dist/sitemap.js.map +1 -0
  212. package/dist/source-sidecars.d.ts +32 -0
  213. package/dist/source-sidecars.d.ts.map +1 -0
  214. package/dist/source-sidecars.js +98 -0
  215. package/dist/source-sidecars.js.map +1 -0
  216. package/dist/targets.d.ts +47 -1
  217. package/dist/targets.d.ts.map +1 -1
  218. package/dist/targets.js +78 -9
  219. package/dist/targets.js.map +1 -1
  220. package/dist/template-loader.d.ts +7 -3
  221. package/dist/template-loader.d.ts.map +1 -1
  222. package/dist/template-loader.js +27 -12
  223. package/dist/template-loader.js.map +1 -1
  224. package/dist/templates-scan-worker.js +1 -1
  225. package/dist/templates-scan-worker.js.map +1 -1
  226. package/dist/templates-scan.d.ts.map +1 -1
  227. package/dist/templates-scan.js +1 -1
  228. package/dist/templates-scan.js.map +1 -1
  229. package/dist/types.d.ts +116 -9
  230. package/dist/types.d.ts.map +1 -1
  231. package/dist/types.js +28 -5
  232. package/dist/types.js.map +1 -1
  233. package/dist/workers/cloudflare-r2.d.ts +11 -2
  234. package/dist/workers/cloudflare-r2.d.ts.map +1 -1
  235. package/dist/workers/cloudflare-r2.js +120 -55
  236. package/dist/workers/cloudflare-r2.js.map +1 -1
  237. package/package.json +11 -2
  238. package/admin-dist/assets/index-Bh_y1d_l.css +0 -1
  239. package/admin-dist/assets/index-DjGNi6yy.js +0 -608
  240. package/admin-dist/assets/vendor-vue-DSjyxCX6.js +0 -1
package/dist/cli/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { resolve, join, dirname } from 'node:path';
2
+ import { resolve, join, dirname, relative } from 'node:path';
3
3
  import { watch, existsSync, readFileSync } from 'node:fs';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { serveStatic } from '@hono/node-server/serve-static';
@@ -11,18 +11,19 @@ import { resolvePage } from '../resolver.js';
11
11
  import { renderPage } from '../renderer.js';
12
12
  import { createFilesystemProvider } from '../providers/filesystem.js';
13
13
  import { invalidateTemplate, invalidateAllTemplates } from '../template-loader.js';
14
+ import { getEnvironment, getType, isEditable } from '../types.js';
14
15
  import { createAdminApp } from '../admin-api/index.js';
15
16
  // ANSI color helpers — no dependency, suppressed when NO_COLOR or CI
16
17
  const noColor = !!process.env.NO_COLOR || !process.stdout.isTTY;
17
18
  const c = {
18
- bold: (s) => noColor ? s : `\x1b[1m${s}\x1b[22m`,
19
- dim: (s) => noColor ? s : `\x1b[2m${s}\x1b[22m`,
20
- cyan: (s) => noColor ? s : `\x1b[36m${s}\x1b[39m`,
21
- green: (s) => noColor ? s : `\x1b[32m${s}\x1b[39m`,
22
- yellow: (s) => noColor ? s : `\x1b[33m${s}\x1b[39m`,
23
- red: (s) => noColor ? s : `\x1b[31m${s}\x1b[39m`,
24
- magenta: (s) => noColor ? s : `\x1b[35m${s}\x1b[39m`,
25
- bgGreen: (s) => noColor ? s : `\x1b[42m\x1b[30m${s}\x1b[39m\x1b[49m`,
19
+ bold: (s) => (noColor ? s : `\x1b[1m${s}\x1b[22m`),
20
+ dim: (s) => (noColor ? s : `\x1b[2m${s}\x1b[22m`),
21
+ cyan: (s) => (noColor ? s : `\x1b[36m${s}\x1b[39m`),
22
+ green: (s) => (noColor ? s : `\x1b[32m${s}\x1b[39m`),
23
+ yellow: (s) => (noColor ? s : `\x1b[33m${s}\x1b[39m`),
24
+ red: (s) => (noColor ? s : `\x1b[31m${s}\x1b[39m`),
25
+ magenta: (s) => (noColor ? s : `\x1b[35m${s}\x1b[39m`),
26
+ bgGreen: (s) => (noColor ? s : `\x1b[42m\x1b[30m${s}\x1b[39m\x1b[49m`),
26
27
  };
27
28
  const args = process.argv.slice(2);
28
29
  const command = args[0];
@@ -134,10 +135,20 @@ function printHelp() {
134
135
  gazetta serve [target] [site] Serve published pages from target storage
135
136
  gazetta deploy [target] [site] Deploy worker to hosting (one-time setup)
136
137
  gazetta validate [site] Check site for broken references
138
+ gazetta translate <item> --to <locale> [target]
139
+ Create a locale copy of a page or fragment
140
+ gazetta history [target] [site] List revisions on a target
141
+ gazetta undo [target] [site] Restore the previous revision (soft undo)
142
+ gazetta rollback <rev> [target] [site]
143
+ Restore an arbitrary revision by id
137
144
  gazetta help Show this help message
138
145
 
139
146
  Options:
140
147
  --port, -p <port> Server port (default: 3000)
148
+ --force, -f Publish all items (skip unchanged check)
149
+ --yes, -y Skip confirmation prompt (required in CI
150
+ for undo/rollback on production targets)
151
+ --limit <n> Max revisions to list (default: 50)
141
152
 
142
153
  Auto-detection:
143
154
  Site is auto-detected from sites/ directory. If multiple sites exist,
@@ -154,20 +165,41 @@ function printHelp() {
154
165
  gazetta publish production my-site # publish specific site to production
155
166
  gazetta serve production -p 8080 # serve production on port 8080
156
167
  gazetta validate # check site for errors
168
+ gazetta history # list revisions on default target
169
+ gazetta undo production --yes # undo last write on production (CI-safe)
170
+ gazetta rollback rev-1776337441608 # roll back to a specific revision
157
171
  `);
158
172
  }
159
173
  function parseArgs(input) {
160
174
  const positional = [];
161
175
  let port;
176
+ let force = false;
177
+ let yes = false;
178
+ let limit;
162
179
  for (let i = 0; i < input.length; i++) {
163
180
  if (input[i] === '--port' || input[i] === '-p') {
164
181
  port = parseInt(input[++i], 10);
165
182
  }
183
+ else if (input[i] === '--force' || input[i] === '-f') {
184
+ force = true;
185
+ }
186
+ else if (input[i] === '--yes' || input[i] === '-y') {
187
+ yes = true;
188
+ }
189
+ else if (input[i] === '--limit') {
190
+ limit = parseInt(input[++i], 10);
191
+ }
192
+ else if (input[i] === '--to') {
193
+ i++; // consume the locale value — translate command reads it from raw args
194
+ }
195
+ else if (input[i].startsWith('--to=')) {
196
+ // consumed by translate command directly
197
+ }
166
198
  else if (!input[i].startsWith('-')) {
167
199
  positional.push(input[i]);
168
200
  }
169
201
  }
170
- return { positional, port };
202
+ return { positional, port, force, yes, limit };
171
203
  }
172
204
  /**
173
205
  * Resolve the site directory from positional args or auto-detection.
@@ -198,8 +230,7 @@ async function resolveSiteDir(positionalSite) {
198
230
  const sitesDir = resolve('sites');
199
231
  if (existsSync(sitesDir)) {
200
232
  const { readdirSync, statSync } = await import('node:fs');
201
- const sites = readdirSync(sitesDir)
202
- .filter(name => {
233
+ const sites = readdirSync(sitesDir).filter(name => {
203
234
  const dir = join(sitesDir, name);
204
235
  return statSync(dir).isDirectory() && existsSync(join(dir, 'site.yaml'));
205
236
  });
@@ -262,7 +293,7 @@ async function runInit(dir) {
262
293
  }
263
294
  const name = target.split('/').pop() ?? 'my-site';
264
295
  const files = {
265
- 'sites/main/site.yaml': `name: ${name}\nversion: 1.0.0\nsystemPages:\n - "404"\ntargets:\n local:\n storage:\n type: filesystem\n path: ./dist/local\n`,
296
+ 'sites/main/site.yaml': `name: ${name}\nversion: 1.0.0\nsystemPages:\n - "404"\ntargets:\n local:\n storage:\n type: filesystem\n # environment=local, editable=true (defaults); path=./targets/local (default)\n`,
266
297
  'templates/page-layout/index.ts': `import { z } from 'zod'
267
298
  import type { TemplateFunction } from 'gazetta'
268
299
 
@@ -351,20 +382,28 @@ const template: TemplateFunction = ({ content = {} }) => {
351
382
 
352
383
  export default template
353
384
  `,
354
- 'sites/main/fragments/header/fragment.json': JSON.stringify({
385
+ 'sites/main/targets/local/fragments/header/fragment.json': JSON.stringify({
355
386
  template: 'nav',
356
387
  content: { brand: name, links: [{ label: 'Home', href: '/' }] },
357
388
  }, null, 2) + '\n',
358
- 'sites/main/pages/home/page.json': JSON.stringify({
389
+ 'sites/main/targets/local/pages/home/page.json': JSON.stringify({
359
390
  template: 'page-layout',
360
391
  content: { title: name, description: 'A site built with Gazetta' },
361
392
  components: [
362
393
  '@header',
363
- { name: 'hero', template: 'hero', content: { title: `Welcome to ${name}`, subtitle: 'A site built with Gazetta' } },
364
- { name: 'intro', template: 'text-block', content: { body: '<p>Edit this content in the CMS at <a href="/admin">/admin</a>.</p>' } },
394
+ {
395
+ name: 'hero',
396
+ template: 'hero',
397
+ content: { title: `Welcome to ${name}`, subtitle: 'A site built with Gazetta' },
398
+ },
399
+ {
400
+ name: 'intro',
401
+ template: 'text-block',
402
+ content: { body: '<p>Edit this content in the CMS at <a href="/admin">/admin</a>.</p>' },
403
+ },
365
404
  ],
366
405
  }, null, 2) + '\n',
367
- 'sites/main/pages/404/page.json': JSON.stringify({
406
+ 'sites/main/targets/local/pages/404/page.json': JSON.stringify({
368
407
  template: 'page-layout',
369
408
  content: { title: 'Page Not Found', description: "The page you're looking for doesn't exist." },
370
409
  }, null, 2) + '\n',
@@ -408,24 +447,25 @@ export default template
408
447
  const cdStep = dir !== '.' ? `cd ${dir} && ` : '';
409
448
  outro(`Done! Run: ${c.cyan(`${cdStep}npx gazetta dev`)}`);
410
449
  }
411
- async function runPublish(siteDir, targetName) {
412
- const storage = createFilesystemProvider();
450
+ async function runPublish(siteDir, targetName, opts = {}) {
413
451
  const projectRoot = detectProjectRoot(siteDir);
414
452
  const templatesDir = join(projectRoot, 'templates');
415
- const site = await loadSite({ siteDir, storage, templatesDir });
416
- // Load target configs from site.yaml
417
- const siteYamlPath = join(siteDir, 'site.yaml');
418
- if (!existsSync(siteYamlPath)) {
419
- console.error(`\n ${c.red('Error:')} No site.yaml found at ${siteDir}\n`);
453
+ // Source comes from the default editable target in site.yaml.
454
+ const { buildSourceContext } = await import('./bootstrap.js');
455
+ let source, manifest, targetConfigs;
456
+ try {
457
+ ;
458
+ ({ source, manifest, targetConfigs } = await buildSourceContext({ projectSiteDir: siteDir }));
459
+ }
460
+ catch (err) {
461
+ console.error(`\n ${c.red('Error:')} ${err.message}\n`);
420
462
  process.exit(1);
421
463
  }
422
- const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
464
+ const storage = source.storage;
465
+ const site = await loadSite({ contentRoot: source.contentRoot, templatesDir, manifest });
466
+ const siteYaml = manifest;
423
467
  if (!siteYaml.targets || Object.keys(siteYaml.targets).length === 0) {
424
- console.error(`\n Error: no targets configured in ${siteYamlPath}`);
425
- console.error(`\n Add a target to site.yaml:\n`);
426
- console.error(` targets:`);
427
- console.error(` staging:`);
428
- console.error(` storage: { type: filesystem, path: ./dist/staging }\n`);
468
+ console.error(`\n Error: no targets configured in site.yaml`);
429
469
  process.exit(1);
430
470
  }
431
471
  // Determine which targets to publish to
@@ -440,6 +480,7 @@ async function runPublish(siteDir, targetName) {
440
480
  const { createTargetRegistry } = await import('../targets.js');
441
481
  const targets = await createTargetRegistry(Object.fromEntries(targetNames.map(n => [n, siteYaml.targets[n]])), siteDir);
442
482
  const { publishPageRendered, publishPageStatic, publishFragmentRendered, publishSiteManifest, publishFragmentIndex } = await import('../publish-rendered.js');
483
+ const { publishPageAllLocales, publishFragmentAllLocales } = await import('../publish-locale.js');
443
484
  const { scanTemplates, templateHashesFrom, reportTemplateErrors } = await import('../templates-scan.js');
444
485
  const { hashManifest } = await import('../hash.js');
445
486
  // Validate + hash templates once for this publish run
@@ -464,42 +505,233 @@ async function runPublish(siteDir, targetName) {
464
505
  continue;
465
506
  }
466
507
  const targetConfig = siteYaml.targets[name];
467
- const { getPublishMode } = await import('../types.js');
468
- const publishMode = targetConfig ? getPublishMode(targetConfig) : 'static';
469
- const isStatic = publishMode === 'static';
470
- console.log(` ${c.bold(name)} ${c.dim(`(${publishMode})`)}`);
508
+ const { getType } = await import('../types.js');
509
+ const targetType = targetConfig ? getType(targetConfig) : 'static';
510
+ const isStatic = targetType === 'static';
511
+ console.log(` ${c.bold(name)} ${c.dim(`(${targetType})`)}`);
471
512
  let totalFiles = 0;
472
513
  let totalRemoved = 0;
514
+ // Incremental: compare source hashes against target sidecars, skip
515
+ // items whose hash already matches the target. --force bypasses.
516
+ const unchanged = new Set();
517
+ if (!opts.force) {
518
+ const { compareTargets } = await import('../compare.js');
519
+ const cmp = await compareTargets({
520
+ sourceRoot: source.contentRoot,
521
+ target: targetStorage,
522
+ templatesDir,
523
+ projectRoot,
524
+ type: targetType,
525
+ scanTemplates: async () => templateInfos,
526
+ manifest,
527
+ });
528
+ for (const item of cmp.unchanged)
529
+ unchanged.add(item);
530
+ }
531
+ let skipped = 0;
532
+ const sourceRoot = source.contentRoot;
533
+ // SEO context for this target — built once, shared across all page renders.
534
+ const seo = {
535
+ siteName: site.manifest.name,
536
+ siteUrl: targetConfig?.siteUrl,
537
+ locale: site.manifest.locale,
538
+ defaultOgImage: site.manifest.defaultOgImage,
539
+ };
473
540
  if (isStatic) {
474
- // Static mode — fully assembled HTML, no fragments needed separately
541
+ // Static mode — fully assembled HTML, no fragments needed separately.
542
+ // Page hash must include fragment hashes so a fragment change
543
+ // invalidates every page that bakes it in (compareTargets uses the
544
+ // same combination on the local side).
545
+ const fragmentHashes = new Map();
546
+ for (const [fragName, frag] of site.fragments) {
547
+ fragmentHashes.set(fragName, hashManifest(frag, { templateHashes }));
548
+ }
475
549
  for (const [pageName, page] of site.pages) {
476
- const manifestHash = hashManifest(page, { templateHashes });
477
- const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir, manifestHash);
550
+ if (unchanged.has(`pages/${pageName}`)) {
551
+ skipped++;
552
+ continue;
553
+ }
554
+ const manifestHash = hashManifest(page, { templateHashes, fragmentHashes });
555
+ const { files } = await publishPageStatic(pageName, sourceRoot, targetStorage, templatesDir, manifestHash, site, seo);
478
556
  totalFiles += files;
479
557
  console.log(` ${c.green('✓')} ${pageName}`);
480
558
  }
481
559
  }
482
560
  else {
483
561
  // ESI mode — fragments separate, pages with placeholders
484
- for (const [fragName, frag] of site.fragments) {
485
- const manifestHash = hashManifest(frag, { templateHashes });
486
- const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir, manifestHash);
562
+ for (const [fragName] of site.fragments) {
563
+ // Build per-locale unchanged set: null = default, 'fr' = French
564
+ const fragUnchanged = new Set();
565
+ if (unchanged.has(`fragments/${fragName}`))
566
+ fragUnchanged.add(null);
567
+ const fragLocales = site.fragmentLocales.get(fragName);
568
+ if (fragLocales) {
569
+ for (const loc of fragLocales.locales.keys()) {
570
+ if (unchanged.has(`fragments/${fragName}:${loc}`))
571
+ fragUnchanged.add(loc);
572
+ }
573
+ }
574
+ // Skip entirely if all locales unchanged
575
+ const totalFragLocales = 1 + (fragLocales?.locales.size ?? 0);
576
+ if (fragUnchanged.size >= totalFragLocales) {
577
+ skipped++;
578
+ continue;
579
+ }
580
+ const { files, removed } = await publishFragmentAllLocales(fragName, sourceRoot, targetStorage, site, { templateHashes }, { templatesDir, targetLocales: targetConfig?.locales, unchangedLocales: fragUnchanged });
487
581
  totalFiles += files;
488
582
  totalRemoved += removed;
489
- console.log(` ${c.green('')} @${fragName}`);
583
+ const skippedCount = fragUnchanged.size > 0 ? ` (${fragUnchanged.size} locale${fragUnchanged.size > 1 ? 's' : ''} skipped)` : '';
584
+ console.log(` ${c.green('✓')} @${fragName}${skippedCount}`);
490
585
  }
491
- for (const [pageName, page] of site.pages) {
492
- const manifestHash = hashManifest(page, { templateHashes });
493
- const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir, manifestHash);
586
+ for (const [pageName] of site.pages) {
587
+ // Build per-locale unchanged set
588
+ const pageUnchanged = new Set();
589
+ if (unchanged.has(`pages/${pageName}`))
590
+ pageUnchanged.add(null);
591
+ const pageLocales = site.pageLocales.get(pageName);
592
+ if (pageLocales) {
593
+ for (const loc of pageLocales.locales.keys()) {
594
+ if (unchanged.has(`pages/${pageName}:${loc}`))
595
+ pageUnchanged.add(loc);
596
+ }
597
+ }
598
+ const totalPageLocales = 1 + (pageLocales?.locales.size ?? 0);
599
+ if (pageUnchanged.size >= totalPageLocales) {
600
+ skipped++;
601
+ continue;
602
+ }
603
+ const { files, removed } = await publishPageAllLocales(pageName, sourceRoot, targetStorage, site, { templateHashes }, {
604
+ cache: targetConfig?.cache,
605
+ templatesDir,
606
+ seo,
607
+ targetLocales: targetConfig?.locales,
608
+ unchangedLocales: pageUnchanged,
609
+ });
494
610
  totalFiles += files;
495
611
  totalRemoved += removed;
496
- console.log(` ${c.green('✓')} ${pageName}`);
612
+ const skippedCount = pageUnchanged.size > 0 ? ` (${pageUnchanged.size} locale${pageUnchanged.size > 1 ? 's' : ''} skipped)` : '';
613
+ console.log(` ${c.green('✓')} ${pageName}${skippedCount}`);
497
614
  }
498
615
  }
616
+ if (skipped > 0)
617
+ console.log(` ${c.dim(`· ${skipped} unchanged (skipped)`)}`);
499
618
  // Site manifest + fragment index
500
- await publishSiteManifest(storage, siteDir, targetStorage);
501
- await publishFragmentIndex(storage, siteDir, targetStorage);
619
+ await publishSiteManifest(sourceRoot, targetStorage, site);
620
+ await publishFragmentIndex(sourceRoot, targetStorage, site);
502
621
  totalFiles += 2;
622
+ // Sitemap + robots.txt — generated from target sidecars
623
+ const siteUrl = targetConfig?.siteUrl;
624
+ if (siteUrl) {
625
+ const { listSidecars } = await import('../sidecars.js');
626
+ const { generateSitemap } = await import('../sitemap.js');
627
+ const { generateRobotsTxt } = await import('../robots.js');
628
+ const targetPageSidecars = await listSidecars(targetStorage, 'pages');
629
+ // Merge source-side knowledge — listSidecars may miss just-written
630
+ // entries on R2 due to eventual list-after-write consistency. Every
631
+ // page we just published gets an entry even if the listing missed it.
632
+ const now = new Date().toISOString();
633
+ for (const [pageName, page] of site.pages) {
634
+ if (!targetPageSidecars.has(pageName)) {
635
+ targetPageSidecars.set(pageName, {
636
+ hash: '',
637
+ uses: [],
638
+ template: page.template,
639
+ pub: { lastPublished: now, noindex: !!page.metadata?.robots?.includes('noindex') },
640
+ });
641
+ }
642
+ }
643
+ for (const [pageName, localeEntry] of site.pageLocales) {
644
+ for (const [loc, localePage] of localeEntry.locales) {
645
+ const key = `${pageName}:${loc}`;
646
+ if (!targetPageSidecars.has(key)) {
647
+ targetPageSidecars.set(key, {
648
+ hash: '',
649
+ uses: [],
650
+ template: localePage.template,
651
+ pub: { lastPublished: now, noindex: !!localePage.metadata?.robots?.includes('noindex') },
652
+ });
653
+ }
654
+ }
655
+ }
656
+ const { resolveSiteLocales, defaultLocaleFor } = await import('../locale.js');
657
+ // Build hreflang groups — two strategies:
658
+ // 1. Subpath: same siteUrl, multiple locales → locale-prefixed routes
659
+ // 2. Cross-domain: other targets with different siteUrl → cross-link
660
+ const resolvedLoc = resolveSiteLocales(manifest);
661
+ const defLoc = defaultLocaleFor(manifest);
662
+ const hreflangGroups = new Map();
663
+ if (resolvedLoc) {
664
+ const { localeRoutePrefix } = await import('../locale.js');
665
+ const thisTargetLocales = targetConfig?.locales ?? resolvedLoc.supported;
666
+ const thisTargetDefault = targetConfig?.locale ?? defLoc;
667
+ for (const [pageName, page] of site.pages) {
668
+ if (pageName.includes('['))
669
+ continue;
670
+ const alternates = [];
671
+ // Subpath alternates on this target
672
+ if (thisTargetLocales.length > 1) {
673
+ for (const loc of thisTargetLocales) {
674
+ const prefix = localeRoutePrefix(loc, { ...resolvedLoc, default: thisTargetDefault });
675
+ const route = page.route === '/' ? prefix || '/' : `${prefix}${page.route}`;
676
+ alternates.push({ locale: loc, url: `${siteUrl}${route}` });
677
+ }
678
+ }
679
+ else {
680
+ // Single-locale target — add self
681
+ alternates.push({ locale: thisTargetLocales[0] ?? defLoc, url: `${siteUrl}${page.route}` });
682
+ }
683
+ // Cross-domain alternates from other targets
684
+ for (const [otherName, otherConfig] of Object.entries(siteYaml.targets ?? {})) {
685
+ if (otherName === name)
686
+ continue; // skip self
687
+ if (!otherConfig.siteUrl)
688
+ continue;
689
+ const otherLocales = otherConfig.locales ?? resolvedLoc.supported;
690
+ const otherDefault = otherConfig.locale ?? defLoc;
691
+ for (const loc of otherLocales) {
692
+ // Skip locales already covered by this target
693
+ if (alternates.some(a => a.locale === loc))
694
+ continue;
695
+ const otherResolved = { ...resolvedLoc, default: otherDefault };
696
+ const prefix = localeRoutePrefix(loc, otherResolved);
697
+ const route = page.route === '/' ? prefix || '/' : `${prefix}${page.route}`;
698
+ alternates.push({ locale: loc, url: `${otherConfig.siteUrl}${route}` });
699
+ }
700
+ }
701
+ if (alternates.length > 1) {
702
+ hreflangGroups.set(pageName, alternates);
703
+ }
704
+ }
705
+ }
706
+ const sitemapXml = generateSitemap({
707
+ siteUrl,
708
+ pages: targetPageSidecars,
709
+ systemPages: site.manifest.systemPages,
710
+ hreflangGroups: hreflangGroups.size > 0 ? hreflangGroups : undefined,
711
+ defaultLocale: defLoc,
712
+ });
713
+ if (sitemapXml) {
714
+ await targetStorage.writeFile('sitemap.xml', sitemapXml);
715
+ totalFiles++;
716
+ console.log(` ${c.dim('· sitemap.xml')}`);
717
+ }
718
+ // robots.txt: only at the domain root — Google ignores robots.txt at
719
+ // subpaths. If siteUrl has a path component, the domain root belongs
720
+ // to someone else (host, reverse proxy, another app).
721
+ const isRootDeploy = !new URL(siteUrl).pathname.replace(/\/+$/, '');
722
+ if (isRootDeploy) {
723
+ let robotsTxt;
724
+ try {
725
+ robotsTxt = await source.contentRoot.storage.readFile(source.contentRoot.path('robots.txt'));
726
+ }
727
+ catch {
728
+ robotsTxt = generateRobotsTxt({ siteUrl });
729
+ }
730
+ await targetStorage.writeFile('robots.txt', robotsTxt);
731
+ totalFiles++;
732
+ console.log(` ${c.dim('· robots.txt')}`);
733
+ }
734
+ }
503
735
  const removedMsg = totalRemoved > 0 ? c.dim(` (${totalRemoved} old files cleaned)`) : '';
504
736
  console.log(`\n ${c.green('✓')} ${c.bold(name)}: ${totalFiles} files published${removedMsg}\n`);
505
737
  }
@@ -517,7 +749,8 @@ async function runPublish(siteDir, targetName) {
517
749
  }
518
750
  try {
519
751
  const { lookupCloudflareZoneId } = await import('../publish-rendered.js');
520
- const zoneId = resolveEnvVars(purge.zoneId) ?? (config.siteUrl ? await lookupCloudflareZoneId(config.siteUrl, apiToken) : null);
752
+ const zoneId = resolveEnvVars(purge.zoneId) ??
753
+ (config.siteUrl ? await lookupCloudflareZoneId(config.siteUrl, apiToken) : null);
521
754
  if (!zoneId) {
522
755
  console.log(` ${name}: zone not found, set purge.zoneId or siteUrl`);
523
756
  continue;
@@ -603,8 +836,10 @@ async function runBuild(siteDir) {
603
836
  const editorsDir = join(adminDir, 'editors');
604
837
  const fieldsDir = join(adminDir, 'fields');
605
838
  const entryExtensions = ['.ts', '.tsx', '.jsx'];
606
- const hasEditors = existsSync(editorsDir) && (await import('node:fs')).readdirSync(editorsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
607
- const hasFields = existsSync(fieldsDir) && (await import('node:fs')).readdirSync(fieldsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
839
+ const hasEditors = existsSync(editorsDir) &&
840
+ (await import('node:fs')).readdirSync(editorsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
841
+ const hasFields = existsSync(fieldsDir) &&
842
+ (await import('node:fs')).readdirSync(fieldsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
608
843
  if (hasEditors || hasFields) {
609
844
  const { build: esbuild } = await import('esbuild');
610
845
  const { writeFile: writeFileAsync, mkdir: mkdirAsync } = await import('node:fs/promises');
@@ -612,7 +847,7 @@ async function runBuild(siteDir) {
612
847
  await mkdirAsync(sharedDir, { recursive: true });
613
848
  // Build shared dependency bundles (one copy of React, etc.)
614
849
  const sharedDeps = {
615
- 'react': 'export * from "react"; import React from "react"; export default React;',
850
+ react: 'export * from "react"; import React from "react"; export default React;',
616
851
  'react-dom/client': 'export * from "react-dom/client";',
617
852
  'react/jsx-runtime': 'export * from "react/jsx-runtime";',
618
853
  'gazetta/editor': 'export * from "gazetta/editor";',
@@ -638,14 +873,19 @@ async function runBuild(siteDir) {
638
873
  });
639
874
  importMap[specifier] = `/admin/_shared/${safeName}.js`;
640
875
  }
641
- catch { /* skip — dep may not be installed */ }
876
+ catch {
877
+ /* skip — dep may not be installed */
878
+ }
642
879
  await import('node:fs/promises').then(fs => fs.rm(stubFile, { force: true }));
643
880
  }
644
881
  console.log(` ${c.green('✓')} Shared deps: ${Object.keys(importMap).join(', ')}`);
645
882
  // Bundle each custom editor/field with shared deps externalized
646
883
  const externals = Object.keys(importMap);
647
884
  let bundledCount = 0;
648
- for (const [kind, srcDir] of [['editors', editorsDir], ['fields', fieldsDir]]) {
885
+ for (const [kind, srcDir] of [
886
+ ['editors', editorsDir],
887
+ ['fields', fieldsDir],
888
+ ]) {
649
889
  if (!existsSync(srcDir))
650
890
  continue;
651
891
  const { readdirSync } = await import('node:fs');
@@ -694,11 +934,12 @@ async function runAdmin(siteDir, port) {
694
934
  process.exit(1);
695
935
  }
696
936
  const app = new Hono();
697
- app.get('/__reload', (ctx) => ctx.body(null, 204));
698
- const fsStorage = createFilesystemProvider();
699
- await setupProductionMode(app, siteDir, fsStorage, builtAdminDir, templatesDir, adminDir);
937
+ app.get('/__reload', ctx => ctx.body(null, 204));
938
+ const { buildSourceContext } = await import('./bootstrap.js');
939
+ const { source, targetConfigs } = await buildSourceContext({ projectSiteDir: siteDir });
940
+ await setupProductionMode(app, source, siteDir, builtAdminDir, templatesDir, adminDir, targetConfigs);
700
941
  // SPA fallback for non-API admin routes
701
- app.get('*', (ctx) => {
942
+ app.get('*', ctx => {
702
943
  const indexPath = join(builtAdminDir, 'index.html');
703
944
  if (existsSync(indexPath))
704
945
  return ctx.html(readFileSync(indexPath, 'utf-8'));
@@ -713,7 +954,10 @@ async function runAdmin(siteDir, port) {
713
954
  console.log();
714
955
  });
715
956
  for (const signal of ['SIGINT', 'SIGTERM']) {
716
- process.on(signal, () => { console.log(`\n Shutting down...`); server.close(() => process.exit(0)); });
957
+ process.on(signal, () => {
958
+ console.log(`\n Shutting down...`);
959
+ server.close(() => process.exit(0));
960
+ });
717
961
  }
718
962
  }
719
963
  async function runServe(siteDir, port, targetName) {
@@ -734,10 +978,11 @@ async function runServe(siteDir, port, targetName) {
734
978
  process.exit(1);
735
979
  }
736
980
  const { createStorageProvider } = await import('../targets.js');
737
- const storage = await createStorageProvider(config.storage, siteDir);
738
- const { getPublishMode } = await import('../types.js');
981
+ // Pass targetName so filesystem path defaults to ./targets/<name> when unset.
982
+ const storage = await createStorageProvider(config.storage, siteDir, name);
983
+ const { getType } = await import('../types.js');
739
984
  const { createServer } = await import('../serve.js');
740
- const app = createServer({ storage, mode: getPublishMode(config) });
985
+ const app = createServer({ storage, type: getType(config) });
741
986
  const server = serve({ fetch: app.fetch, port }, () => {
742
987
  console.log();
743
988
  console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('serve')} ${c.dim(siteYaml.name)} ${c.dim(`(${name})`)}`);
@@ -827,16 +1072,17 @@ async function runDeploy(siteDir, targetName) {
827
1072
  console.log(`\n ${c.green('✓')} Worker deployed. Now publish content:\n ${c.cyan(`gazetta publish ${targetName}`)}\n`);
828
1073
  }
829
1074
  async function runValidate(siteDir) {
830
- const storage = createFilesystemProvider();
831
1075
  const projectRoot = detectProjectRoot(siteDir);
832
1076
  const templatesDir = join(projectRoot, 'templates');
833
1077
  console.log();
834
1078
  console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('validate')} ${c.dim(siteDir)}`);
835
1079
  console.log();
836
- // 1. Check site.yaml
1080
+ // 1. Check site.yaml + load default editable target's content
837
1081
  let site;
838
1082
  try {
839
- site = await loadSite({ siteDir, storage, templatesDir });
1083
+ const { buildSourceContext } = await import('./bootstrap.js');
1084
+ const { source, manifest } = await buildSourceContext({ projectSiteDir: siteDir });
1085
+ site = await loadSite({ contentRoot: source.contentRoot, templatesDir, manifest });
840
1086
  console.log(` ${c.green('✓')} site.yaml ${c.dim(`— ${site.manifest.name}`)}`);
841
1087
  }
842
1088
  catch (err) {
@@ -871,11 +1117,12 @@ async function runValidate(siteDir) {
871
1117
  errors++;
872
1118
  }
873
1119
  }
874
- // 4. List templates
1120
+ // 4. List templates (project-level filesystem, not target content)
1121
+ const projectStorage = createFilesystemProvider();
875
1122
  let templateNames = [];
876
1123
  try {
877
- const entries = await storage.readDir(templatesDir);
878
- templateNames = entries.filter(e => e.isDirectory).map(e => e.name);
1124
+ const entries = await projectStorage.readDir(templatesDir);
1125
+ templateNames = entries.filter(e => e.isDirectory).map((e) => e.name);
879
1126
  console.log(` ${c.green('✓')} ${c.dim(`${templateNames.length} templates`)}`);
880
1127
  }
881
1128
  catch {
@@ -885,7 +1132,9 @@ async function runValidate(siteDir) {
885
1132
  const adminDir = join(projectRoot, 'admin');
886
1133
  const editorsDir = join(adminDir, 'editors');
887
1134
  if (existsSync(editorsDir)) {
888
- const editorFiles = (await import('node:fs')).readdirSync(editorsDir).filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
1135
+ const editorFiles = (await import('node:fs'))
1136
+ .readdirSync(editorsDir)
1137
+ .filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
889
1138
  for (const file of editorFiles) {
890
1139
  const editorName = file.replace(/\.(ts|tsx)$/, '');
891
1140
  if (!templateNames.includes(editorName)) {
@@ -895,12 +1144,17 @@ async function runValidate(siteDir) {
895
1144
  }
896
1145
  // 6. Check for missing custom fields (schema references field but file doesn't exist)
897
1146
  const fieldsDir = join(adminDir, 'fields');
898
- const fieldFiles = existsSync(fieldsDir) ? (await import('node:fs')).readdirSync(fieldsDir).filter(f => f.endsWith('.ts') || f.endsWith('.tsx')).map(f => f.replace(/\.(ts|tsx)$/, '')) : [];
1147
+ const fieldFiles = existsSync(fieldsDir)
1148
+ ? (await import('node:fs'))
1149
+ .readdirSync(fieldsDir)
1150
+ .filter(f => f.endsWith('.ts') || f.endsWith('.tsx'))
1151
+ .map(f => f.replace(/\.(ts|tsx)$/, ''))
1152
+ : [];
899
1153
  const { loadTemplate } = await import('../template-loader.js');
900
1154
  const zod = await import('zod');
901
1155
  for (const tplName of templateNames) {
902
1156
  try {
903
- const loaded = await loadTemplate(storage, templatesDir, tplName);
1157
+ const loaded = await loadTemplate(projectStorage, templatesDir, tplName);
904
1158
  const jsonSchema = zod.z.toJSONSchema(loaded.schema);
905
1159
  const props = jsonSchema.properties;
906
1160
  if (!props)
@@ -913,7 +1167,70 @@ async function runValidate(siteDir) {
913
1167
  }
914
1168
  }
915
1169
  }
916
- catch { /* template load errors already caught above */ }
1170
+ catch {
1171
+ /* template load errors already caught above */
1172
+ }
1173
+ }
1174
+ // 7. Locale validation
1175
+ const { defaultLocaleFor } = await import('../locale.js');
1176
+ const defLoc = defaultLocaleFor(site.manifest);
1177
+ const hasI18n = !!site.manifest.locales?.supported?.length;
1178
+ // 7a. Warn about orphaned locale files when i18n is disabled
1179
+ if (!hasI18n && (site.pageLocales.size > 0 || site.fragmentLocales.size > 0)) {
1180
+ const orphanCount = site.pageLocales.size + site.fragmentLocales.size;
1181
+ console.log(` ${c.yellow('⚠')} ${orphanCount} locale file${orphanCount > 1 ? 's' : ''} found but i18n is disabled ${c.dim('— add locales.supported to site.yaml or remove *.locale.json files')}`);
1182
+ }
1183
+ // 7b. Warn about ambiguous page.en.json when en is default
1184
+ if (hasI18n) {
1185
+ for (const [name, entry] of site.pageLocales) {
1186
+ if (entry.locales.has(defLoc)) {
1187
+ console.log(` ${c.yellow('⚠')} page.${defLoc}.json in ${name} is ambiguous ${c.dim(`— "${defLoc}" is the default locale, use page.json instead`)}`);
1188
+ }
1189
+ }
1190
+ for (const [name, entry] of site.fragmentLocales) {
1191
+ if (entry.locales.has(defLoc)) {
1192
+ console.log(` ${c.yellow('⚠')} fragment.${defLoc}.json in ${name} is ambiguous ${c.dim(`— "${defLoc}" is the default locale, use fragment.json instead`)}`);
1193
+ }
1194
+ }
1195
+ }
1196
+ // 7c. Validate locale variant template/fragment refs
1197
+ if (hasI18n) {
1198
+ for (const [pageName, entry] of site.pageLocales) {
1199
+ for (const [locale] of entry.locales) {
1200
+ try {
1201
+ await resolvePage(pageName, site, locale);
1202
+ }
1203
+ catch (err) {
1204
+ console.error(` ${c.red('✗')} ${pageName} (${locale}) ${c.dim(`— ${err.message}`)}`);
1205
+ errors++;
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ // 8. Cross-domain hreflang bidirectional check
1211
+ // For per-domain targets (each with siteUrl + single locale), verify that
1212
+ // all targets serving the same page cross-link to each other.
1213
+ if (hasI18n && site.manifest.targets) {
1214
+ const targetsWithSiteUrl = Object.entries(site.manifest.targets).filter(([, cfg]) => cfg.siteUrl && cfg.locales?.length === 1);
1215
+ if (targetsWithSiteUrl.length > 1) {
1216
+ const localeToUrl = new Map();
1217
+ for (const [, cfg] of targetsWithSiteUrl) {
1218
+ localeToUrl.set(cfg.locales[0], cfg.siteUrl);
1219
+ }
1220
+ const missingPairs = [];
1221
+ for (const [locA, urlA] of localeToUrl) {
1222
+ for (const [locB, urlB] of localeToUrl) {
1223
+ if (locA === locB)
1224
+ continue;
1225
+ // Each target's sitemap should cross-link to the other
1226
+ // We can't check the actual sitemaps here (would need network),
1227
+ // but we can verify the config is consistent
1228
+ }
1229
+ }
1230
+ if (localeToUrl.size > 1) {
1231
+ console.log(` ${c.green('✓')} cross-domain hreflang: ${[...localeToUrl.entries()].map(([l, u]) => `${l} → ${u}`).join(', ')}`);
1232
+ }
1233
+ }
917
1234
  }
918
1235
  console.log();
919
1236
  if (errors > 0) {
@@ -970,19 +1287,26 @@ function renderErrorOverlay(err) {
970
1287
  </html>`;
971
1288
  }
972
1289
  async function runDev(siteDir, port) {
973
- const storage = createFilesystemProvider();
974
1290
  const projectRoot = detectProjectRoot(siteDir);
975
1291
  const templatesDir = join(projectRoot, 'templates');
976
1292
  const adminDir = join(projectRoot, 'admin');
977
- const site = await loadSite({ siteDir, storage, templatesDir });
1293
+ // Build the source context from the default editable target in site.yaml.
1294
+ // Cloud targets aren't init'd — admin API handles them lazily.
1295
+ const { buildSourceContext } = await import('./bootstrap.js');
1296
+ const { source, manifest, targetConfigs } = await buildSourceContext({ projectSiteDir: siteDir });
1297
+ const site = await loadSite({ contentRoot: source.contentRoot, templatesDir, manifest });
1298
+ const storage = source.storage;
978
1299
  const app = new Hono();
979
1300
  // ---- Live reload (SSE) ----
980
1301
  let reloadId = 0;
981
1302
  const reloadListeners = new Set();
982
- function notifyReload() { reloadId++; for (const l of reloadListeners)
983
- l(); }
1303
+ function notifyReload() {
1304
+ reloadId++;
1305
+ for (const l of reloadListeners)
1306
+ l();
1307
+ }
984
1308
  const RELOAD_SCRIPT = `<script>new EventSource('/__reload').onmessage = () => location.reload()</script>`;
985
- app.get('/__reload', (c) => {
1309
+ app.get('/__reload', c => {
986
1310
  return streamSSE(c, async (stream) => {
987
1311
  let lastId = reloadId;
988
1312
  const check = async () => {
@@ -992,20 +1316,47 @@ async function runDev(siteDir, port) {
992
1316
  }
993
1317
  };
994
1318
  reloadListeners.add(check);
995
- stream.onAbort(() => { reloadListeners.delete(check); });
1319
+ stream.onAbort(() => {
1320
+ reloadListeners.delete(check);
1321
+ });
996
1322
  while (true) {
997
1323
  await stream.sleep(500);
998
1324
  await check();
999
1325
  }
1000
1326
  });
1001
1327
  });
1002
- // ---- Site page routes ----
1003
- for (const [pageName, page] of site.pages) {
1328
+ // ---- Trailing slash normalization ----
1329
+ // Strip trailing slashes so /fr/ resolves as /fr and /fr/about/ as
1330
+ // /fr/about. Re-dispatches through the Hono router with the clean URL.
1331
+ // No redirect — preserves POST body and avoids round-trips.
1332
+ app.use(async (c, next) => {
1333
+ const url = new URL(c.req.url);
1334
+ if (url.pathname !== '/' && url.pathname.endsWith('/')) {
1335
+ url.pathname = url.pathname.slice(0, -1);
1336
+ return app.fetch(new Request(url, c.req.raw), c.env);
1337
+ }
1338
+ return next();
1339
+ });
1340
+ // ---- Site page routes (default + locale variants) ----
1341
+ const { allPageEntries } = await import('../site-loader.js');
1342
+ for (const { name: pageName, page, locale: pageLocale } of allPageEntries(site)) {
1004
1343
  app.get(page.route, async (c) => {
1005
1344
  try {
1006
- const freshSite = await loadSite({ siteDir, storage, templatesDir });
1007
- const resolved = await resolvePage(pageName, freshSite);
1008
- const html = await renderPage(resolved, c.req.param());
1345
+ const freshSite = await loadSite({ contentRoot: source.contentRoot, templatesDir, manifest });
1346
+ const resolved = await resolvePage(pageName, freshSite, pageLocale);
1347
+ const freshPage = pageLocale
1348
+ ? freshSite.pageLocales.get(pageName)?.locales.get(pageLocale)
1349
+ : freshSite.pages.get(pageName);
1350
+ const html = await renderPage(resolved, {
1351
+ routeParams: c.req.param(),
1352
+ metadata: freshPage?.metadata ?? page.metadata,
1353
+ route: freshPage?.route ?? page.route,
1354
+ seo: {
1355
+ siteName: freshSite.manifest.name,
1356
+ locale: pageLocale ?? freshSite.manifest.locale,
1357
+ defaultOgImage: freshSite.manifest.defaultOgImage,
1358
+ },
1359
+ });
1009
1360
  return c.html(html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`));
1010
1361
  }
1011
1362
  catch (err) {
@@ -1013,20 +1364,61 @@ async function runDev(siteDir, port) {
1013
1364
  }
1014
1365
  });
1015
1366
  }
1367
+ // ---- Locale fallback routes for pages without locale variants ----
1368
+ // When a page exists in the default locale but has no page.fr.json,
1369
+ // register /fr{route} that renders the default content with FR locale context.
1370
+ // This prevents 404s on locale-prefixed URLs for untranslated pages.
1371
+ const { resolveSiteLocales } = await import('../locale.js');
1372
+ const resolvedLocales = resolveSiteLocales(manifest);
1373
+ if (resolvedLocales) {
1374
+ const nonDefaultLocales = resolvedLocales.supported.filter(l => l !== resolvedLocales.default);
1375
+ for (const loc of nonDefaultLocales) {
1376
+ for (const [pageName, page] of site.pages) {
1377
+ const hasLocaleVariant = site.pageLocales.get(pageName)?.locales.has(loc);
1378
+ if (hasLocaleVariant)
1379
+ continue; // already registered by allPageEntries
1380
+ const localeRoute = `/${loc}${page.route === '/' ? '' : page.route}`;
1381
+ app.get(localeRoute, async (c) => {
1382
+ try {
1383
+ const freshSite = await loadSite({ contentRoot: source.contentRoot, templatesDir, manifest });
1384
+ const resolved = await resolvePage(pageName, freshSite, loc);
1385
+ const freshPage = freshSite.pages.get(pageName);
1386
+ const html = await renderPage(resolved, {
1387
+ routeParams: c.req.param(),
1388
+ metadata: freshPage?.metadata,
1389
+ route: freshPage?.route,
1390
+ seo: {
1391
+ siteName: freshSite.manifest.name,
1392
+ locale: loc,
1393
+ defaultOgImage: freshSite.manifest.defaultOgImage,
1394
+ },
1395
+ });
1396
+ return c.html(html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`));
1397
+ }
1398
+ catch (err) {
1399
+ return c.html(renderErrorOverlay(err), 500);
1400
+ }
1401
+ });
1402
+ }
1403
+ }
1404
+ }
1016
1405
  // ---- Detect mode: dev (monorepo with apps/admin source) vs production (pre-built) ----
1017
1406
  const cmsWebDir = findCmsDir();
1018
1407
  const cmsStaticDir = findCmsStaticDir();
1019
1408
  const isDevMode = cmsWebDir !== null;
1409
+ // Admin Hono instance — captured so the template file watcher can
1410
+ // invalidate its memoized template-scan cache on .ts/.tsx changes.
1411
+ let cmsApp = null;
1020
1412
  if (isDevMode) {
1021
1413
  // Dev mode: mount CMS API inline (same process = shared template cache)
1022
- await setupCmsApi(app, siteDir, storage, templatesDir, adminDir);
1414
+ cmsApp = await setupCmsApi(app, source, siteDir, templatesDir, adminDir, targetConfigs);
1023
1415
  }
1024
1416
  else if (cmsStaticDir) {
1025
1417
  // Production mode: inline CMS API + static files
1026
- await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
1418
+ cmsApp = await setupProductionMode(app, source, siteDir, cmsStaticDir, templatesDir, adminDir, targetConfigs);
1027
1419
  }
1028
1420
  // ---- 404 ----
1029
- app.notFound((c) => {
1421
+ app.notFound(c => {
1030
1422
  const routes = [...site.pages.entries()].map(([n, p]) => ` ${p.route} → ${n}`).join('\n');
1031
1423
  return c.html(`<pre style="padding:2rem">Page not found: ${c.req.path}\n\nAvailable:\n${routes}\n /admin → CMS editor</pre>`, 404);
1032
1424
  });
@@ -1045,6 +1437,37 @@ async function runDev(siteDir, port) {
1045
1437
  console.log();
1046
1438
  console.log(` ${c.dim('┃')} Pages ${[...site.pages.entries()].map(([n, p]) => `${c.dim(p.route)} ${c.dim('→')} ${n}`).join(c.dim(', '))}`);
1047
1439
  console.log(` ${c.dim('┃')} Frags ${c.dim([...site.fragments.keys()].join(', ') || '(none)')}`);
1440
+ // ---- Settings banner ----
1441
+ // Prints resolved configuration at startup so path / target / site
1442
+ // issues are diagnosed immediately instead of via empty API responses.
1443
+ // Opt-in via GAZETTA_QUIET=1 for scripted callers that don't want it.
1444
+ if (!process.env.GAZETTA_QUIET) {
1445
+ const relProject = relative(process.cwd(), projectRoot) || '.';
1446
+ const relSite = relative(projectRoot, siteDir) || '.';
1447
+ const relTemplates = relative(projectRoot, templatesDir) || '.';
1448
+ const sourceName = source.targetName ?? '(none)';
1449
+ const sourceCfg = targetConfigs[sourceName];
1450
+ const sourceEnv = sourceCfg ? getEnvironment(sourceCfg) : 'unknown';
1451
+ const sourceType = sourceCfg ? getType(sourceCfg) : 'unknown';
1452
+ const sourceEditable = sourceCfg ? isEditable(sourceCfg) : false;
1453
+ const sourceRoot = source.contentRoot.rootPath || '.';
1454
+ const targetsCount = Object.keys(targetConfigs).length;
1455
+ console.log();
1456
+ console.log(` ${c.dim('┃')} ${c.bold('Settings')}`);
1457
+ console.log(` ${c.dim('┃')} Project ${c.dim(relProject)}`);
1458
+ console.log(` ${c.dim('┃')} Site ${c.dim(relSite)}`);
1459
+ console.log(` ${c.dim('┃')} Templates ${c.dim(relTemplates)}`);
1460
+ console.log(` ${c.dim('┃')} Source ${sourceName} ${c.dim(`(${sourceEnv}, ${sourceEditable ? 'editable' : 'read-only'}, ${sourceType})`)}`);
1461
+ console.log(` ${c.dim('┃')} Content root ${c.dim(sourceRoot)}`);
1462
+ console.log(` ${c.dim('┃')} Targets (${targetsCount})`);
1463
+ for (const [name, cfg] of Object.entries(targetConfigs)) {
1464
+ const env = getEnvironment(cfg);
1465
+ const type = getType(cfg);
1466
+ const ed = isEditable(cfg) ? 'editable ' : 'read-only';
1467
+ const storagePath = cfg.storage?.type === 'filesystem' ? (cfg.storage.path ?? `targets/${name}`) : `${cfg.storage?.type ?? '?'}`;
1468
+ console.log(` ${c.dim('┃')} ${c.dim('•')} ${name.padEnd(14)} ${c.dim(env.padEnd(11))} ${c.dim(ed)} ${c.dim(type.padEnd(8))} ${c.dim('→ ' + storagePath)}`);
1469
+ }
1470
+ }
1048
1471
  if (isDevMode && cmsWebDir) {
1049
1472
  // While Vite is spinning up (compiling, scanning deps, attaching
1050
1473
  // middleware), any /admin/* request falls through to the site's 404
@@ -1062,7 +1485,11 @@ async function runDev(siteDir, port) {
1062
1485
  return true;
1063
1486
  }
1064
1487
  if (url === '/admin' || url.startsWith('/admin/') || url.startsWith('/@')) {
1065
- res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Retry-After': '2' });
1488
+ res.writeHead(503, {
1489
+ 'Content-Type': 'text/html; charset=utf-8',
1490
+ 'Cache-Control': 'no-store',
1491
+ 'Retry-After': '2',
1492
+ });
1066
1493
  res.end(LOADER_HTML);
1067
1494
  return true;
1068
1495
  }
@@ -1075,6 +1502,7 @@ async function runDev(siteDir, port) {
1075
1502
  if (loaderHandler(req, res))
1076
1503
  return;
1077
1504
  for (const l of originalListeners) {
1505
+ ;
1078
1506
  l(req, res);
1079
1507
  }
1080
1508
  });
@@ -1102,7 +1530,9 @@ async function runDev(siteDir, port) {
1102
1530
  }
1103
1531
  }
1104
1532
  }
1105
- catch { /* ignore */ }
1533
+ catch {
1534
+ /* ignore */
1535
+ }
1106
1536
  }
1107
1537
  }
1108
1538
  const vite = await createViteServer({
@@ -1131,6 +1561,7 @@ async function runDev(siteDir, port) {
1131
1561
  });
1132
1562
  const honoHandler = (req, res) => {
1133
1563
  for (const listener of originalListeners) {
1564
+ ;
1134
1565
  listener(req, res);
1135
1566
  }
1136
1567
  };
@@ -1144,7 +1575,10 @@ async function runDev(siteDir, port) {
1144
1575
  res.end(JSON.stringify({ ready: true }));
1145
1576
  return;
1146
1577
  }
1147
- if (url.startsWith('/admin/api') || url.startsWith('/admin/preview') || url === '/admin/theme.css' || url.startsWith('/admin/theme.css?')) {
1578
+ if (url.startsWith('/admin/api') ||
1579
+ url.startsWith('/admin/preview') ||
1580
+ url === '/admin/theme.css' ||
1581
+ url.startsWith('/admin/theme.css?')) {
1148
1582
  honoHandler(req, res);
1149
1583
  }
1150
1584
  else if (url.startsWith('/admin') || url.startsWith('/@')) {
@@ -1154,6 +1588,26 @@ async function runDev(siteDir, port) {
1154
1588
  honoHandler(req, res);
1155
1589
  }
1156
1590
  });
1591
+ // Force Vite to scan deps + complete initial optimization BEFORE we
1592
+ // mark the CMS ready. Without this, `cmsReady = true` fires the
1593
+ // moment Vite is created — loader page reloads, browser starts
1594
+ // fetching the SPA, and Vite's still building the dep bundle in the
1595
+ // background. The first round of imports arrives, Vite finds new
1596
+ // transitive deps, and fires `optimized dependencies changed.
1597
+ // reloading` mid-page-load. That reload cancels in-flight
1598
+ // `/admin/api/*` requests — silently breaking any component that
1599
+ // doesn't retry (FragmentBlastRadius, for one).
1600
+ //
1601
+ // Warm the SPA's main entry module (not index.html — Vite's
1602
+ // import-analysis plugin treats warmupRequest urls as JS modules
1603
+ // and chokes on HTML). The entry's transitive imports are exactly
1604
+ // what the browser will request on first load, so settling them
1605
+ // here means the browser gets a stable bundle. waitForRequestsIdle
1606
+ // blocks until Vite finishes processing the static-import chain,
1607
+ // which includes dep optimization.
1608
+ const ENTRY = '/src/client/main.ts';
1609
+ await vite.warmupRequest(ENTRY);
1610
+ await vite.waitForRequestsIdle(ENTRY);
1157
1611
  cmsReady = true;
1158
1612
  }
1159
1613
  catch (err) {
@@ -1171,13 +1625,30 @@ async function runDev(siteDir, port) {
1171
1625
  const siteWatcher = watch(siteDir, { recursive: true }, (_event, filename) => {
1172
1626
  if (!filename)
1173
1627
  return;
1628
+ // .gazetta/ is a reserved namespace (history, source-sidecars, etc.) that
1629
+ // the runtime never reads at request time. Writes there are extremely
1630
+ // frequent (one per save/publish × per-target) — treating them as
1631
+ // content changes would flood SSE reloads and reset preview iframe
1632
+ // scroll state mid-test. Filter them out at the watcher boundary.
1633
+ const norm = filename.replace(/\\/g, '/');
1634
+ if (norm.includes('/.gazetta/') || norm.startsWith('.gazetta/'))
1635
+ return;
1174
1636
  if (filename.endsWith('.json') || filename.endsWith('.yaml')) {
1175
1637
  console.log(` Manifest changed: ${filename}`);
1176
1638
  invalidateAllTemplates();
1639
+ // Refresh source sidecars for external edits (git pull, direct file
1640
+ // edit). PUT routes already handle their own writes — this catches
1641
+ // everything outside the admin UI.
1642
+ const pageMatch = /^pages\/(.+)\/page\.json$/.exec(norm);
1643
+ const fragMatch = /^fragments\/(.+)\/fragment\.json$/.exec(norm);
1644
+ if (pageMatch)
1645
+ cmsApp?.writeSourceSidecar('page', pageMatch[1]).catch(() => { });
1646
+ else if (fragMatch)
1647
+ cmsApp?.writeSourceSidecar('fragment', fragMatch[1]).catch(() => { });
1177
1648
  notifyReload();
1178
1649
  }
1179
1650
  });
1180
- siteWatcher.on('error', (err) => console.warn(` File watcher warning (site): ${err.message}`));
1651
+ siteWatcher.on('error', err => console.warn(` File watcher warning (site): ${err.message}`));
1181
1652
  // Watch templates dir for template source changes
1182
1653
  if (existsSync(templatesDir)) {
1183
1654
  const tplWatcher = watch(templatesDir, { recursive: true }, (_event, filename) => {
@@ -1188,11 +1659,15 @@ async function runDev(siteDir, port) {
1188
1659
  if (parts.length >= 1) {
1189
1660
  console.log(` Template changed: ${parts[0]}`);
1190
1661
  invalidateTemplate(parts[0]);
1662
+ // Drop the admin-api's cached scan so next compare/publish
1663
+ // rehashes. Cheap (the scan is what's slow, not invalidation).
1664
+ cmsApp?.invalidateTemplatesCache();
1665
+ cmsApp?.invalidateSourceSidecars();
1191
1666
  notifyReload();
1192
1667
  }
1193
1668
  }
1194
1669
  });
1195
- tplWatcher.on('error', (err) => console.warn(` File watcher warning (templates): ${err.message}`));
1670
+ tplWatcher.on('error', err => console.warn(` File watcher warning (templates): ${err.message}`));
1196
1671
  }
1197
1672
  }
1198
1673
  // ---- Mount CMS API on the main Hono app (shared process = shared template cache) ----
@@ -1204,7 +1679,7 @@ async function runDev(siteDir, port) {
1204
1679
  * so user declarations win the cascade. See #134 and css-theming.md.
1205
1680
  */
1206
1681
  function mountUserThemeRoute(cmsApp, adminDir) {
1207
- cmsApp.get('/theme.css', (c) => {
1682
+ cmsApp.get('/theme.css', c => {
1208
1683
  const themePath = join(adminDir, 'theme.css');
1209
1684
  c.header('Content-Type', 'text/css; charset=utf-8');
1210
1685
  c.header('Cache-Control', 'no-cache');
@@ -1217,34 +1692,22 @@ function mountUserThemeRoute(cmsApp, adminDir) {
1217
1692
  return c.body(readFileSync(themePath, 'utf-8'));
1218
1693
  });
1219
1694
  }
1220
- async function setupCmsApi(app, siteDir, storage, templatesDir, adminDir) {
1221
- const siteYamlPath = join(siteDir, 'site.yaml');
1222
- let targetConfigs;
1223
- if (existsSync(siteYamlPath)) {
1224
- const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
1225
- targetConfigs = siteYaml.targets;
1226
- }
1227
- const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, targetConfigs });
1695
+ async function setupCmsApi(app, source, siteDir, templatesDir, adminDir, targetConfigs) {
1696
+ const cmsApp = createAdminApp({ source, siteDir, templatesDir, adminDir, targetConfigs });
1228
1697
  mountUserThemeRoute(cmsApp, adminDir);
1229
1698
  app.route('/admin', cmsApp);
1699
+ return cmsApp;
1230
1700
  }
1231
1701
  // ---- Production mode: inline CMS API + static files from admin-dist/ ----
1232
- async function setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir) {
1233
- // Read target configs from site.yaml — targets are initialized lazily on first publish/fetch
1234
- const siteYamlPath = join(siteDir, 'site.yaml');
1235
- let targetConfigs;
1236
- if (existsSync(siteYamlPath)) {
1237
- const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
1238
- targetConfigs = siteYaml.targets;
1239
- }
1702
+ async function setupProductionMode(app, source, siteDir, cmsStaticDir, templatesDir, adminDir, targetConfigs) {
1240
1703
  // Mount CMS API inline at /admin (production mode — bundled editors/fields)
1241
- const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, production: true, targetConfigs });
1704
+ const cmsApp = createAdminApp({ source, siteDir, templatesDir, adminDir, production: true, targetConfigs });
1242
1705
  mountUserThemeRoute(cmsApp, adminDir);
1243
1706
  app.route('/admin', cmsApp);
1244
1707
  // Serve pre-built CMS static files (includes bundled editors/fields)
1245
1708
  app.use('/admin/*', serveStatic({
1246
1709
  root: cmsStaticDir,
1247
- rewriteRequestPath: (path) => path.replace(/^\/admin/, ''),
1710
+ rewriteRequestPath: path => path.replace(/^\/admin/, ''),
1248
1711
  }));
1249
1712
  // SPA fallback: serve index.html for /admin and unmatched /admin/* routes
1250
1713
  const serveIndex = (c) => {
@@ -1256,6 +1719,7 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir, template
1256
1719
  };
1257
1720
  app.get('/admin/*', serveIndex);
1258
1721
  app.get('/admin', serveIndex);
1722
+ return cmsApp;
1259
1723
  }
1260
1724
  /** Find apps/admin source dir (monorepo dev mode) */
1261
1725
  function findCmsDir() {
@@ -1289,11 +1753,15 @@ async function main() {
1289
1753
  }
1290
1754
  const parsed = parseArgs(args.slice(1));
1291
1755
  // Commands that take [target] [site] positional args
1292
- const targetFirstCommands = new Set(['publish', 'serve', 'deploy']);
1756
+ const targetFirstCommands = new Set(['publish', 'serve', 'deploy', 'history', 'undo']);
1293
1757
  // Commands that take [site] positional arg
1294
1758
  const siteOnlyCommands = new Set(['dev', 'validate', 'admin']);
1295
1759
  let siteDir;
1296
1760
  let targetName;
1761
+ // rollback: positional layout is `<rev> [target] [site]`. We stash
1762
+ // the revision id here because the shared positional parser uses
1763
+ // index 0 for target/site; rollback just consumes index 0 first.
1764
+ let rollbackRevisionId;
1297
1765
  if (command === 'init') {
1298
1766
  await runInit(parsed.positional[0] ?? '.');
1299
1767
  return;
@@ -1303,6 +1771,25 @@ async function main() {
1303
1771
  await runBuild(siteDir);
1304
1772
  return;
1305
1773
  }
1774
+ else if (command === 'rollback') {
1775
+ // gazetta rollback <rev> [target] [site]
1776
+ const [rev, second, third] = parsed.positional;
1777
+ if (!rev || !rev.startsWith('rev-')) {
1778
+ console.error(`\n Error: rollback requires a revision id as the first argument (e.g. gazetta rollback rev-1776337441608 [target])\n`);
1779
+ process.exit(1);
1780
+ return;
1781
+ }
1782
+ rollbackRevisionId = rev;
1783
+ const secondIsSite = second && (second.includes('/') || existsSync(join(resolve(second), 'site.yaml')));
1784
+ if (secondIsSite) {
1785
+ siteDir = await resolveSiteDir(second);
1786
+ targetName = await resolveTarget(undefined, siteDir);
1787
+ }
1788
+ else {
1789
+ siteDir = await resolveSiteDir(third);
1790
+ targetName = await resolveTarget(second, siteDir);
1791
+ }
1792
+ }
1306
1793
  else if (targetFirstCommands.has(command)) {
1307
1794
  // gazetta publish [target] [site]
1308
1795
  const [first, second] = parsed.positional;
@@ -1320,6 +1807,15 @@ async function main() {
1320
1807
  else if (siteOnlyCommands.has(command)) {
1321
1808
  siteDir = await resolveSiteDir(parsed.positional[0]);
1322
1809
  }
1810
+ else if (command === 'translate') {
1811
+ // gazetta translate <item> --to <locale> [target]
1812
+ // positional args after the item are the optional target name
1813
+ siteDir = await resolveSiteDir(undefined);
1814
+ // Find the target arg — skip the item (pages/... or fragments/...) and --to/locale flags
1815
+ const translatePositionals = parsed.positional.filter(p => !p.startsWith('pages/') && !p.startsWith('fragments/'));
1816
+ if (translatePositionals.length > 0)
1817
+ targetName = translatePositionals[0];
1818
+ }
1323
1819
  else {
1324
1820
  console.error(` Unknown command: ${command}\n`);
1325
1821
  printHelp();
@@ -1345,7 +1841,7 @@ async function main() {
1345
1841
  }
1346
1842
  switch (command) {
1347
1843
  case 'publish':
1348
- await runPublish(siteDir, targetName);
1844
+ await runPublish(siteDir, targetName, { force: parsed.force });
1349
1845
  break;
1350
1846
  case 'serve':
1351
1847
  await runServe(siteDir, parsed.port ?? 3000, targetName);
@@ -1362,9 +1858,91 @@ async function main() {
1362
1858
  case 'admin':
1363
1859
  await runAdmin(siteDir, parsed.port ?? 3000);
1364
1860
  break;
1861
+ case 'translate': {
1862
+ const itemArg = args[1];
1863
+ const localeArg = args.find(a => a.startsWith('--to='))?.slice(5) ?? args[args.indexOf('--to') + 1];
1864
+ if (!itemArg || !localeArg) {
1865
+ console.error(' Usage: gazetta translate <pages/name|fragments/name> --to <locale>');
1866
+ console.error(' Example: gazetta translate pages/about --to fr');
1867
+ process.exit(1);
1868
+ }
1869
+ const { normalizeLocale, localeFilename, isValidLocale } = await import('../locale.js');
1870
+ if (!isValidLocale(localeArg)) {
1871
+ console.error(` Error: invalid locale code "${localeArg}". Use BCP 47 format (e.g. fr, en-gb, pt-br)`);
1872
+ process.exit(1);
1873
+ }
1874
+ const locale = normalizeLocale(localeArg);
1875
+ const isPage = itemArg.startsWith('pages/');
1876
+ const isFragment = itemArg.startsWith('fragments/');
1877
+ if (!isPage && !isFragment) {
1878
+ console.error(` Error: item must start with pages/ or fragments/ (got "${itemArg}")`);
1879
+ process.exit(1);
1880
+ }
1881
+ // Resolve the content directory — translate operates on a target's filesystem.
1882
+ // Uses the specified target or falls back to the first editable target.
1883
+ const siteYaml = yaml.load(readFileSync(join(siteDir, 'site.yaml'), 'utf-8'));
1884
+ const { isEditable } = await import('../types.js');
1885
+ const resolvedTarget = targetName ?? Object.entries(siteYaml.targets ?? {}).find(([, cfg]) => isEditable(cfg))?.[0];
1886
+ if (!resolvedTarget) {
1887
+ console.error(' Error: no editable target found');
1888
+ process.exit(1);
1889
+ }
1890
+ const targetConfig = siteYaml.targets[resolvedTarget];
1891
+ if (!targetConfig) {
1892
+ console.error(` Error: target "${resolvedTarget}" not found in site.yaml`);
1893
+ process.exit(1);
1894
+ }
1895
+ const storagePath = targetConfig.storage.path ?? join('targets', resolvedTarget);
1896
+ const contentDir = resolve(siteDir, storagePath);
1897
+ const baseName = isPage ? 'page' : 'fragment';
1898
+ const dir = join(contentDir, itemArg);
1899
+ const sourceFile = join(dir, `${baseName}.json`);
1900
+ const destFile = join(dir, localeFilename(baseName, locale));
1901
+ const fs = await import('node:fs/promises');
1902
+ if (!existsSync(sourceFile)) {
1903
+ console.error(` Error: ${sourceFile} not found`);
1904
+ process.exit(1);
1905
+ }
1906
+ if (existsSync(destFile)) {
1907
+ console.error(` Error: ${destFile} already exists`);
1908
+ process.exit(1);
1909
+ }
1910
+ await fs.copyFile(sourceFile, destFile);
1911
+ console.log(` ${c.green('✓')} Created ${relative(process.cwd(), destFile)}`);
1912
+ console.log(` Edit the file to translate the content.`);
1913
+ break;
1914
+ }
1915
+ case 'history':
1916
+ case 'undo':
1917
+ case 'rollback': {
1918
+ const { runHistoryList, runHistoryUndo, runHistoryRollback } = await import('./history.js');
1919
+ const ctx = await resolveHistoryContext(siteDir, targetName);
1920
+ if (command === 'history')
1921
+ await runHistoryList(ctx, { limit: parsed.limit });
1922
+ else if (command === 'undo')
1923
+ await runHistoryUndo(ctx, { yes: parsed.yes });
1924
+ else
1925
+ await runHistoryRollback(ctx, rollbackRevisionId, { yes: parsed.yes });
1926
+ break;
1927
+ }
1928
+ }
1929
+ }
1930
+ /**
1931
+ * Resolve site + target + config into the shape HistoryCommandContext
1932
+ * expects. Lives here rather than in cli/history.ts so the target-
1933
+ * resolution logic (site.yaml parsing, CI env handling) stays with
1934
+ * the other CLI commands that already do it the same way.
1935
+ */
1936
+ async function resolveHistoryContext(siteDir, targetName) {
1937
+ const { bootstrapFromSiteYaml } = await import('./bootstrap.js');
1938
+ const { targetConfigs } = await bootstrapFromSiteYaml(siteDir);
1939
+ const config = targetConfigs[targetName];
1940
+ if (!config) {
1941
+ throw new Error(`Unknown target "${targetName}". Available: ${Object.keys(targetConfigs).join(', ')}`);
1365
1942
  }
1943
+ return { siteDir, targetName, config };
1366
1944
  }
1367
- main().catch((err) => {
1945
+ main().catch(err => {
1368
1946
  console.error(`\n Error: ${err.message}\n`);
1369
1947
  process.exit(1);
1370
1948
  });