gazetta 0.0.8 → 0.1.1

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 (89) hide show
  1. package/admin-dist/assets/index-Dj0qNcv2.css +1 -0
  2. package/admin-dist/assets/index-mw1Q3IRb.js +2459 -0
  3. package/admin-dist/index.html +3 -2
  4. package/dist/admin-api/index.d.ts +6 -0
  5. package/dist/admin-api/index.d.ts.map +1 -1
  6. package/dist/admin-api/index.js +8 -3
  7. package/dist/admin-api/index.js.map +1 -1
  8. package/dist/admin-api/routes/fields.d.ts +4 -0
  9. package/dist/admin-api/routes/fields.d.ts.map +1 -0
  10. package/dist/admin-api/routes/fields.js +21 -0
  11. package/dist/admin-api/routes/fields.js.map +1 -0
  12. package/dist/admin-api/routes/pages.d.ts.map +1 -1
  13. package/dist/admin-api/routes/pages.js +2 -4
  14. package/dist/admin-api/routes/pages.js.map +1 -1
  15. package/dist/admin-api/routes/preview.d.ts +1 -1
  16. package/dist/admin-api/routes/preview.d.ts.map +1 -1
  17. package/dist/admin-api/routes/preview.js +29 -8
  18. package/dist/admin-api/routes/preview.js.map +1 -1
  19. package/dist/admin-api/routes/publish.d.ts +1 -1
  20. package/dist/admin-api/routes/publish.d.ts.map +1 -1
  21. package/dist/admin-api/routes/publish.js +44 -33
  22. package/dist/admin-api/routes/publish.js.map +1 -1
  23. package/dist/admin-api/routes/templates.d.ts +1 -1
  24. package/dist/admin-api/routes/templates.d.ts.map +1 -1
  25. package/dist/admin-api/routes/templates.js +32 -8
  26. package/dist/admin-api/routes/templates.js.map +1 -1
  27. package/dist/cli/index.js +637 -170
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/editor/mount.d.ts +15 -0
  30. package/dist/editor/mount.d.ts.map +1 -1
  31. package/dist/editor/mount.js +684 -29
  32. package/dist/editor/mount.js.map +1 -1
  33. package/dist/formats.d.ts +31 -0
  34. package/dist/formats.d.ts.map +1 -1
  35. package/dist/formats.js +14 -0
  36. package/dist/formats.js.map +1 -1
  37. package/dist/index.d.ts +10 -6
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +8 -5
  40. package/dist/index.js.map +1 -1
  41. package/dist/manifest.d.ts.map +1 -1
  42. package/dist/manifest.js +10 -9
  43. package/dist/manifest.js.map +1 -1
  44. package/dist/providers/r2.d.ts +8 -0
  45. package/dist/providers/r2.d.ts.map +1 -0
  46. package/dist/providers/r2.js +83 -0
  47. package/dist/providers/r2.js.map +1 -0
  48. package/dist/publish-rendered.d.ts +7 -3
  49. package/dist/publish-rendered.d.ts.map +1 -1
  50. package/dist/publish-rendered.js +26 -22
  51. package/dist/publish-rendered.js.map +1 -1
  52. package/dist/renderer.d.ts +1 -0
  53. package/dist/renderer.d.ts.map +1 -1
  54. package/dist/renderer.js +22 -3
  55. package/dist/renderer.js.map +1 -1
  56. package/dist/resolver.d.ts +1 -0
  57. package/dist/resolver.d.ts.map +1 -1
  58. package/dist/resolver.js +23 -3
  59. package/dist/resolver.js.map +1 -1
  60. package/dist/scope.d.ts +10 -4
  61. package/dist/scope.d.ts.map +1 -1
  62. package/dist/scope.js +17 -8
  63. package/dist/scope.js.map +1 -1
  64. package/dist/serve.d.ts +14 -0
  65. package/dist/serve.d.ts.map +1 -0
  66. package/dist/serve.js +135 -0
  67. package/dist/serve.js.map +1 -0
  68. package/dist/site-loader.d.ts +11 -1
  69. package/dist/site-loader.d.ts.map +1 -1
  70. package/dist/site-loader.js +19 -7
  71. package/dist/site-loader.js.map +1 -1
  72. package/dist/targets.d.ts +1 -0
  73. package/dist/targets.d.ts.map +1 -1
  74. package/dist/targets.js +42 -1
  75. package/dist/targets.js.map +1 -1
  76. package/dist/template-loader.d.ts +3 -2
  77. package/dist/template-loader.d.ts.map +1 -1
  78. package/dist/template-loader.js +32 -2
  79. package/dist/template-loader.js.map +1 -1
  80. package/dist/types.d.ts +35 -15
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/types.js +4 -1
  83. package/dist/types.js.map +1 -1
  84. package/dist/workers/cloudflare-r2.d.ts.map +1 -1
  85. package/dist/workers/cloudflare-r2.js +14 -1
  86. package/dist/workers/cloudflare-r2.js.map +1 -1
  87. package/package.json +16 -7
  88. package/admin-dist/assets/index-CAGoaiMq.js +0 -1941
  89. package/admin-dist/assets/index-NBRKgCFg.css +0 -1
package/dist/cli/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { resolve, join } from 'node:path';
2
+ import { resolve, join, dirname } from 'node:path';
3
3
  import { watch, existsSync, readFileSync } from 'node:fs';
4
- import { spawn } from 'node:child_process';
5
4
  import { serve } from '@hono/node-server';
6
5
  import { serveStatic } from '@hono/node-server/serve-static';
7
6
  import { Hono } from 'hono';
@@ -13,60 +12,183 @@ import { renderPage } from '../renderer.js';
13
12
  import { createFilesystemProvider } from '../providers/filesystem.js';
14
13
  import { invalidateTemplate, invalidateAllTemplates } from '../template-loader.js';
15
14
  import { createAdminApp } from '../admin-api/index.js';
15
+ // ANSI color helpers — no dependency, suppressed when NO_COLOR or CI
16
+ const noColor = !!process.env.NO_COLOR || !process.stdout.isTTY;
17
+ 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`,
26
+ };
16
27
  const args = process.argv.slice(2);
17
28
  const command = args[0];
29
+ /**
30
+ * Detect the project root from a site directory.
31
+ * Walks up from siteDir looking for a parent that contains templates/.
32
+ * Falls back to siteDir for flat projects (templates/ inside site dir).
33
+ */
34
+ function detectProjectRoot(siteDir) {
35
+ // If siteDir itself has templates/, it's a flat project
36
+ if (existsSync(join(siteDir, 'templates')))
37
+ return siteDir;
38
+ // Walk up looking for templates/
39
+ let dir = resolve(siteDir);
40
+ const root = resolve('/');
41
+ while (dir !== root) {
42
+ const parent = dirname(dir);
43
+ if (existsSync(join(parent, 'templates')))
44
+ return parent;
45
+ dir = parent;
46
+ }
47
+ // Fallback — use siteDir (templates/ may not exist yet)
48
+ return siteDir;
49
+ }
18
50
  function printHelp() {
19
51
  console.log(`
20
52
  gazetta - Stateless CMS for composable websites
21
53
 
22
54
  Usage:
