gazetta 0.5.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 (236) 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 +19 -4
  7. package/dist/admin-api/index.d.ts.map +1 -1
  8. package/dist/admin-api/index.js +154 -18
  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 +33 -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 -3
  19. package/dist/admin-api/routes/fragments.d.ts.map +1 -1
  20. package/dist/admin-api/routes/fragments.js +92 -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 -3
  27. package/dist/admin-api/routes/pages.d.ts.map +1 -1
  28. package/dist/admin-api/routes/pages.js +118 -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 +2 -1
  35. package/dist/admin-api/routes/publish.d.ts.map +1 -1
  36. package/dist/admin-api/routes/publish.js +213 -66
  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 +630 -115
  112. package/dist/cli/index.js.map +1 -1
  113. package/dist/compare.d.ts +8 -5
  114. package/dist/compare.d.ts.map +1 -1
  115. package/dist/compare.js +53 -14
  116. package/dist/compare.js.map +1 -1
  117. package/dist/content-root.d.ts +38 -0
  118. package/dist/content-root.d.ts.map +1 -0
  119. package/dist/content-root.js +29 -0
  120. package/dist/content-root.js.map +1 -0
  121. package/dist/editor/mount.d.ts +1 -1
  122. package/dist/editor/mount.d.ts.map +1 -1
  123. package/dist/editor/mount.js +61 -29
  124. package/dist/editor/mount.js.map +1 -1
  125. package/dist/hash.d.ts +34 -3
  126. package/dist/hash.d.ts.map +1 -1
  127. package/dist/hash.js +64 -7
  128. package/dist/hash.js.map +1 -1
  129. package/dist/history-provider.d.ts +49 -0
  130. package/dist/history-provider.d.ts.map +1 -0
  131. package/dist/history-provider.js +226 -0
  132. package/dist/history-provider.js.map +1 -0
  133. package/dist/history-recorder.d.ts +98 -0
  134. package/dist/history-recorder.d.ts.map +1 -0
  135. package/dist/history-recorder.js +160 -0
  136. package/dist/history-recorder.js.map +1 -0
  137. package/dist/history-restorer.d.ts +46 -0
  138. package/dist/history-restorer.d.ts.map +1 -0
  139. package/dist/history-restorer.js +105 -0
  140. package/dist/history-restorer.js.map +1 -0
  141. package/dist/history.d.ts +111 -0
  142. package/dist/history.d.ts.map +1 -0
  143. package/dist/history.js +25 -0
  144. package/dist/history.js.map +1 -0
  145. package/dist/index.d.ts +26 -4
  146. package/dist/index.d.ts.map +1 -1
  147. package/dist/index.js +16 -3
  148. package/dist/index.js.map +1 -1
  149. package/dist/locale.d.ts +74 -0
  150. package/dist/locale.d.ts.map +1 -0
  151. package/dist/locale.js +150 -0
  152. package/dist/locale.js.map +1 -0
  153. package/dist/manifest.d.ts.map +1 -1
  154. package/dist/manifest.js +16 -1
  155. package/dist/manifest.js.map +1 -1
  156. package/dist/providers/azure-blob.d.ts.map +1 -1
  157. package/dist/providers/azure-blob.js.map +1 -1
  158. package/dist/providers/r2.d.ts.map +1 -1
  159. package/dist/providers/r2.js +7 -4
  160. package/dist/providers/r2.js.map +1 -1
  161. package/dist/providers/s3.d.ts.map +1 -1
  162. package/dist/providers/s3.js +23 -15
  163. package/dist/providers/s3.js.map +1 -1
  164. package/dist/publish-locale.d.ts +44 -0
  165. package/dist/publish-locale.d.ts.map +1 -0
  166. package/dist/publish-locale.js +103 -0
  167. package/dist/publish-locale.js.map +1 -0
  168. package/dist/publish-rendered.d.ts +16 -5
  169. package/dist/publish-rendered.d.ts.map +1 -1
  170. package/dist/publish-rendered.js +89 -36
  171. package/dist/publish-rendered.js.map +1 -1
  172. package/dist/publish.d.ts +5 -7
  173. package/dist/publish.d.ts.map +1 -1
  174. package/dist/publish.js +21 -12
  175. package/dist/publish.js.map +1 -1
  176. package/dist/renderer.d.ts +14 -4
  177. package/dist/renderer.d.ts.map +1 -1
  178. package/dist/renderer.js +35 -23
  179. package/dist/renderer.js.map +1 -1
  180. package/dist/resolver.d.ts +7 -2
  181. package/dist/resolver.d.ts.map +1 -1
  182. package/dist/resolver.js +66 -15
  183. package/dist/resolver.js.map +1 -1
  184. package/dist/robots.d.ts +22 -0
  185. package/dist/robots.d.ts.map +1 -0
  186. package/dist/robots.js +25 -0
  187. package/dist/robots.js.map +1 -0
  188. package/dist/seo.d.ts +56 -0
  189. package/dist/seo.d.ts.map +1 -0
  190. package/dist/seo.js +72 -0
  191. package/dist/seo.js.map +1 -0
  192. package/dist/serve.d.ts +41 -3
  193. package/dist/serve.d.ts.map +1 -1
  194. package/dist/serve.js +206 -65
  195. package/dist/serve.js.map +1 -1
  196. package/dist/sidecars.d.ts +9 -5
  197. package/dist/sidecars.d.ts.map +1 -1
  198. package/dist/sidecars.js +112 -22
  199. package/dist/sidecars.js.map +1 -1
  200. package/dist/site-loader.d.ts +74 -6
  201. package/dist/site-loader.d.ts.map +1 -1
  202. package/dist/site-loader.js +138 -28
  203. package/dist/site-loader.js.map +1 -1
  204. package/dist/sitemap.d.ts +45 -0
  205. package/dist/sitemap.d.ts.map +1 -0
  206. package/dist/sitemap.js +67 -0
  207. package/dist/sitemap.js.map +1 -0
  208. package/dist/source-sidecars.d.ts +21 -2
  209. package/dist/source-sidecars.d.ts.map +1 -1
  210. package/dist/source-sidecars.js +51 -5
  211. package/dist/source-sidecars.js.map +1 -1
  212. package/dist/targets.d.ts +47 -1
  213. package/dist/targets.d.ts.map +1 -1
  214. package/dist/targets.js +78 -9
  215. package/dist/targets.js.map +1 -1
  216. package/dist/template-loader.d.ts +7 -3
  217. package/dist/template-loader.d.ts.map +1 -1
  218. package/dist/template-loader.js +27 -12
  219. package/dist/template-loader.js.map +1 -1
  220. package/dist/templates-scan-worker.js +1 -1
  221. package/dist/templates-scan-worker.js.map +1 -1
  222. package/dist/templates-scan.d.ts.map +1 -1
  223. package/dist/templates-scan.js +1 -1
  224. package/dist/templates-scan.js.map +1 -1
  225. package/dist/types.d.ts +116 -9
  226. package/dist/types.d.ts.map +1 -1
  227. package/dist/types.js +28 -5
  228. package/dist/types.js.map +1 -1
  229. package/dist/workers/cloudflare-r2.d.ts +11 -2
  230. package/dist/workers/cloudflare-r2.d.ts.map +1 -1
  231. package/dist/workers/cloudflare-r2.js +120 -55
  232. package/dist/workers/cloudflare-r2.js.map +1 -1
  233. package/package.json +11 -2
  234. package/admin-dist/assets/index-BZAFKsUp.js +0 -608
  235. package/admin-dist/assets/index-BpRotMuK.css +0 -1
  236. 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,11 +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)
141
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)
142
152
 
143
153
  Auto-detection:
144
154
  Site is auto-detected from sites/ directory. If multiple sites exist,
@@ -155,12 +165,17 @@ function printHelp() {
155
165
  gazetta publish production my-site # publish specific site to production
156
166
  gazetta serve production -p 8080 # serve production on port 8080
157
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
158
171
  `);
159
172
  }
160
173
  function parseArgs(input) {
161
174
  const positional = [];
162
175
  let port;
163
176
  let force = false;
177
+ let yes = false;
178
+ let limit;
164
179
  for (let i = 0; i < input.length; i++) {
165
180
  if (input[i] === '--port' || input[i] === '-p') {
166
181
  port = parseInt(input[++i], 10);
@@ -168,11 +183,23 @@ function parseArgs(input) {
168
183
  else if (input[i] === '--force' || input[i] === '-f') {
169
184
  force = true;
170
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
+ }
171
198
  else if (!input[i].startsWith('-')) {
172
199
  positional.push(input[i]);
173
200
  }
174
201
  }
175
- return { positional, port, force };
202
+ return { positional, port, force, yes, limit };
176
203
  }
177
204
  /**
178
205
  * Resolve the site directory from positional args or auto-detection.
@@ -203,8 +230,7 @@ async function resolveSiteDir(positionalSite) {
203
230
  const sitesDir = resolve('sites');
204
231
  if (existsSync(sitesDir)) {
205
232
  const { readdirSync, statSync } = await import('node:fs');
206
- const sites = readdirSync(sitesDir)
207
- .filter(name => {
233
+ const sites = readdirSync(sitesDir).filter(name => {
208
234
  const dir = join(sitesDir, name);
209
235
  return statSync(dir).isDirectory() && existsSync(join(dir, 'site.yaml'));
210
236
  });
@@ -267,7 +293,7 @@ async function runInit(dir) {
267
293
  }
268
294
  const name = target.split('/').pop() ?? 'my-site';
269
295
  const files = {
270
- '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`,
271
297
  'templates/page-layout/index.ts': `import { z } from 'zod'
272
298
  import type { TemplateFunction } from 'gazetta'
273
299
 
@@ -356,20 +382,28 @@ const template: TemplateFunction = ({ content = {} }) => {
356
382
 
357
383
  export default template
358
384
  `,
359
- 'sites/main/fragments/header/fragment.json': JSON.stringify({
385
+ 'sites/main/targets/local/fragments/header/fragment.json': JSON.stringify({
360
386
  template: 'nav',
361
387
  content: { brand: name, links: [{ label: 'Home', href: '/' }] },
362
388
  }, null, 2) + '\n',
363
- 'sites/main/pages/home/page.json': JSON.stringify({
389
+ 'sites/main/targets/local/pages/home/page.json': JSON.stringify({
364
390
  template: 'page-layout',
365
391
  content: { title: name, description: 'A site built with Gazetta' },
366
392
  components: [
367
393
  '@header',
368
- { name: 'hero', template: 'hero', content: { title: `Welcome to ${name}`, subtitle: 'A site built with Gazetta' } },
369
- { 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
+ },
370
404
  ],
371
405
  }, null, 2) + '\n',
372
- 'sites/main/pages/404/page.json': JSON.stringify({
406
+ 'sites/main/targets/local/pages/404/page.json': JSON.stringify({
373
407
  template: 'page-layout',
374
408
  content: { title: 'Page Not Found', description: "The page you're looking for doesn't exist." },
375
409
  }, null, 2) + '\n',
@@ -414,23 +448,24 @@ export default template
414
448
  outro(`Done! Run: ${c.cyan(`${cdStep}npx gazetta dev`)}`);
415
449
  }
416
450
  async function runPublish(siteDir, targetName, opts = {}) {
417
- const storage = createFilesystemProvider();
418
451
  const projectRoot = detectProjectRoot(siteDir);
419
452
  const templatesDir = join(projectRoot, 'templates');
420
- const site = await loadSite({ siteDir, storage, templatesDir });
421
- // Load target configs from site.yaml
422
- const siteYamlPath = join(siteDir, 'site.yaml');
423
- if (!existsSync(siteYamlPath)) {
424
- 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`);
425
462
  process.exit(1);
426
463
  }
427
- 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;
428
467
  if (!siteYaml.targets || Object.keys(siteYaml.targets).length === 0) {
429
- console.error(`\n Error: no targets configured in ${siteYamlPath}`);
430
- console.error(`\n Add a target to site.yaml:\n`);
431
- console.error(` targets:`);
432
- console.error(` staging:`);
433
- console.error(` storage: { type: filesystem, path: ./dist/staging }\n`);
468
+ console.error(`\n Error: no targets configured in site.yaml`);
434
469
  process.exit(1);
435
470
  }
436
471
  // Determine which targets to publish to
@@ -445,6 +480,7 @@ async function runPublish(siteDir, targetName, opts = {}) {
445
480
  const { createTargetRegistry } = await import('../targets.js');
446
481
  const targets = await createTargetRegistry(Object.fromEntries(targetNames.map(n => [n, siteYaml.targets[n]])), siteDir);
447
482
  const { publishPageRendered, publishPageStatic, publishFragmentRendered, publishSiteManifest, publishFragmentIndex } = await import('../publish-rendered.js');
483
+ const { publishPageAllLocales, publishFragmentAllLocales } = await import('../publish-locale.js');
448
484
  const { scanTemplates, templateHashesFrom, reportTemplateErrors } = await import('../templates-scan.js');
449
485
  const { hashManifest } = await import('../hash.js');
450
486
  // Validate + hash templates once for this publish run
@@ -469,10 +505,10 @@ async function runPublish(siteDir, targetName, opts = {}) {
469
505
  continue;
470
506
  }
471
507
  const targetConfig = siteYaml.targets[name];
472
- const { getPublishMode } = await import('../types.js');
473
- const publishMode = targetConfig ? getPublishMode(targetConfig) : 'static';
474
- const isStatic = publishMode === 'static';
475
- 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})`)}`);
476
512
  let totalFiles = 0;
477
513
  let totalRemoved = 0;
478
514
  // Incremental: compare source hashes against target sidecars, skip
@@ -481,18 +517,26 @@ async function runPublish(siteDir, targetName, opts = {}) {
481
517
  if (!opts.force) {
482
518
  const { compareTargets } = await import('../compare.js');
483
519
  const cmp = await compareTargets({
484
- source: storage,
520
+ sourceRoot: source.contentRoot,
485
521
  target: targetStorage,
486
- siteDir,
487
522
  templatesDir,
488
523
  projectRoot,
489
- publishMode,
524
+ type: targetType,
490
525
  scanTemplates: async () => templateInfos,
526
+ manifest,
491
527
  });
492
528
  for (const item of cmp.unchanged)
493
529
  unchanged.add(item);
494
530
  }
495
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
+ };
496
540
  if (isStatic) {
497
541
  // Static mode — fully assembled HTML, no fragments needed separately.
498
542
  // Page hash must include fragment hashes so a fragment change
@@ -508,42 +552,186 @@ async function runPublish(siteDir, targetName, opts = {}) {
508
552
  continue;
509
553
  }
510
554
  const manifestHash = hashManifest(page, { templateHashes, fragmentHashes });
511
- const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir, manifestHash, site);
555
+ const { files } = await publishPageStatic(pageName, sourceRoot, targetStorage, templatesDir, manifestHash, site, seo);
512
556
  totalFiles += files;
513
557
  console.log(` ${c.green('✓')} ${pageName}`);
514
558
  }
515
559
  }
516
560
  else {
517
561
  // ESI mode — fragments separate, pages with placeholders
518
- for (const [fragName, frag] of site.fragments) {
519
- if (unchanged.has(`fragments/${fragName}`)) {
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) {
520
577
  skipped++;
521
578
  continue;
522
579
  }
523
- const manifestHash = hashManifest(frag, { templateHashes });
524
- const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir, manifestHash, site);
580
+ const { files, removed } = await publishFragmentAllLocales(fragName, sourceRoot, targetStorage, site, { templateHashes }, { templatesDir, targetLocales: targetConfig?.locales, unchangedLocales: fragUnchanged });
525
581
  totalFiles += files;
526
582
  totalRemoved += removed;
527
- 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}`);
528
585
  }
529
- for (const [pageName, page] of site.pages) {
530
- if (unchanged.has(`pages/${pageName}`)) {
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) {
531
600
  skipped++;
532
601
  continue;
533
602
  }
534
- const manifestHash = hashManifest(page, { templateHashes });
535
- const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir, manifestHash, site);
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
+ });
536
610
  totalFiles += files;
537
611
  totalRemoved += removed;
538
- 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}`);
539
614
  }
540
615
  }
541
616
  if (skipped > 0)
542
617
  console.log(` ${c.dim(`· ${skipped} unchanged (skipped)`)}`);
543
618
  // Site manifest + fragment index
544
- await publishSiteManifest(storage, siteDir, targetStorage, site);
545
- await publishFragmentIndex(storage, siteDir, targetStorage, site);
619
+ await publishSiteManifest(sourceRoot, targetStorage, site);
620
+ await publishFragmentIndex(sourceRoot, targetStorage, site);
546
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
+ }
547
735
  const removedMsg = totalRemoved > 0 ? c.dim(` (${totalRemoved} old files cleaned)`) : '';
548
736
  console.log(`\n ${c.green('✓')} ${c.bold(name)}: ${totalFiles} files published${removedMsg}\n`);
549
737
  }
@@ -561,7 +749,8 @@ async function runPublish(siteDir, targetName, opts = {}) {
561
749
  }
562
750
  try {
563
751
  const { lookupCloudflareZoneId } = await import('../publish-rendered.js');
564
- 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);
565
754
  if (!zoneId) {
566
755
  console.log(` ${name}: zone not found, set purge.zoneId or siteUrl`);
567
756
  continue;
@@ -647,8 +836,10 @@ async function runBuild(siteDir) {
647
836
  const editorsDir = join(adminDir, 'editors');
648
837
  const fieldsDir = join(adminDir, 'fields');
649
838
  const entryExtensions = ['.ts', '.tsx', '.jsx'];
650
- const hasEditors = existsSync(editorsDir) && (await import('node:fs')).readdirSync(editorsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
651
- 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)));
652
843
  if (hasEditors || hasFields) {
653
844
  const { build: esbuild } = await import('esbuild');
654
845
  const { writeFile: writeFileAsync, mkdir: mkdirAsync } = await import('node:fs/promises');
@@ -656,7 +847,7 @@ async function runBuild(siteDir) {
656
847
  await mkdirAsync(sharedDir, { recursive: true });
657
848
  // Build shared dependency bundles (one copy of React, etc.)
658
849
  const sharedDeps = {
659
- 'react': 'export * from "react"; import React from "react"; export default React;',
850
+ react: 'export * from "react"; import React from "react"; export default React;',
660
851
  'react-dom/client': 'export * from "react-dom/client";',
661
852
  'react/jsx-runtime': 'export * from "react/jsx-runtime";',
662
853
  'gazetta/editor': 'export * from "gazetta/editor";',
@@ -682,14 +873,19 @@ async function runBuild(siteDir) {
682
873
  });
683
874
  importMap[specifier] = `/admin/_shared/${safeName}.js`;
684
875
  }
685
- catch { /* skip — dep may not be installed */ }
876
+ catch {
877
+ /* skip — dep may not be installed */
878
+ }
686
879
  await import('node:fs/promises').then(fs => fs.rm(stubFile, { force: true }));
687
880
  }
688
881
  console.log(` ${c.green('✓')} Shared deps: ${Object.keys(importMap).join(', ')}`);
689
882
  // Bundle each custom editor/field with shared deps externalized
690
883
  const externals = Object.keys(importMap);
691
884
  let bundledCount = 0;
692
- for (const [kind, srcDir] of [['editors', editorsDir], ['fields', fieldsDir]]) {
885
+ for (const [kind, srcDir] of [
886
+ ['editors', editorsDir],
887
+ ['fields', fieldsDir],
888
+ ]) {
693
889
  if (!existsSync(srcDir))
694
890
  continue;
695
891
  const { readdirSync } = await import('node:fs');
@@ -738,11 +934,12 @@ async function runAdmin(siteDir, port) {
738
934
  process.exit(1);
739
935
  }
740
936
  const app = new Hono();
741
- app.get('/__reload', (ctx) => ctx.body(null, 204));
742
- const fsStorage = createFilesystemProvider();
743
- 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);
744
941
  // SPA fallback for non-API admin routes
745
- app.get('*', (ctx) => {
942
+ app.get('*', ctx => {
746
943
  const indexPath = join(builtAdminDir, 'index.html');
747
944
  if (existsSync(indexPath))
748
945
  return ctx.html(readFileSync(indexPath, 'utf-8'));
@@ -757,7 +954,10 @@ async function runAdmin(siteDir, port) {
757
954
  console.log();
758
955
  });
759
956
  for (const signal of ['SIGINT', 'SIGTERM']) {
760
- 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
+ });
761
961
  }
762
962
  }
763
963
  async function runServe(siteDir, port, targetName) {
@@ -778,10 +978,11 @@ async function runServe(siteDir, port, targetName) {
778
978
  process.exit(1);
779
979
  }
780
980
  const { createStorageProvider } = await import('../targets.js');
781
- const storage = await createStorageProvider(config.storage, siteDir);
782
- 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');
783
984
  const { createServer } = await import('../serve.js');
784
- const app = createServer({ storage, mode: getPublishMode(config) });
985
+ const app = createServer({ storage, type: getType(config) });
785
986
  const server = serve({ fetch: app.fetch, port }, () => {
786
987
  console.log();
787
988
  console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('serve')} ${c.dim(siteYaml.name)} ${c.dim(`(${name})`)}`);