23
- gazetta init [dir] Create a new site
24
- gazetta dev [site-dir] Start dev server + CMS at /admin
25
- gazetta publish [site-dir] Pre-render and publish to targets
26
- gazetta deploy -t <name> Deploy worker to hosting (one-time setup)
27
- gazetta validate [site-dir] Check site for broken references
28
- gazetta help Show this help message
55
+ gazetta init [dir] Create a new site
56
+ gazetta dev [site] Start dev server + CMS at /admin
57
+ gazetta build Build admin UI for production
58
+ gazetta admin [site] Run production CMS admin server
59
+ gazetta publish [target] [site] Pre-render and publish to a target
60
+ gazetta serve [target] [site] Serve published pages from target storage
61
+ gazetta deploy [target] [site] Deploy worker to hosting (one-time setup)
62
+ gazetta validate [site] Check site for broken references
63
+ gazetta help Show this help message
29
64
 
30
65
  Options:
31
- --port, -p <port> Server port (default: 3000)
32
- --target, -t <name> Target to publish/deploy to (default: all)
66
+ --port, -p <port> Server port (default: 3000)
67
+
68
+ Auto-detection:
69
+ Site is auto-detected from sites/ directory. If multiple sites exist,
70
+ you'll be prompted to choose (or pass it as an argument).
71
+
72
+ Target is auto-detected as the first target in site.yaml. If multiple
73
+ targets exist, you'll be prompted to choose (or pass it as an argument).
33
74
 
34
75
  Examples:
35
- gazetta init my-site # scaffold a new site
36
- gazetta dev # dev server + CMS
37
- gazetta publish # publish to all targets
38
- gazetta publish -t production # publish to specific target
39
- gazetta deploy -t production # deploy worker (one-time)
40
- gazetta validate # check site for broken references
76
+ gazetta init my-site # scaffold a new site
77
+ gazetta dev # dev server (auto-detect site)
78
+ gazetta publish # publish to default target
79
+ gazetta publish production # publish to production
80
+ gazetta publish production my-site # publish specific site to production
81
+ gazetta serve production -p 8080 # serve production on port 8080
82
+ gazetta validate # check site for errors
41
83
  `);
42
84
  }
43
85
  function parseArgs(input) {
44
- let siteDir = '.';
86
+ const positional = [];
45
87
  let port;
46
- let target;
47
88
  for (let i = 0; i < input.length; i++) {
48
89
  if (input[i] === '--port' || input[i] === '-p') {
49
90
  port = parseInt(input[++i], 10);
50
91
  }
51
- else if (input[i] === '--target' || input[i] === '-t') {
52
- target = input[++i];
53
- }
54
92
  else if (!input[i].startsWith('-')) {
55
- siteDir = input[i];
93
+ positional.push(input[i]);
56
94
  }
57
95
  }
58
- return { siteDir: resolve(siteDir), port, target };
96
+ return { positional, port };
97
+ }
98
+ /**
99
+ * Resolve the site directory from positional args or auto-detection.
100
+ * For commands like `dev` and `validate`, the first positional is the site.
101
+ * For commands like `publish` and `serve`, the first positional is the target
102
+ * and the second is the site.
103
+ */
104
+ async function resolveSiteDir(positionalSite) {
105
+ // Explicit site dir provided
106
+ if (positionalSite) {
107
+ const dir = resolve(positionalSite);
108
+ if (existsSync(join(dir, 'site.yaml')))
109
+ return dir;
110
+ // Maybe it's a site name under sites/
111
+ const sitesSubdir = resolve('sites', positionalSite);
112
+ if (existsSync(join(sitesSubdir, 'site.yaml')))
113
+ return sitesSubdir;
114
+ // Maybe it's a project root with sites/
115
+ const mainSite = resolve(dir, 'sites/main');
116
+ if (existsSync(join(mainSite, 'site.yaml')))
117
+ return mainSite;
118
+ return dir; // let loadSite produce a clear error
119
+ }
120
+ // Auto-detect: check current dir first
121
+ if (existsSync(join(resolve('.'), 'site.yaml')))
122
+ return resolve('.');
123
+ // Check sites/ directory
124
+ const sitesDir = resolve('sites');
125
+ if (existsSync(sitesDir)) {
126
+ const { readdirSync, statSync } = await import('node:fs');
127
+ const sites = readdirSync(sitesDir)
128
+ .filter(name => {
129
+ const dir = join(sitesDir, name);
130
+ return statSync(dir).isDirectory() && existsSync(join(dir, 'site.yaml'));
131
+ });
132
+ if (sites.length === 1)
133
+ return join(sitesDir, sites[0]);
134
+ if (sites.length > 1) {
135
+ if (process.env.CI) {
136
+ console.error(`\n Error: multiple sites found. Specify one: gazetta ${command} <site>\n Available: ${sites.join(', ')}\n`);
137
+ process.exit(1);
138
+ }
139
+ const { select } = await import('@clack/prompts');
140
+ const result = await select({
141
+ message: 'Select site:',
142
+ options: sites.map(s => ({ value: s, label: s })),
143
+ });
144
+ if (typeof result === 'symbol')
145
+ process.exit(0); // cancelled
146
+ return join(sitesDir, result);
147
+ }
148
+ }
149
+ // No site found — give a helpful error
150
+ console.error(`\n Error: no site found in current directory.\n`);
151
+ console.error(` To create a new project: gazetta init my-site`);
152
+ console.error(` To use an existing site: gazetta ${command} <path-to-site>\n`);
153
+ process.exit(1);
154
+ }
155
+ /**
156
+ * Resolve target from positional args or auto-detection.
157
+ * Prompts if multiple targets and no explicit choice.
158
+ */
159
+ async function resolveTarget(positionalTarget, siteDir) {
160
+ if (positionalTarget)
161
+ return positionalTarget;
162
+ const siteYamlPath = join(siteDir, 'site.yaml');
163
+ if (!existsSync(siteYamlPath))
164
+ return undefined;
165
+ const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
166
+ const targets = Object.keys(siteYaml.targets ?? {});
167
+ if (targets.length <= 1)
168
+ return targets[0]; // auto-select if 0 or 1
169
+ if (process.env.CI) {
170
+ console.error(`\n Error: multiple targets found. Specify one: gazetta ${command} <target>\n Available: ${targets.join(', ')}\n`);
171
+ process.exit(1);
172
+ }
173
+ const { select } = await import('@clack/prompts');
174
+ const result = await select({
175
+ message: 'Select target:',
176
+ options: targets.map(t => ({ value: t, label: t })),
177
+ });
178
+ if (typeof result === 'symbol')
179
+ process.exit(0);
180
+ return result;
59
181
  }
60
182
  async function runInit(dir) {
61
183
  const { writeFile, mkdir } = await import('node:fs/promises');
62
184
  const target = resolve(dir);
63
- if (existsSync(join(target, 'site.yaml'))) {
64
- console.error(`\n Error: site.yaml already exists in ${target}\n`);
185
+ if (existsSync(join(target, 'sites')) || existsSync(join(target, 'site.yaml'))) {
186
+ console.error(`\n Error: project already exists in ${target}\n`);
65
187
  process.exit(1);
66
188
  }
67
189
  const name = target.split('/').pop() ?? 'my-site';
68
190
  const files = {
69
- 'site.yaml': `name: ${name}\nversion: 1.0.0\n`,
191
+ 'sites/main/site.yaml': `name: ${name}\nversion: 1.0.0\n`,
70
192
  'templates/page-layout/index.ts': `import { z } from 'zod'
71
193
  import type { TemplateFunction } from 'gazetta'
72
194
 