@@ -871,16 +1072,17 @@ async function runDeploy(siteDir, targetName) {
871
1072
  console.log(`\n ${c.green('✓')} Worker deployed. Now publish content:\n ${c.cyan(`gazetta publish ${targetName}`)}\n`);
872
1073
  }
873
1074
  async function runValidate(siteDir) {
874
- const storage = createFilesystemProvider();
875
1075
  const projectRoot = detectProjectRoot(siteDir);
876
1076
  const templatesDir = join(projectRoot, 'templates');
877
1077
  console.log();
878
1078
  console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('validate')} ${c.dim(siteDir)}`);
879
1079
  console.log();
880
- // 1. Check site.yaml
1080
+ // 1. Check site.yaml + load default editable target's content
881
1081
  let site;
882
1082
  try {
883
- 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 });
884
1086
  console.log(` ${c.green('✓')} site.yaml ${c.dim(`— ${site.manifest.name}`)}`);
885
1087
  }
886
1088
  catch (err) {
@@ -915,11 +1117,12 @@ async function runValidate(siteDir) {
915
1117
  errors++;
916
1118
  }
917
1119
  }
918
- // 4. List templates
1120
+ // 4. List templates (project-level filesystem, not target content)
1121
+ const projectStorage = createFilesystemProvider();
919
1122
  let templateNames = [];
920
1123
  try {
921
- const entries = await storage.readDir(templatesDir);
922
- 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);
923
1126
  console.log(` ${c.green('✓')} ${c.dim(`${templateNames.length} templates`)}`);
924
1127
  }
925
1128
  catch {
@@ -929,7 +1132,9 @@ async function runValidate(siteDir) {
929
1132
  const adminDir = join(projectRoot, 'admin');
930
1133
  const editorsDir = join(adminDir, 'editors');
931
1134
  if (existsSync(editorsDir)) {
932
- 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'));
933
1138
  for (const file of editorFiles) {
934
1139
  const editorName = file.replace(/\.(ts|tsx)$/, '');
935
1140
  if (!templateNames.includes(editorName)) {
@@ -939,12 +1144,17 @@ async function runValidate(siteDir) {
939
1144
  }
940
1145
  // 6. Check for missing custom fields (schema references field but file doesn't exist)
941
1146
  const fieldsDir = join(adminDir, 'fields');
942
- 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
+ : [];
943
1153
  const { loadTemplate } = await import('../template-loader.js');
944
1154
  const zod = await import('zod');
945
1155
  for (const tplName of templateNames) {
946
1156
  try {
947
- const loaded = await loadTemplate(storage, templatesDir, tplName);
1157
+ const loaded = await loadTemplate(projectStorage, templatesDir, tplName);
948
1158
  const jsonSchema = zod.z.toJSONSchema(loaded.schema);
949
1159
  const props = jsonSchema.properties;
950
1160
  if (!props)
@@ -957,7 +1167,70 @@ async function runValidate(siteDir) {
957
1167
  }
958
1168
  }
959
1169
  }
960
- 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
+ }
961
1234
  }
962
1235
  console.log();
963
1236
  if (errors > 0) {
@@ -1014,19 +1287,26 @@ function renderErrorOverlay(err) {
1014
1287
  </html>`;