@@ -155,15 +277,14 @@ const template: TemplateFunction = ({ content = {} }) => {
155
277
 
156
278
  export default template
157
279
  `,
158
- 'fragments/header/fragment.yaml': `template: nav
280
+ 'sites/main/fragments/header/fragment.yaml': `template: nav
159
281
  content:
160
282
  brand: ${name}
161
283
  links:
162
284
  - label: Home
163
285
  href: /
164
286
  `,
165
- 'pages/home/page.yaml': `route: /
166
- template: page-layout
287
+ 'sites/main/pages/home/page.yaml': `template: page-layout
167
288
  content:
168
289
  title: ${name}
169
290
  description: A site built with Gazetta
@@ -172,22 +293,29 @@ components:
172
293
  - hero
173
294
  - intro
174
295
  `,
175
- 'pages/home/hero/component.yaml': `template: hero
296
+ 'sites/main/pages/home/hero/component.yaml': `template: hero
176
297
  content:
177
298
  title: Welcome to ${name}
178
299
  subtitle: A site built with Gazetta
179
300
  `,
180
- 'pages/home/intro/component.yaml': `template: text-block
301
+ 'sites/main/pages/home/intro/component.yaml': `template: text-block
181
302
  content:
182
303
  body: "<p>Edit this content in the CMS at <a href='/admin'>/admin</a>.</p>"
183
304
  `,
305
+ 'sites/main/pages/404/page.yaml': `template: page-layout
306
+ content:
307
+ title: "Page Not Found"
308
+ description: "The page you're looking for doesn't exist."
309
+ `,
310
+ 'admin/.gitkeep': '',
311
+ '.gitignore': `node_modules/\ndist/\n.env.local\n`,
184
312
  'package.json': JSON.stringify({
185
313
  name,
186
314
  private: true,
187
315
  type: 'module',
188
- scripts: { dev: 'gazetta dev .' },
189
- dependencies: { gazetta: '*' },
190
- devDependencies: { tsx: '^4.21.0', zod: '^4.3.6' },
316
+ engines: { node: '>=22' },
317
+ scripts: { dev: 'gazetta dev' },
318
+ dependencies: { gazetta: '*', react: '^19.0.0', 'react-dom': '^19.0.0', zod: '^4.0.0' },
191
319
  }, null, 2) + '\n',
192
320
  };
193
321
  for (const [path, content] of Object.entries(files)) {
@@ -195,27 +323,48 @@ content:
195
323
  await mkdir(join(fullPath, '..'), { recursive: true });
196
324
  await writeFile(fullPath, content);
197
325
  }
198
- console.log(`\n Created site in ${target}\n`);
199
- console.log(` Next steps:`);
200
- if (dir !== '.')
201
- console.log(` cd ${dir}`);
202
- console.log(` npm install`);
203
- console.log(` npx gazetta dev`);
204
- console.log();
326
+ const { intro, outro, note, spinner } = await import('@clack/prompts');
327
+ intro(c.bgGreen(c.bold(' gazetta ')));
328
+ note(`${c.bold('templates/')} ${c.dim('4 templates (hero, nav, page-layout, text-block)')}\n` +
329
+ `${c.bold('admin/')} ${c.dim('custom editors and fields')}\n` +
330
+ `${c.bold('sites/main/')} ${c.dim('site content')}\n` +
331
+ ` ${c.dim('pages/home/')} ${c.dim('home page with hero + intro')}\n` +
332
+ ` ${c.dim('pages/404/')} ${c.dim('error page')}\n` +
333
+ ` ${c.dim('fragments/header/')} ${c.dim('shared header nav')}\n` +
334
+ ` ${c.dim('site.yaml')} ${c.dim('site config')}\n` +
335
+ `${c.bold('package.json')}`, `Created ${c.green(name)}/`);
336
+ // Run npm install
337
+ const s = spinner();
338
+ s.start('Installing dependencies');
339
+ try {
340
+ const { execSync } = await import('node:child_process');
341
+ execSync('npm install', { cwd: target, stdio: 'pipe' });
342
+ s.stop('Dependencies installed');
343
+ }
344
+ catch {
345
+ s.stop('npm install failed — run it manually');
346
+ }
347
+ const cdStep = dir !== '.' ? `cd ${dir} && ` : '';
348
+ outro(`Done! Run: ${c.cyan(`${cdStep}npx gazetta dev`)}`);
205
349
  }
206
350
  async function runPublish(siteDir, targetName) {
207
351
  const storage = createFilesystemProvider();
208
- console.log(`\n Loading site from ${siteDir}...`);
209
- const site = await loadSite(siteDir, storage);
352
+ const projectRoot = detectProjectRoot(siteDir);
353
+ const templatesDir = join(projectRoot, 'templates');
354
+ const site = await loadSite({ siteDir, storage, templatesDir });
210
355
  // Load target configs from site.yaml
211
356
  const siteYamlPath = join(siteDir, 'site.yaml');
212
357
  if (!existsSync(siteYamlPath)) {
213
- console.error(`\n Error: No site.yaml found at ${siteDir}\n`);
358
+ console.error(`\n ${c.red('Error:')} No site.yaml found at ${siteDir}\n`);
214
359
  process.exit(1);
215
360
  }
216
361
  const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
217
362
  if (!siteYaml.targets || Object.keys(siteYaml.targets).length === 0) {
218
- console.error(`\n Error: No targets configured in site.yaml\n`);
363
+ console.error(`\n Error: no targets configured in ${siteYamlPath}`);
364
+ console.error(`\n Add a target to site.yaml:\n`);
365
+ console.error(` targets:`);
366
+ console.error(` staging:`);
367
+ console.error(` storage: { type: filesystem, path: ./dist/staging }\n`);
219
368
  process.exit(1);
220
369
  }
221
370
  // Determine which targets to publish to
@@ -230,10 +379,13 @@ async function runPublish(siteDir, targetName) {
230
379
  const { createTargetRegistry } = await import('../targets.js');
231
380
  const targets = await createTargetRegistry(Object.fromEntries(targetNames.map(n => [n, siteYaml.targets[n]])), siteDir);
232
381
  const { publishPageRendered, publishPageStatic, publishFragmentRendered, publishSiteManifest, publishFragmentIndex } = await import('../publish-rendered.js');
233
- console.log(`\n Site: ${site.manifest.name}`);
234
- console.log(` Pages: ${[...site.pages.keys()].join(', ')}`);
235
- console.log(` Fragments: ${[...site.fragments.keys()].join(', ')}`);
236
- console.log(` Targets: ${targetNames.join(', ')}\n`);
382
+ console.log();
383
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('publish')} ${c.dim(site.manifest.name)}`);
384
+ console.log();
385
+ console.log(` ${c.dim('┃')} Pages ${c.dim([...site.pages.keys()].join(', '))}`);
386
+ console.log(` ${c.dim('┃')} Fragments ${c.dim([...site.fragments.keys()].join(', '))}`);
387
+ console.log(` ${c.dim('┃')} Targets ${targetNames.join(', ')}`);
388
+ console.log();
237
389
  for (const name of targetNames) {
238
390
  const targetStorage = targets.get(name);
239
391
  if (!targetStorage) {
@@ -241,47 +393,283 @@ async function runPublish(siteDir, targetName) {
241
393
  continue;
242
394
  }
243
395
  const targetConfig = siteYaml.targets[name];
244
- const isStatic = !targetConfig?.worker;
245
- console.log(` Publishing to ${name}${isStatic ? ' (static)' : ' (ESI)'}...`);
396
+ const { getPublishMode } = await import('../types.js');
397
+ const publishMode = targetConfig ? getPublishMode(targetConfig) : 'static';
398
+ const isStatic = publishMode === 'static';
399
+ console.log(` ${c.bold(name)} ${c.dim(`(${publishMode})`)}`);
246
400
  let totalFiles = 0;
401
+ let totalRemoved = 0;
247
402
  if (isStatic) {
248
403
  // Static mode — fully assembled HTML, no fragments needed separately
249
404
  for (const pageName of site.pages.keys()) {
250
- const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage);
405
+ const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir);
251
406
  totalFiles += files;
252
- console.log(` page: ${pageName} (${files} files)`);
407
+ console.log(` ${c.green('✓')} ${pageName}`);
253
408
  }
254
409
  }
255
410
  else {
256
411
  // ESI mode — fragments separate, pages with placeholders
257
412
  for (const fragName of site.fragments.keys()) {
258
- const { files } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage);
413
+ const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir);
259
414
  totalFiles += files;
260
- console.log(` fragment: ${fragName} (${files} files)`);
415
+ totalRemoved += removed;
416
+ console.log(` ${c.green('✓')} @${fragName}`);
261
417
  }
262
418
  for (const pageName of site.pages.keys()) {
263
- const { files } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache);
419
+ const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir);
264
420
  totalFiles += files;
265
- console.log(` page: ${pageName} (${files} files)`);
421
+ totalRemoved += removed;
422
+ console.log(` ${c.green('✓')} ${pageName}`);
266
423
  }
267
424
  }
268
425
  // Site manifest + fragment index
269
426
  await publishSiteManifest(storage, siteDir, targetStorage);
270
427
  await publishFragmentIndex(storage, siteDir, targetStorage);
271
428
  totalFiles += 2;
272
- console.log(` ${name}: ${totalFiles} files published\n`);
429
+ const removedMsg = totalRemoved > 0 ? c.dim(` (${totalRemoved} old files cleaned)`) : '';
430
+ console.log(`\n ${c.green('✓')} ${c.bold(name)}: ${totalFiles} files published${removedMsg}\n`);
273
431
  }
274
- // Purge cache once at the end (all targets)
275
- const zoneId = process.env.CF_ZONE_ID;
276
- const apiToken = process.env.CF_API_TOKEN;
277
- if (zoneId && apiToken) {
278
- const { createCloudflarePurge } = await import('../publish-rendered.js');
279
- const purge = createCloudflarePurge(zoneId, apiToken);
280
- await purge.purgeAll();
281
- console.log(` Cache purged\n`);
432
+ // Purge CDN cache per target
433
+ const { resolveEnvVars } = await import('../targets.js');
434
+ for (const [name, config] of Object.entries(siteYaml.targets ?? {})) {
435
+ const purge = config.cache?.purge;
436
+ if (!purge)
437
+ continue;
438
+ if (purge.type === 'cloudflare') {
439
+ const apiToken = resolveEnvVars(purge.apiToken);
440
+ if (!apiToken) {
441
+ console.log(` ${name}: purge.apiToken not set, skipping cache purge`);
442
+ continue;
443
+ }
444
+ try {
445
+ const { lookupCloudflareZoneId } = await import('../publish-rendered.js');
446
+ const zoneId = resolveEnvVars(purge.zoneId) ?? (config.siteUrl ? await lookupCloudflareZoneId(config.siteUrl, apiToken) : null);
447
+ if (!zoneId) {
448
+ console.log(` ${name}: zone not found, set purge.zoneId or siteUrl`);
449
+ continue;
450
+ }
451
+ const { createCloudflarePurge } = await import('../publish-rendered.js');
452
+ await createCloudflarePurge(zoneId, apiToken).purgeAll();
453
+ console.log(` ${name}: cache purged`);
454
+ }
455
+ catch (err) {
456
+ console.warn(` ${name}: cache purge failed: ${err.message}`);
457
+ }
458
+ }
282
459
  }
283
460
  console.log(` Done!\n`);
284
461
  }