1015
1288
  }
1016
1289
  async function runDev(siteDir, port) {
1017
- const storage = createFilesystemProvider();
1018
1290
  const projectRoot = detectProjectRoot(siteDir);
1019
1291
  const templatesDir = join(projectRoot, 'templates');
1020
1292
  const adminDir = join(projectRoot, 'admin');
1021
- 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;
1022
1299
  const app = new Hono();
1023
1300
  // ---- Live reload (SSE) ----
1024
1301
  let reloadId = 0;
1025
1302
  const reloadListeners = new Set();
1026
- function notifyReload() { reloadId++; for (const l of reloadListeners)
1027
- l(); }
1303
+ function notifyReload() {
1304
+ reloadId++;
1305
+ for (const l of reloadListeners)
1306
+ l();
1307
+ }
1028
1308
  const RELOAD_SCRIPT = `<script>new EventSource('/__reload').onmessage = () => location.reload()</script>`;
1029
- app.get('/__reload', (c) => {
1309
+ app.get('/__reload', c => {
1030
1310
  return streamSSE(c, async (stream) => {
1031
1311
  let lastId = reloadId;
1032
1312
  const check = async () => {
@@ -1036,20 +1316,47 @@ async function runDev(siteDir, port) {
1036
1316
  }
1037
1317
  };
1038
1318
  reloadListeners.add(check);
1039
- stream.onAbort(() => { reloadListeners.delete(check); });
1319
+ stream.onAbort(() => {
1320
+ reloadListeners.delete(check);
1321
+ });
1040
1322
  while (true) {
1041
1323
  await stream.sleep(500);
1042
1324
  await check();
1043
1325
  }
1044
1326
  });
1045
1327
  });
1046
- // ---- Site page routes ----
1047
- 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)) {
1048
1343
  app.get(page.route, async (c) => {
1049
1344
  try {
1050
- const freshSite = await loadSite({ siteDir, storage, templatesDir });
1051
- const resolved = await resolvePage(pageName, freshSite);
1052
- 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
+ });
1053
1360
  return c.html(html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`));
1054
1361
  }
1055
1362
  catch (err) {
@@ -1057,6 +1364,44 @@ async function runDev(siteDir, port) {
1057
1364
  }
1058
1365
  });
1059
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
+ }
1060
1405
  // ---- Detect mode: dev (monorepo with apps/admin source) vs production (pre-built) ----
1061
1406
  const cmsWebDir = findCmsDir();
1062
1407
  const cmsStaticDir = findCmsStaticDir();
@@ -1066,14 +1411,14 @@ async function runDev(siteDir, port) {
1066
1411
  let cmsApp = null;
1067
1412
  if (isDevMode) {
1068
1413
  // Dev mode: mount CMS API inline (same process = shared template cache)
1069
- cmsApp = await setupCmsApi(app, siteDir, storage, templatesDir, adminDir);
1414
+ cmsApp = await setupCmsApi(app, source, siteDir, templatesDir, adminDir, targetConfigs);
1070
1415
  }
1071
1416
  else if (cmsStaticDir) {
1072
1417
  // Production mode: inline CMS API + static files
1073
- cmsApp = await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
1418
+ cmsApp = await setupProductionMode(app, source, siteDir, cmsStaticDir, templatesDir, adminDir, targetConfigs);
1074
1419
  }
1075
1420
  // ---- 404 ----
1076
- app.notFound((c) => {
1421
+ app.notFound(c => {
1077
1422
  const routes = [...site.pages.entries()].map(([n, p]) => ` ${p.route} → ${n}`).join('\n');
1078
1423
  return c.html(`<pre style="padding:2rem">Page not found: ${c.req.path}\n\nAvailable:\n${routes}\n /admin → CMS editor</pre>`, 404);
1079
1424
  });
@@ -1092,6 +1437,37 @@ async function runDev(siteDir, port) {
1092
1437
  console.log();
1093
1438
  console.log(` ${c.dim('┃')} Pages ${[...site.pages.entries()].map(([n, p]) => `${c.dim(p.route)} ${c.dim('→')} ${n}`).join(c.dim(', '))}`);
1094
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
+ }
1095
1471
  if (isDevMode && cmsWebDir) {
1096
1472
  // While Vite is spinning up (compiling, scanning deps, attaching
1097
1473
  // middleware), any /admin/* request falls through to the site's 404
@@ -1109,7 +1485,11 @@ async function runDev(siteDir, port) {
1109
1485
  return true;
1110
1486
  }
1111
1487
  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' });
1488
+ res.writeHead(503, {
1489
+ 'Content-Type': 'text/html; charset=utf-8',
1490
+ 'Cache-Control': 'no-store',
1491
+ 'Retry-After': '2',
1492
+ });
1113
1493
  res.end(LOADER_HTML);
1114
1494
  return true;
1115
1495
  }
@@ -1122,6 +1502,7 @@ async function runDev(siteDir, port) {
1122
1502
  if (loaderHandler(req, res))
1123
1503
  return;
1124
1504
  for (const l of originalListeners) {
1505
+ ;
1125
1506
  l(req, res);
1126
1507
  }
1127
1508
  });
@@ -1149,7 +1530,9 @@ async function runDev(siteDir, port) {
1149
1530
  }
1150
1531
  }
1151
1532
  }
1152
- catch { /* ignore */ }
1533
+ catch {
1534
+ /* ignore */
1535
+ }
1153
1536
  }
1154
1537
  }
1155
1538
  const vite = await createViteServer({
@@ -1178,6 +1561,7 @@ async function runDev(siteDir, port) {
1178
1561
  });
1179
1562
  const honoHandler = (req, res) => {
1180
1563
  for (const listener of originalListeners) {
1564
+ ;
1181
1565
  listener(req, res);
1182
1566
  }
1183
1567
  };
@@ -1191,7 +1575,10 @@ async function runDev(siteDir, port) {
1191
1575
  res.end(JSON.stringify({ ready: true }));
1192
1576
  return;
1193
1577
  }
1194
- 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?')) {
1195
1582
  honoHandler(req, res);
1196
1583
  }
1197
1584
  else if (url.startsWith('/admin') || url.startsWith('/@')) {
@@ -1201,6 +1588,26 @@ async function runDev(siteDir, port) {
1201
1588
  honoHandler(req, res);
1202
1589
  }
1203
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);
1204
1611
  cmsReady = true;
1205
1612
  }
1206
1613
  catch (err) {
@@ -1218,13 +1625,20 @@ async function runDev(siteDir, port) {
1218
1625
  const siteWatcher = watch(siteDir, { recursive: true }, (_event, filename) => {
1219
1626
  if (!filename)
1220
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;
1221
1636
  if (filename.endsWith('.json') || filename.endsWith('.yaml')) {
1222
1637
  console.log(` Manifest changed: ${filename}`);
1223
1638
  invalidateAllTemplates();
1224
1639
  // Refresh source sidecars for external edits (git pull, direct file
1225
1640
  // edit). PUT routes already handle their own writes — this catches
1226
1641
  // everything outside the admin UI.
1227
- const norm = filename.replace(/\\/g, '/');
1228
1642
  const pageMatch = /^pages\/(.+)\/page\.json$/.exec(norm);
1229
1643
  const fragMatch = /^fragments\/(.+)\/fragment\.json$/.exec(norm);
1230
1644
  if (pageMatch)
@@ -1234,7 +1648,7 @@ async function runDev(siteDir, port) {
1234
1648
  notifyReload();
1235
1649
  }
1236
1650
  });
1237
- 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}`));
1238
1652
  // Watch templates dir for template source changes
1239
1653
  if (existsSync(templatesDir)) {
1240
1654
  const tplWatcher = watch(templatesDir, { recursive: true }, (_event, filename) => {
@@ -1253,7 +1667,7 @@ async function runDev(siteDir, port) {
1253
1667
  }
1254
1668
  }
1255
1669
  });
1256
- 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}`));
1257
1671
  }
1258
1672
  }
1259
1673
  // ---- Mount CMS API on the main Hono app (shared process = shared template cache) ----
@@ -1265,7 +1679,7 @@ async function runDev(siteDir, port) {
1265
1679
  * so user declarations win the cascade. See #134 and css-theming.md.
1266
1680
  */
1267
1681
  function mountUserThemeRoute(cmsApp, adminDir) {
1268
- cmsApp.get('/theme.css', (c) => {
1682
+ cmsApp.get('/theme.css', c => {
1269
1683
  const themePath = join(adminDir, 'theme.css');
1270
1684
  c.header('Content-Type', 'text/css; charset=utf-8');
1271
1685
  c.header('Cache-Control', 'no-cache');
@@ -1278,35 +1692,22 @@ function mountUserThemeRoute(cmsApp, adminDir) {
1278
1692
  return c.body(readFileSync(themePath, 'utf-8'));
1279
1693
  });
1280
1694
  }
1281
- async function setupCmsApi(app, siteDir, storage, templatesDir, adminDir) {
1282
- const siteYamlPath = join(siteDir, 'site.yaml');
1283
- let targetConfigs;
1284
- if (existsSync(siteYamlPath)) {
1285
- const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
1286
- targetConfigs = siteYaml.targets;
1287
- }
1288
- 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 });
1289
1697
  mountUserThemeRoute(cmsApp, adminDir);
1290
1698
  app.route('/admin', cmsApp);
1291
1699
  return cmsApp;
1292
1700
  }
1293
1701
  // ---- Production mode: inline CMS API + static files from admin-dist/ ----
1294
- async function setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir) {
1295
- // Read target configs from site.yaml — targets are initialized lazily on first publish/fetch
1296
- const siteYamlPath = join(siteDir, 'site.yaml');
1297
- let targetConfigs;
1298
- if (existsSync(siteYamlPath)) {
1299
- const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
1300
- targetConfigs = siteYaml.targets;
1301
- }
1702
+ async function setupProductionMode(app, source, siteDir, cmsStaticDir, templatesDir, adminDir, targetConfigs) {
1302
1703
  // Mount CMS API inline at /admin (production mode — bundled editors/fields)
1303
- const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, production: true, targetConfigs });
1704
+ const cmsApp = createAdminApp({ source, siteDir, templatesDir, adminDir, production: true, targetConfigs });
1304
1705
  mountUserThemeRoute(cmsApp, adminDir);
1305
1706
  app.route('/admin', cmsApp);
1306
1707
  // Serve pre-built CMS static files (includes bundled editors/fields)
1307
1708
  app.use('/admin/*', serveStatic({
1308
1709
  root: cmsStaticDir,
1309
- rewriteRequestPath: (path) => path.replace(/^\/admin/, ''),
1710
+ rewriteRequestPath: path => path.replace(/^\/admin/, ''),
1310
1711
  }));
1311
1712
  // SPA fallback: serve index.html for /admin and unmatched /admin/* routes
1312
1713
  const serveIndex = (c) => {
@@ -1352,11 +1753,15 @@ async function main() {
1352
1753
  }
1353
1754
  const parsed = parseArgs(args.slice(1));
1354
1755
  // Commands that take [target] [site] positional args
1355
- const targetFirstCommands = new Set(['publish', 'serve', 'deploy']);
1756
+ const targetFirstCommands = new Set(['publish', 'serve', 'deploy', 'history', 'undo']);
1356
1757
  // Commands that take [site] positional arg
1357
1758
  const siteOnlyCommands = new Set(['dev', 'validate', 'admin']);
1358
1759
  let siteDir;
1359
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;
1360
1765
  if (command === 'init') {
1361
1766
  await runInit(parsed.positional[0] ?? '.');
1362
1767
  return;
@@ -1366,6 +1771,25 @@ async function main() {
1366
1771
  await runBuild(siteDir);
1367
1772
  return;
1368
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
+ }
1369
1793
  else if (targetFirstCommands.has(command)) {
1370
1794
  // gazetta publish [target] [site]
1371
1795
  const [first, second] = parsed.positional;
@@ -1383,6 +1807,15 @@ async function main() {
1383
1807
  else if (siteOnlyCommands.has(command)) {
1384
1808
  siteDir = await resolveSiteDir(parsed.positional[0]);
1385
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
+ }
1386
1819
  else {
1387
1820
  console.error(` Unknown command: ${command}\n`);
1388
1821
  printHelp();
@@ -1425,9 +1858,91 @@ async function main() {
1425
1858
  case 'admin':
1426
1859
  await runAdmin(siteDir, parsed.port ?? 3000);
1427
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(', ')}`);
1428
1942
  }
1943
+ return { siteDir, targetName, config };
1429
1944
  }
1430
- main().catch((err) => {
1945
+ main().catch(err => {
1431
1946
  console.error(`\n Error: ${err.message}\n`);
1432
1947
  process.exit(1);
1433
1948
  });