462
+ async function runBuild(siteDir) {
463
+ const projectRoot = detectProjectRoot(siteDir);
464
+ const outDir = join(projectRoot, 'dist', 'admin');
465
+ console.log();
466
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('build')}`);
467
+ console.log();
468
+ // Find the admin source (monorepo) or pre-built admin (npm package)
469
+ const cmsWebDir = findCmsDir();
470
+ const cmsStaticDir = findCmsStaticDir();
471
+ if (cmsWebDir) {
472
+ // Monorepo — build from source via Vite
473
+ console.log(` ${c.dim('┃')} Admin source ${c.dim(cmsWebDir)}`);
474
+ console.log(` ${c.dim('┃')} Output ${c.dim(outDir)}`);
475
+ console.log();
476
+ const { build } = await import('vite');
477
+ await build({
478
+ configFile: join(cmsWebDir, 'vite.config.ts'),
479
+ root: cmsWebDir,
480
+ base: '/admin/',
481
+ build: {
482
+ outDir,
483
+ emptyOutDir: true,
484
+ chunkSizeWarningLimit: 2000,
485
+ rollupOptions: {
486
+ output: {
487
+ manualChunks: {
488
+ 'vendor-react': ['react', 'react-dom', 'react-dom/client'],
489
+ 'vendor-editor': ['@rjsf/core', '@rjsf/utils', '@rjsf/validator-ajv8', '@hello-pangea/dnd'],
490
+ 'vendor-tiptap': ['@tiptap/react', '@tiptap/starter-kit', '@tiptap/extension-link', '@tiptap/extension-placeholder'],
491
+ },
492
+ },
493
+ onwarn(warning, defaultHandler) {
494
+ if (warning.code === 'MODULE_LEVEL_DIRECTIVE')
495
+ return;
496
+ if (warning.code === 'PLUGIN_WARNING' && warning.message?.includes('dynamically imported'))
497
+ return;
498
+ defaultHandler(warning);
499
+ },
500
+ },
501
+ },
502
+ logLevel: 'warn',
503
+ });
504
+ console.log(` ${c.green('✓')} Admin UI built to ${c.dim(outDir)}`);
505
+ }
506
+ else if (cmsStaticDir) {
507
+ // npm package — copy pre-built admin
508
+ const { cp } = await import('node:fs/promises');
509
+ const { mkdir } = await import('node:fs/promises');
510
+ await mkdir(outDir, { recursive: true });
511
+ await cp(cmsStaticDir, outDir, { recursive: true });
512
+ console.log(` ${c.green('✓')} Admin UI copied to ${c.dim(outDir)}`);
513
+ }
514
+ else {
515
+ console.error(` ${c.red('Error:')} admin UI source not found`);
516
+ console.error(` ${c.dim('Run from monorepo or install gazetta from npm')}`);
517
+ process.exit(1);
518
+ }
519
+ // Bundle custom editors and fields with esbuild + shared import map
520
+ const adminDir = join(projectRoot, 'admin');
521
+ const editorsDir = join(adminDir, 'editors');
522
+ const fieldsDir = join(adminDir, 'fields');
523
+ const entryExtensions = ['.ts', '.tsx', '.jsx'];
524
+ const hasEditors = existsSync(editorsDir) && (await import('node:fs')).readdirSync(editorsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
525
+ const hasFields = existsSync(fieldsDir) && (await import('node:fs')).readdirSync(fieldsDir).some(f => entryExtensions.some(ext => f.endsWith(ext)));
526
+ if (hasEditors || hasFields) {
527
+ const { build: esbuild } = await import('esbuild');
528
+ const { writeFile: writeFileAsync, mkdir: mkdirAsync } = await import('node:fs/promises');
529
+ const sharedDir = join(outDir, '_shared');
530
+ await mkdirAsync(sharedDir, { recursive: true });
531
+ // Build shared dependency bundles (one copy of React, etc.)
532
+ const sharedDeps = {
533
+ 'react': 'export * from "react"; import React from "react"; export default React;',
534
+ 'react-dom/client': 'export * from "react-dom/client";',
535
+ 'react/jsx-runtime': 'export * from "react/jsx-runtime";',
536
+ 'gazetta/editor': 'export * from "gazetta/editor";',
537
+ 'gazetta/types': 'export * from "gazetta/types";',
538
+ };
539
+ const importMap = {};
540
+ for (const [specifier, stub] of Object.entries(sharedDeps)) {
541
+ const safeName = specifier.replace(/\//g, '_');
542
+ const stubFile = join(sharedDir, `_stub_${safeName}.js`);
543
+ await writeFileAsync(stubFile, stub);
544
+ const outfile = join(sharedDir, `${safeName}.js`);
545
+ try {
546
+ await esbuild({
547
+ entryPoints: [stubFile],
548
+ outfile,
549
+ bundle: true,
550
+ format: 'esm',
551
+ platform: 'browser',
552
+ target: 'es2022',
553
+ minify: true,
554
+ define: { 'process.env.NODE_ENV': '"production"' },
555
+ logLevel: 'warning',
556
+ });
557
+ importMap[specifier] = `/admin/_shared/${safeName}.js`;
558
+ }
559
+ catch { /* skip — dep may not be installed */ }
560
+ await import('node:fs/promises').then(fs => fs.rm(stubFile, { force: true }));
561
+ }
562
+ console.log(` ${c.green('✓')} Shared deps: ${Object.keys(importMap).join(', ')}`);
563
+ // Bundle each custom editor/field with shared deps externalized
564
+ const externals = Object.keys(importMap);
565
+ let bundledCount = 0;
566
+ for (const [kind, srcDir] of [['editors', editorsDir], ['fields', fieldsDir]]) {
567
+ if (!existsSync(srcDir))
568
+ continue;
569
+ const { readdirSync } = await import('node:fs');
570
+ const files = readdirSync(srcDir).filter(f => entryExtensions.some(ext => f.endsWith(ext)) && !f.startsWith('.') && !f.startsWith('_'));
571
+ for (const file of files) {
572
+ const name = file.replace(/\.(ts|tsx|jsx)$/, '');
573
+ const entryPoint = join(srcDir, file);
574
+ const outfile = join(outDir, kind, `${name}.js`);
575
+ await esbuild({
576
+ entryPoints: [entryPoint],
577
+ outfile,
578
+ bundle: true,
579
+ format: 'esm',
580
+ platform: 'browser',
581
+ target: 'es2022',
582
+ minify: true,
583
+ external: externals,
584
+ define: { 'process.env.NODE_ENV': '"production"' },
585
+ logLevel: 'warning',
586
+ });
587
+ bundledCount++;
588
+ console.log(` ${c.green('✓')} ${kind}/${name}.js`);
589
+ }
590
+ }
591
+ // Inject import map into index.html
592
+ const indexPath = join(outDir, 'index.html');
593
+ if (existsSync(indexPath)) {
594
+ let html = readFileSync(indexPath, 'utf-8');
595
+ const mapScript = `<script type="importmap">\n${JSON.stringify({ imports: importMap }, null, 2)}\n</script>`;
596
+ html = html.replace('<head>', `<head>\n${mapScript}`);
597
+ await writeFileAsync(indexPath, html);
598
+ console.log(` ${c.green('✓')} Import map injected into index.html`);
599
+ }
600
+ console.log(`\n ${bundledCount} custom ${bundledCount === 1 ? 'module' : 'modules'} bundled`);
601
+ }
602
+ console.log();
603
+ }
604
+ async function runAdmin(siteDir, port) {
605
+ const projectRoot = detectProjectRoot(siteDir);
606
+ const templatesDir = join(projectRoot, 'templates');
607
+ const adminDir = join(projectRoot, 'admin');
608
+ const builtAdminDir = join(projectRoot, 'dist', 'admin');
609
+ if (!existsSync(join(builtAdminDir, 'index.html'))) {
610
+ console.error(`\n ${c.red('Error:')} admin UI not built`);
611
+ console.error(` Run ${c.cyan('gazetta build')} first\n`);
612
+ process.exit(1);
613
+ }
614
+ const app = new Hono();
615
+ app.get('/__reload', (ctx) => ctx.body(null, 204));
616
+ const fsStorage = createFilesystemProvider();
617
+ await setupProductionMode(app, siteDir, fsStorage, builtAdminDir, templatesDir, adminDir);
618
+ // SPA fallback for non-API admin routes
619
+ app.get('*', (ctx) => {
620
+ const indexPath = join(builtAdminDir, 'index.html');
621
+ if (existsSync(indexPath))
622
+ return ctx.html(readFileSync(indexPath, 'utf-8'));
623
+ return ctx.notFound();
624
+ });
625
+ const siteYaml = yaml.load(readFileSync(join(siteDir, 'site.yaml'), 'utf-8'));
626
+ const server = serve({ fetch: app.fetch, port }, () => {
627
+ console.log();
628
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('admin')} ${c.dim(siteYaml.name)}`);
629
+ console.log();
630
+ console.log(` ${c.dim('┃')} Admin ${c.cyan(`http://localhost:${port}/admin`)}`);
631
+ console.log();
632
+ });
633
+ for (const signal of ['SIGINT', 'SIGTERM']) {
634
+ process.on(signal, () => { console.log(`\n Shutting down...`); server.close(() => process.exit(0)); });
635
+ }
636
+ }
637
+ async function runServe(siteDir, port, targetName) {
638
+ const siteYamlPath = join(siteDir, 'site.yaml');
639
+ if (!existsSync(siteYamlPath)) {
640
+ console.error(`\n Error: ${siteYamlPath} not found\n`);
641
+ process.exit(1);
642
+ }
643
+ const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
644
+ if (!siteYaml.targets || Object.keys(siteYaml.targets).length === 0) {
645
+ console.error('\n Error: no targets configured in site.yaml\n');
646
+ process.exit(1);
647
+ }
648
+ const name = targetName ?? Object.keys(siteYaml.targets)[0];
649
+ const config = siteYaml.targets[name];
650
+ if (!config) {
651
+ console.error(`\n Error: target "${name}" not found in site.yaml\n`);
652
+ process.exit(1);
653
+ }
654
+ const { createStorageProvider } = await import('../targets.js');
655
+ const storage = await createStorageProvider(config.storage, siteDir);
656
+ const { getPublishMode } = await import('../types.js');
657
+ const { createServer } = await import('../serve.js');
658
+ const app = createServer({ storage, mode: getPublishMode(config) });
659
+ const server = serve({ fetch: app.fetch, port }, () => {
660
+ console.log();
661
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('serve')} ${c.dim(siteYaml.name)} ${c.dim(`(${name})`)}`);
662
+ console.log();
663
+ console.log(` ${c.dim('┃')} Local ${c.cyan(`http://localhost:${port}/`)}`);
664
+ console.log();
665
+ });
666
+ for (const signal of ['SIGINT', 'SIGTERM']) {
667
+ process.on(signal, () => {
668
+ console.log(`\n Shutting down...`);
669
+ server.close(() => process.exit(0));
670
+ });
671
+ }
672
+ }
285
673
  async function runDeploy(siteDir, targetName) {
286
674
  const { execSync } = await import('node:child_process');
287
675
  const { writeFile, mkdir, rm } = await import('node:fs/promises');
@@ -296,7 +684,7 @@ async function runDeploy(siteDir, targetName) {
296
684
  process.exit(1);
297
685
  }
298
686
  if (!targetName) {
299
- console.error(`\n Error: --target is required for deploy\n Usage: gazetta deploy -t <target-name>\n`);
687
+ console.error(`\n ${c.red('Error:')} target is required for deploy\n Usage: gazetta deploy <target-name>\n`);
300
688
  process.exit(1);
301
689
  }
302
690
  const target = siteYaml.targets[targetName];
@@ -354,19 +742,23 @@ async function runDeploy(siteDir, targetName) {
354
742
  finally {
355
743
  await rm(tmpDir, { recursive: true, force: true });
356
744
  }
357
- console.log(`\n Worker deployed. Now publish content:\n gazetta publish -t ${targetName}\n`);
745
+ console.log(`\n ${c.green('✓')} Worker deployed. Now publish content:\n ${c.cyan(`gazetta publish ${targetName}`)}\n`);
358
746
  }
359
747
  async function runValidate(siteDir) {
360
748
  const storage = createFilesystemProvider();
361
- console.log(`\n Validating ${siteDir}...\n`);
749
+ const projectRoot = detectProjectRoot(siteDir);
750
+ const templatesDir = join(projectRoot, 'templates');
751
+ console.log();
752
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green('validate')} ${c.dim(siteDir)}`);
753
+ console.log();
362
754
  // 1. Check site.yaml
363
755
  let site;
364
756
  try {
365
- site = await loadSite(siteDir, storage);
366
- console.log(` ✓ site.yaml ${site.manifest.name}`);
757
+ site = await loadSite({ siteDir, storage, templatesDir });
758
+ console.log(` ${c.green('')} site.yaml ${c.dim(`— ${site.manifest.name}`)}`);
367
759
  }
368
760
  catch (err) {
369
- console.error(` ✗ site.yaml ${err.message}`);
761
+ console.error(` ${c.red('')} site.yaml ${c.dim(`— ${err.message}`)}`);
370
762
  process.exit(1);
371
763
  }
372
764
  let errors = 0;
@@ -374,14 +766,13 @@ async function runValidate(siteDir) {
374
766
  for (const [fragName, frag] of site.fragments) {
375
767
  try {
376
768
  const { resolveComponent } = await import('../resolver.js');
377
- const templatesDir = join(siteDir, 'templates');
378
- const ctx = { site, templatesDir, visited: new Set(), path: [`@${fragName}`] };
769
+ const ctx = { site, templatesDir: site.templatesDir, visited: new Set(), path: [`@${fragName}`] };
379
770
  await resolveComponent(`@${fragName}`, '', ctx);
380
771
  const childCount = frag.components?.length ?? 0;
381
- console.log(` ✓ fragment: ${fragName} (${childCount} components)`);
772
+ console.log(` ${c.green('')} @${fragName} ${c.dim(`(${childCount} components)`)}`);
382
773
  }
383
774
  catch (err) {
384
- console.error(` ✗ fragment: ${fragName} ${err.message}`);
775
+ console.error(` ${c.red('')} @${fragName} ${c.dim(`— ${err.message}`)}`);
385
776
  errors++;
386
777
  }
387
778
  }
@@ -390,23 +781,57 @@ async function runValidate(siteDir) {
390
781
  try {
391
782
  await resolvePage(pageName, site);
392
783
  const componentCount = page.components?.length ?? 0;
393
- const fragmentCount = page.components?.filter(c => c.startsWith('@')).length ?? 0;
394
- console.log(` ✓ page: ${pageName} (${componentCount} components, ${fragmentCount} fragments)`);
784
+ const fragmentCount = page.components?.filter(cc => cc.startsWith('@')).length ?? 0;
785
+ console.log(` ${c.green('')} ${pageName} ${c.dim(`(${componentCount} components, ${fragmentCount} fragments)`)}`);
395
786
  }
396
787
  catch (err) {
397
- console.error(` ✗ page: ${pageName} ${err.message}`);
788
+ console.error(` ${c.red('')} ${pageName} ${c.dim(`— ${err.message}`)}`);
398
789
  errors++;
399
790
  }
400
791
  }
401
792
  // 4. List templates
402
- const templatesDir = join(siteDir, 'templates');
793
+ let templateNames = [];
403
794
  try {
404
795
  const entries = await storage.readDir(templatesDir);
405
- const templateCount = entries.filter(e => e.isDirectory).length;
406
- console.log(` ✓ ${templateCount} templates found`);
796
+ templateNames = entries.filter(e => e.isDirectory).map(e => e.name);
797
+ console.log(` ${c.green('')} ${c.dim(`${templateNames.length} templates`)}`);
407
798
  }
408
799
  catch {
409
- console.log(` ⚠ templates/ directory not found`);
800
+ console.log(` ${c.yellow('')} ${c.dim('templates/ directory not found')}`);
801
+ }
802
+ // 5. Check for orphaned editors (editor exists but template doesn't)
803
+ const adminDir = join(projectRoot, 'admin');
804
+ const editorsDir = join(adminDir, 'editors');
805
+ if (existsSync(editorsDir)) {
806
+ const editorFiles = (await import('node:fs')).readdirSync(editorsDir).filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
807
+ for (const file of editorFiles) {
808
+ const editorName = file.replace(/\.(ts|tsx)$/, '');
809
+ if (!templateNames.includes(editorName)) {
810
+ console.log(` ${c.yellow('⚠')} orphaned editor: ${c.dim(`admin/editors/${file}`)} ${c.dim('— no matching template')}`);
811
+ }
812
+ }
813
+ }
814
+ // 6. Check for missing custom fields (schema references field but file doesn't exist)
815
+ const fieldsDir = join(adminDir, 'fields');
816
+ const fieldFiles = existsSync(fieldsDir) ? (await import('node:fs')).readdirSync(fieldsDir).filter(f => f.endsWith('.ts') || f.endsWith('.tsx')).map(f => f.replace(/\.(ts|tsx)$/, '')) : [];
817
+ const { loadTemplate } = await import('../template-loader.js');
818
+ const zod = await import('zod');
819
+ for (const tplName of templateNames) {
820
+ try {
821
+ const loaded = await loadTemplate(storage, templatesDir, tplName);
822
+ const jsonSchema = zod.z.toJSONSchema(loaded.schema);
823
+ const props = jsonSchema.properties;
824
+ if (!props)
825
+ continue;
826
+ for (const [propName, prop] of Object.entries(props)) {
827
+ const fieldRef = prop.field;
828
+ if (fieldRef && !fieldFiles.includes(fieldRef)) {
829
+ console.error(` ${c.red('✗')} template ${tplName}.${propName} references field "${fieldRef}" ${c.dim('— not found in admin/fields/')}`);
830
+ errors++;
831
+ }
832
+ }
833
+ }
834
+ catch { /* template load errors already caught above */ }
410
835
  }
411
836
  console.log();
412
837
  if (errors > 0) {
@@ -464,8 +889,10 @@ function renderErrorOverlay(err) {
464
889
  }
465
890
  async function runDev(siteDir, port) {
466
891
  const storage = createFilesystemProvider();
467
- console.log(`\n Loading site from ${siteDir}...`);
468
- const site = await loadSite(siteDir, storage);
892
+ const projectRoot = detectProjectRoot(siteDir);
893
+ const templatesDir = join(projectRoot, 'templates');
894
+ const adminDir = join(projectRoot, 'admin');
895
+ const site = await loadSite({ siteDir, storage, templatesDir });
469
896
  const app = new Hono();
470
897
  // ---- Live reload (SSE) ----
471
898
  let reloadId = 0;
@@ -494,7 +921,7 @@ async function runDev(siteDir, port) {
494
921
  for (const [pageName, page] of site.pages) {
495
922
  app.get(page.route, async (c) => {
496
923
  try {
497
- const freshSite = await loadSite(siteDir, storage);
924
+ const freshSite = await loadSite({ siteDir, storage, templatesDir });
498
925
  const resolved = await resolvePage(pageName, freshSite);
499
926
  const html = await renderPage(resolved, c.req.param());
500
927
  return c.html(html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`));
@@ -504,17 +931,17 @@ async function runDev(siteDir, port) {
504
931
  }
505
932
  });
506
933
  }
507
- // ---- Detect mode: dev (monorepo with apps/admin-ui source) vs production (pre-built) ----
934
+ // ---- Detect mode: dev (monorepo with apps/admin source) vs production (pre-built) ----
508
935
  const cmsWebDir = findCmsDir();
509
936
  const cmsStaticDir = findCmsStaticDir();
510
937
  const isDevMode = cmsWebDir !== null;
511
938
  if (isDevMode) {
512
- // Dev mode: proxy API to subprocess, Vite middleware for HMR
513
- await setupDevMode(app, siteDir, port, cmsWebDir);
939
+ // Dev mode: mount CMS API inline (same process = shared template cache)
940
+ await setupCmsApi(app, siteDir, storage, templatesDir, adminDir);
514
941
  }
515
942
  else if (cmsStaticDir) {
516
943
  // Production mode: inline CMS API + static files
517
- await setupProductionMode(app, siteDir, storage, cmsStaticDir);
944
+ await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
518
945
  }
519
946
  // ---- 404 ----
520
947
  app.notFound((c) => {
@@ -522,36 +949,38 @@ async function runDev(siteDir, port) {
522
949
  return c.html(`<pre style="padding:2rem">Page not found: ${c.req.path}\n\nAvailable:\n${routes}\n /admin → CMS editor</pre>`, 404);
523
950
  });
524
951
  // ---- Start server ----
525
- let apiProc = null;
526
- if (isDevMode) {
527
- const apiPort = port + 100;
528
- apiProc = spawn('npx', ['tsx', join(cmsWebDir, 'src/server/dev.ts'), siteDir], {
529
- env: { ...process.env, API_PORT: String(apiPort) },
530
- stdio: 'pipe',
531
- });
532
- apiProc.stderr?.on('data', (d) => {
533
- const msg = d.toString().trim();
534
- if (msg)
535
- console.error(` [admin-api] ${msg}`);
536
- });
537
- }
952
+ const startTime = performance.now();
538
953
  const nodeServer = serve({ fetch: app.fetch, port }, async () => {
539
- console.log(`\n Gazetta running at http://localhost:${port}\n`);
540
- console.log(` Site: ${site.manifest.name}`);
541
- console.log(` Pages:`);
542
- for (const [name, page] of site.pages)
543
- console.log(` ${page.route} ${name}`);
544
- console.log(` Fragments: ${[...site.fragments.keys()].join(', ') || '(none)'}`);
954
+ const elapsed = Math.round(performance.now() - startTime);
955
+ console.log();
956
+ console.log(` ${c.bgGreen(c.bold(' gazetta '))} ${c.green(site.manifest.name)} ${c.dim(`ready in ${elapsed} ms`)}`);
957
+ console.log();
958
+ console.log(` ${c.dim('┃')} Local ${c.cyan(`http://localhost:${port}/`)}`);
959
+ if (isDevMode) {
960
+ console.log(` ${c.dim('┃')} CMS ${c.cyan(`http://localhost:${port}/admin`)}`);
961
+ console.log(` ${c.dim('┃')} Dev ${c.cyan(`http://localhost:${port}/admin/dev`)}`);
962
+ }
963
+ console.log();
964
+ console.log(` ${c.dim('┃')} Pages ${[...site.pages.entries()].map(([n, p]) => `${c.dim(p.route)} ${c.dim('→')} ${n}`).join(c.dim(', '))}`);
965
+ console.log(` ${c.dim('┃')} Frags ${c.dim([...site.fragments.keys()].join(', ') || '(none)')}`);
545
966
  if (isDevMode && cmsWebDir) {
546
967
  try {
547
968
  const { createServer: createViteServer } = await import('vite');
969
+ const { searchForWorkspaceRoot } = await import('vite');
548
970
  const vite = await createViteServer({
549
971
  configFile: join(cmsWebDir, 'vite.config.ts'),
550
972
  root: cmsWebDir,
551
973
  base: '/admin/',
974
+ resolve: {
975
+ alias: {
976
+ '@editors': join(adminDir, 'editors'),
977
+ '@fields': join(adminDir, 'fields'),
978
+ },
979
+ },
552
980
  server: {
553
981
  middlewareMode: true,
554
982
  hmr: { server: nodeServer },
983
+ fs: { allow: [searchForWorkspaceRoot(cmsWebDir), siteDir] },
555
984
  },
556
985
  });
557
986
  const httpServer = nodeServer;
@@ -574,69 +1003,53 @@ async function runDev(siteDir, port) {
574
1003
  honoHandler(req, res);
575
1004
  }
576
1005
  });
577
- console.log(` CMS: http://localhost:${port}/admin (dev mode + HMR)`);
578
1006
  }
579
1007
  catch (err) {
580
1008
  console.warn(` Warning: CMS UI failed to start: ${err.message}`);
581
1009
  }
582
1010
  }
583
- else if (cmsStaticDir) {
584
- console.log(` CMS: http://localhost:${port}/admin`);
585
- }
586
1011
  console.log();
587
1012
  });
588
- // ---- Cleanup ----
589
- process.on('SIGINT', () => {
590
- apiProc?.kill();
591
- process.exit(0);
592
- });
593
1013
  // ---- File watching ----
1014
+ // Watch site dir for content changes (yaml manifests)
594
1015
  watch(siteDir, { recursive: true }, (_event, filename) => {
595
1016
  if (!filename)
596
1017
  return;
597
- if (filename.endsWith('.ts') && filename.includes('templates/')) {
598
- const parts = filename.split('/');
599
- const idx = parts.indexOf('templates');
600
- if (idx >= 0 && idx + 1 < parts.length) {
601
- console.log(` Template changed: ${parts[idx + 1]}`);
602
- invalidateTemplate(parts[idx + 1]);
603
- }
604
- }
605
- else if (filename.endsWith('.yaml')) {
1018
+ if (filename.endsWith('.yaml')) {
606
1019
  console.log(` Manifest changed: ${filename}`);
607
1020
  invalidateAllTemplates();
1021
+ notifyReload();
608
1022
  }
609
- else
610
- return;
611
- notifyReload();
612
1023
  });
613
- }
614
- // ---- Dev mode: proxy /admin/api/* and /admin/preview/* to CMS API subprocess ----
615
- async function setupDevMode(app, _siteDir, port, _cmsWebDir) {
616
- const apiPort = port + 100;
617
- for (const prefix of ['/admin/api', '/admin/preview']) {
618
- app.all(`${prefix}/*`, async (c) => {
619
- try {
620
- const url = new URL(c.req.url);
621
- const path = url.pathname.replace('/admin', '');
622
- const targetUrl = `http://localhost:${apiPort}${path}${url.search}`;
623
- const res = await fetch(targetUrl, {
624
- method: c.req.method,
625
- headers: c.req.raw.headers,
626
- body: c.req.method !== 'GET' && c.req.method !== 'HEAD' ? c.req.raw.body : undefined,
627
- // @ts-expect-error duplex needed for streaming body
628
- duplex: 'half',
629
- });
630
- return new Response(res.body, { status: res.status, headers: res.headers });
631
- }
632
- catch {
633
- return c.json({ error: 'CMS API not ready' }, 502);
1024
+ // Watch templates dir for template source changes
1025
+ if (existsSync(templatesDir)) {
1026
+ watch(templatesDir, { recursive: true }, (_event, filename) => {
1027
+ if (!filename)
1028
+ return;
1029
+ if (filename.endsWith('.ts') || filename.endsWith('.tsx')) {
1030
+ const parts = filename.split('/');
1031
+ if (parts.length >= 1) {
1032
+ console.log(` Template changed: ${parts[0]}`);
1033
+ invalidateTemplate(parts[0]);
1034
+ notifyReload();
1035
+ }
634
1036
  }
635
1037
  });
636
1038
  }
637
1039
  }
1040
+ // ---- Mount CMS API on the main Hono app (shared process = shared template cache) ----
1041
+ async function setupCmsApi(app, siteDir, storage, templatesDir, adminDir) {
1042
+ const siteYamlPath = join(siteDir, 'site.yaml');
1043
+ let targetConfigs;
1044
+ if (existsSync(siteYamlPath)) {
1045
+ const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
1046
+ targetConfigs = siteYaml.targets;
1047
+ }
1048
+ const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, targetConfigs });
1049
+ app.route('/admin', cmsApp);
1050
+ }
638
1051
  // ---- Production mode: inline CMS API + static files from admin-dist/ ----
639
- async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
1052
+ async function setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir) {
640
1053
  // Read target configs from site.yaml — targets are initialized lazily on first publish/fetch
641
1054
  const siteYamlPath = join(siteDir, 'site.yaml');
642
1055
  let targetConfigs;
@@ -644,10 +1057,10 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
644
1057
  const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
645
1058
  targetConfigs = siteYaml.targets;
646
1059
  }
647
- // Mount CMS API inline at /admin
648
- const cmsApp = createAdminApp({ siteDir, storage, targetConfigs });
1060
+ // Mount CMS API inline at /admin (production mode — bundled editors/fields)
1061
+ const cmsApp = createAdminApp({ siteDir, storage, templatesDir, adminDir, production: true, targetConfigs });
649
1062
  app.route('/admin', cmsApp);
650
- // Serve pre-built CMS static files
1063
+ // Serve pre-built CMS static files (includes bundled editors/fields)
651
1064
  app.use('/admin/*', serveStatic({
652
1065
  root: cmsStaticDir,
653
1066
  rewriteRequestPath: (path) => path.replace(/^\/admin/, ''),
@@ -668,12 +1081,12 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
668
1081
  return c.text('CMS admin UI not found', 404);
669
1082
  });
670
1083
  }
671
- /** Find apps/admin-ui source dir (monorepo dev mode) */
1084
+ /** Find apps/admin source dir (monorepo dev mode) */
672
1085
  function findCmsDir() {
673
1086
  const candidates = [
674
- resolve('apps/admin-ui'),
675
- resolve(import.meta.dirname, '../../../../apps/admin-ui'),
676
- resolve(import.meta.dirname, '../../../apps/admin-ui'),
1087
+ resolve('apps/admin'),
1088
+ resolve(import.meta.dirname, '../../../../apps/admin'),
1089
+ resolve(import.meta.dirname, '../../../apps/admin'),
677
1090
  ];
678
1091
  for (const dir of candidates) {
679
1092
  if (existsSync(join(dir, 'src/server/dev.ts')))
@@ -699,26 +1112,80 @@ async function main() {
699
1112
  process.exit(0);
700
1113
  }
701
1114
  const parsed = parseArgs(args.slice(1));
1115
+ // Commands that take [target] [site] positional args
1116
+ const targetFirstCommands = new Set(['publish', 'serve', 'deploy']);
1117
+ // Commands that take [site] positional arg
1118
+ const siteOnlyCommands = new Set(['dev', 'validate', 'admin']);
1119
+ let siteDir;
1120
+ let targetName;
1121
+ if (command === 'init') {
1122
+ await runInit(parsed.positional[0] ?? '.');
1123
+ return;
1124
+ }
1125
+ else if (command === 'build') {
1126
+ const siteDir = await resolveSiteDir(parsed.positional[0]);
1127
+ await runBuild(siteDir);
1128
+ return;
1129
+ }
1130
+ else if (targetFirstCommands.has(command)) {
1131
+ // gazetta publish [target] [site]
1132
+ const [first, second] = parsed.positional;
1133
+ // If first arg looks like a site path (contains / or has site.yaml), it's the site
1134
+ const firstIsSite = first && (first.includes('/') || existsSync(join(resolve(first), 'site.yaml')));
1135
+ if (firstIsSite) {
1136
+ siteDir = await resolveSiteDir(first);
1137
+ targetName = await resolveTarget(undefined, siteDir);
1138
+ }
1139
+ else {
1140
+ siteDir = await resolveSiteDir(second);
1141
+ targetName = await resolveTarget(first, siteDir);
1142
+ }
1143
+ }
1144
+ else if (siteOnlyCommands.has(command)) {
1145
+ siteDir = await resolveSiteDir(parsed.positional[0]);
1146
+ }
1147
+ else {
1148
+ console.error(` Unknown command: ${command}\n`);
1149
+ printHelp();
1150
+ process.exit(1);
1151
+ return;
1152
+ }
1153
+ // Load .env from project root and site dir (skipped in CI)
1154
+ if (!process.env.CI) {
1155
+ const projectRoot = detectProjectRoot(siteDir);
1156
+ const envDirs = projectRoot !== siteDir ? [projectRoot, siteDir] : [siteDir];
1157
+ for (const dir of envDirs) {
1158
+ for (const name of ['.env', '.env.local']) {
1159
+ const envPath = join(dir, name);
1160
+ if (existsSync(envPath)) {
1161
+ for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
1162
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
1163
+ if (m && !(m[1] in process.env))
1164
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
1165
+ }
1166
+ }
1167
+ }
1168
+ }
1169
+ }
702
1170
  switch (command) {
703
- case 'init':
704
- await runInit(args[1] ?? '.');
705
- break;
706
1171
  case 'publish':
707
- await runPublish(parsed.siteDir, parsed.target);
1172
+ await runPublish(siteDir, targetName);
1173
+ break;
1174
+ case 'serve':
1175
+ await runServe(siteDir, parsed.port ?? 3000, targetName);
708
1176
  break;
709
1177
  case 'deploy':
710
- await runDeploy(parsed.siteDir, parsed.target);
1178
+ await runDeploy(siteDir, targetName);
711
1179
  break;
712
1180
  case 'validate':
713
- await runValidate(parsed.siteDir);
1181
+ await runValidate(siteDir);
714
1182
  break;
715
1183
  case 'dev':
716
- await runDev(parsed.siteDir, parsed.port ?? 3000);
1184
+ await runDev(siteDir, parsed.port ?? 3000);
1185
+ break;
1186
+ case 'admin':
1187
+ await runAdmin(siteDir, parsed.port ?? 3000);
717
1188
  break;
718
- default:
719
- console.error(` Unknown command: ${command}\n`);
720
- printHelp();
721
- process.exit(1);
722
1189
  }
723
1190
  }
724
1191
  main().catch((err) => {