gazetta 0.0.7 → 0.1.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 (91) hide show
  1. package/admin-dist/assets/index-DKYjjDqE.js +2451 -0
  2. package/admin-dist/assets/index-yguWG9_J.css +1 -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 +3 -8
  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 +30 -9
  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/app.js +1 -1
  28. package/dist/app.js.map +1 -1
  29. package/dist/cli/index.js +650 -175
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/editor/mount.d.ts +15 -0
  32. package/dist/editor/mount.d.ts.map +1 -1
  33. package/dist/editor/mount.js +684 -29
  34. package/dist/editor/mount.js.map +1 -1
  35. package/dist/formats.d.ts +31 -0
  36. package/dist/formats.d.ts.map +1 -1
  37. package/dist/formats.js +14 -0
  38. package/dist/formats.js.map +1 -1
  39. package/dist/index.d.ts +10 -6
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -5
  42. package/dist/index.js.map +1 -1
  43. package/dist/manifest.d.ts.map +1 -1
  44. package/dist/manifest.js +10 -10
  45. package/dist/manifest.js.map +1 -1
  46. package/dist/providers/r2.d.ts +8 -0
  47. package/dist/providers/r2.d.ts.map +1 -0
  48. package/dist/providers/r2.js +83 -0
  49. package/dist/providers/r2.js.map +1 -0
  50. package/dist/publish-rendered.d.ts +7 -3
  51. package/dist/publish-rendered.d.ts.map +1 -1
  52. package/dist/publish-rendered.js +27 -34
  53. package/dist/publish-rendered.js.map +1 -1
  54. package/dist/renderer.d.ts +2 -1
  55. package/dist/renderer.d.ts.map +1 -1
  56. package/dist/renderer.js +25 -15
  57. package/dist/renderer.js.map +1 -1
  58. package/dist/resolver.d.ts +1 -0
  59. package/dist/resolver.d.ts.map +1 -1
  60. package/dist/resolver.js +23 -3
  61. package/dist/resolver.js.map +1 -1
  62. package/dist/scope.d.ts +10 -4
  63. package/dist/scope.d.ts.map +1 -1
  64. package/dist/scope.js +19 -8
  65. package/dist/scope.js.map +1 -1
  66. package/dist/serve.d.ts +14 -0
  67. package/dist/serve.d.ts.map +1 -0
  68. package/dist/serve.js +135 -0
  69. package/dist/serve.js.map +1 -0
  70. package/dist/site-loader.d.ts +11 -1
  71. package/dist/site-loader.d.ts.map +1 -1
  72. package/dist/site-loader.js +19 -7
  73. package/dist/site-loader.js.map +1 -1
  74. package/dist/targets.d.ts +1 -0
  75. package/dist/targets.d.ts.map +1 -1
  76. package/dist/targets.js +42 -1
  77. package/dist/targets.js.map +1 -1
  78. package/dist/template-loader.d.ts +3 -2
  79. package/dist/template-loader.d.ts.map +1 -1
  80. package/dist/template-loader.js +32 -2
  81. package/dist/template-loader.js.map +1 -1
  82. package/dist/types.d.ts +35 -16
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +4 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/workers/cloudflare-r2.d.ts.map +1 -1
  87. package/dist/workers/cloudflare-r2.js +14 -1
  88. package/dist/workers/cloudflare-r2.js.map +1 -1
  89. package/package.json +16 -7
  90. package/admin-dist/assets/index-CVC2_Byr.js +0 -1941
  91. package/admin-dist/assets/index-W1ylqX_Y.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,71 +12,201 @@ 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]);
94
+ }
95
+ }
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);
56
147
  }
57
148
  }
58
- return { siteDir: resolve(siteDir), port, target };
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
 
73
- export const schema = z.object({})
195
+ export const schema = z.object({
196
+ title: z.string().describe('Page title'),
197
+ description: z.string().optional().describe('Page description'),
198
+ })
74
199
 
75
- const template: TemplateFunction = ({ children = [] }) => ({
200
+ type Content = z.infer<typeof schema>
201
+
202
+ const template: TemplateFunction<Content> = ({ content, children = [] }) => ({
76
203
  html: \`<main>\${children.map(c => c.html).join('\\n')}</main>\`,
77
204
  css: \`main { max-width: 800px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
78
205
  \${children.map(c => c.css).join('\\n')}\`,
79
206
  js: children.map(c => c.js).filter(Boolean).join('\\n'),
80
- head: \`<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
207
+ head: \`<title>\${content?.title ?? ''}</title>
208
+ \${content?.description ? \`<meta name="description" content="\${content.description}">\` : ''}
209
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
81
210
  \${children.map(c => c.head).filter(Boolean).join('\\n')}\`,
82
211
  })
83
212
 
@@ -148,38 +277,45 @@ const template: TemplateFunction = ({ content = {} }) => {
148
277
 
149
278
  export default template
150
279
  `,
151
- 'fragments/header/fragment.yaml': `template: nav
280
+ 'sites/main/fragments/header/fragment.yaml': `template: nav
152
281
  content:
153
282
  brand: ${name}
154
283
  links:
155
284
  - label: Home
156
285
  href: /
157
286
  `,
158
- 'pages/home/page.yaml': `route: /
159
- template: page-layout
160
- metadata:
287
+ 'sites/main/pages/home/page.yaml': `template: page-layout
288
+ content:
161
289
  title: ${name}
290
+ description: A site built with Gazetta
162
291
  components:
163
292
  - "@header"
164
293
  - hero
165
294
  - intro
166
295
  `,
167
- 'pages/home/hero/component.yaml': `template: hero
296
+ 'sites/main/pages/home/hero/component.yaml': `template: hero
168
297
  content:
169
298
  title: Welcome to ${name}
170
299
  subtitle: A site built with Gazetta
171
300
  `,
172
- 'pages/home/intro/component.yaml': `template: text-block
301
+ 'sites/main/pages/home/intro/component.yaml': `template: text-block
173
302
  content:
174
303
  body: "<p>Edit this content in the CMS at <a href='/admin'>/admin</a>.</p>"
175
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`,
176
312
  'package.json': JSON.stringify({
177
313
  name,
178
314
  private: true,
179
315
  type: 'module',
180
- scripts: { dev: 'gazetta dev .' },
181
- dependencies: { gazetta: '*' },
182
- 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' },
183
319
  }, null, 2) + '\n',
184
320
  };
185
321
  for (const [path, content] of Object.entries(files)) {
@@ -187,27 +323,48 @@ content:
187
323
  await mkdir(join(fullPath, '..'), { recursive: true });
188
324
  await writeFile(fullPath, content);
189
325
  }
190
- console.log(`\n Created site in ${target}\n`);
191
- console.log(` Next steps:`);
192
- if (dir !== '.')
193
- console.log(` cd ${dir}`);
194
- console.log(` npm install`);
195
- console.log(` npx gazetta dev`);
196
- 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`)}`);
197
349
  }
198
350
  async function runPublish(siteDir, targetName) {
199
351
  const storage = createFilesystemProvider();
200
- console.log(`\n Loading site from ${siteDir}...`);
201
- 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 });
202
355
  // Load target configs from site.yaml
203
356
  const siteYamlPath = join(siteDir, 'site.yaml');
204
357
  if (!existsSync(siteYamlPath)) {
205
- 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`);
206
359
  process.exit(1);
207
360
  }
208
361
  const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
209
362
  if (!siteYaml.targets || Object.keys(siteYaml.targets).length === 0) {
210
- 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`);
211
368
  process.exit(1);
212
369
  }
213
370
  // Determine which targets to publish to
@@ -222,10 +379,13 @@ async function runPublish(siteDir, targetName) {
222
379
  const { createTargetRegistry } = await import('../targets.js');
223
380
  const targets = await createTargetRegistry(Object.fromEntries(targetNames.map(n => [n, siteYaml.targets[n]])), siteDir);
224
381
  const { publishPageRendered, publishPageStatic, publishFragmentRendered, publishSiteManifest, publishFragmentIndex } = await import('../publish-rendered.js');
225
- console.log(`\n Site: ${site.manifest.name}`);
226
- console.log(` Pages: ${[...site.pages.keys()].join(', ')}`);
227
- console.log(` Fragments: ${[...site.fragments.keys()].join(', ')}`);
228
- 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();
229
389
  for (const name of targetNames) {
230
390
  const targetStorage = targets.get(name);
231
391
  if (!targetStorage) {
@@ -233,47 +393,283 @@ async function runPublish(siteDir, targetName) {
233
393
  continue;
234
394
  }
235
395
  const targetConfig = siteYaml.targets[name];
236
- const isStatic = !targetConfig?.worker;
237
- 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})`)}`);
238
400
  let totalFiles = 0;
401
+ let totalRemoved = 0;
239
402
  if (isStatic) {
240
403
  // Static mode — fully assembled HTML, no fragments needed separately
241
404
  for (const pageName of site.pages.keys()) {
242
- const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage);
405
+ const { files } = await publishPageStatic(pageName, storage, siteDir, targetStorage, templatesDir);
243
406
  totalFiles += files;
244
- console.log(` page: ${pageName} (${files} files)`);
407
+ console.log(` ${c.green('✓')} ${pageName}`);
245
408
  }
246
409
  }
247
410
  else {
248
411
  // ESI mode — fragments separate, pages with placeholders
249
412
  for (const fragName of site.fragments.keys()) {
250
- const { files } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage);
413
+ const { files, removed } = await publishFragmentRendered(fragName, storage, siteDir, targetStorage, templatesDir);
251
414
  totalFiles += files;
252
- console.log(` fragment: ${fragName} (${files} files)`);
415
+ totalRemoved += removed;
416
+ console.log(` ${c.green('✓')} @${fragName}`);
253
417
  }
254
418
  for (const pageName of site.pages.keys()) {
255
- const { files } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache);
419
+ const { files, removed } = await publishPageRendered(pageName, storage, siteDir, targetStorage, targetConfig?.cache, templatesDir);
256
420
  totalFiles += files;
257
- console.log(` page: ${pageName} (${files} files)`);
421
+ totalRemoved += removed;
422
+ console.log(` ${c.green('✓')} ${pageName}`);
258
423
  }
259
424
  }
260
425
  // Site manifest + fragment index
261
426
  await publishSiteManifest(storage, siteDir, targetStorage);
262
427
  await publishFragmentIndex(storage, siteDir, targetStorage);
263
428
  totalFiles += 2;
264
- 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`);
265
431
  }
266
- // Purge cache once at the end (all targets)
267
- const zoneId = process.env.CF_ZONE_ID;
268
- const apiToken = process.env.CF_API_TOKEN;
269
- if (zoneId && apiToken) {
270
- const { createCloudflarePurge } = await import('../publish-rendered.js');
271
- const purge = createCloudflarePurge(zoneId, apiToken);
272
- await purge.purgeAll();
273
- 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
+ }
274
459
  }
275
460
  console.log(` Done!\n`);
276
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
+ }
277
673
  async function runDeploy(siteDir, targetName) {
278
674
  const { execSync } = await import('node:child_process');
279
675
  const { writeFile, mkdir, rm } = await import('node:fs/promises');
@@ -288,7 +684,7 @@ async function runDeploy(siteDir, targetName) {
288
684
  process.exit(1);
289
685
  }
290
686
  if (!targetName) {
291
- 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`);
292
688
  process.exit(1);
293
689
  }
294
690
  const target = siteYaml.targets[targetName];
@@ -346,19 +742,23 @@ async function runDeploy(siteDir, targetName) {
346
742
  finally {
347
743
  await rm(tmpDir, { recursive: true, force: true });
348
744
  }
349
- 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`);
350
746
  }
351
747
  async function runValidate(siteDir) {
352
748
  const storage = createFilesystemProvider();
353
- 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();
354
754
  // 1. Check site.yaml
355
755
  let site;
356
756
  try {
357
- site = await loadSite(siteDir, storage);
358
- 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}`)}`);
359
759
  }
360
760
  catch (err) {
361
- console.error(` ✗ site.yaml ${err.message}`);
761
+ console.error(` ${c.red('')} site.yaml ${c.dim(`— ${err.message}`)}`);
362
762
  process.exit(1);
363
763
  }
364
764
  let errors = 0;
@@ -366,14 +766,13 @@ async function runValidate(siteDir) {
366
766
  for (const [fragName, frag] of site.fragments) {
367
767
  try {
368
768
  const { resolveComponent } = await import('../resolver.js');
369
- const templatesDir = join(siteDir, 'templates');
370
- const ctx = { site, templatesDir, visited: new Set(), path: [`@${fragName}`] };
769
+ const ctx = { site, templatesDir: site.templatesDir, visited: new Set(), path: [`@${fragName}`] };
371
770
  await resolveComponent(`@${fragName}`, '', ctx);
372
771
  const childCount = frag.components?.length ?? 0;
373
- console.log(` ✓ fragment: ${fragName} (${childCount} components)`);
772
+ console.log(` ${c.green('')} @${fragName} ${c.dim(`(${childCount} components)`)}`);
374
773
  }
375
774
  catch (err) {
376
- console.error(` ✗ fragment: ${fragName} ${err.message}`);
775
+ console.error(` ${c.red('')} @${fragName} ${c.dim(`— ${err.message}`)}`);
377
776
  errors++;
378
777
  }
379
778
  }
@@ -382,23 +781,57 @@ async function runValidate(siteDir) {
382
781
  try {
383
782
  await resolvePage(pageName, site);
384
783
  const componentCount = page.components?.length ?? 0;
385
- const fragmentCount = page.components?.filter(c => c.startsWith('@')).length ?? 0;
386
- 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)`)}`);
387
786
  }
388
787
  catch (err) {
389
- console.error(` ✗ page: ${pageName} ${err.message}`);
788
+ console.error(` ${c.red('')} ${pageName} ${c.dim(`— ${err.message}`)}`);
390
789
  errors++;
391
790
  }
392
791
  }
393
792
  // 4. List templates
394
- const templatesDir = join(siteDir, 'templates');
793
+ let templateNames = [];
395
794
  try {
396
795
  const entries = await storage.readDir(templatesDir);
397
- const templateCount = entries.filter(e => e.isDirectory).length;
398
- 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`)}`);
399
798
  }
400
799
  catch {
401
- 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 */ }
402
835
  }
403
836
  console.log();
404
837
  if (errors > 0) {
@@ -456,8 +889,10 @@ function renderErrorOverlay(err) {
456
889
  }
457
890
  async function runDev(siteDir, port) {
458
891
  const storage = createFilesystemProvider();
459
- console.log(`\n Loading site from ${siteDir}...`);
460
- 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 });
461
896
  const app = new Hono();
462
897
  // ---- Live reload (SSE) ----
463
898
  let reloadId = 0;
@@ -486,9 +921,9 @@ async function runDev(siteDir, port) {
486
921
  for (const [pageName, page] of site.pages) {
487
922
  app.get(page.route, async (c) => {
488
923
  try {
489
- const freshSite = await loadSite(siteDir, storage);
924
+ const freshSite = await loadSite({ siteDir, storage, templatesDir });
490
925
  const resolved = await resolvePage(pageName, freshSite);
491
- const html = await renderPage(resolved, page.metadata, c.req.param());
926
+ const html = await renderPage(resolved, c.req.param());
492
927
  return c.html(html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`));
493
928
  }
494
929
  catch (err) {
@@ -496,17 +931,17 @@ async function runDev(siteDir, port) {
496
931
  }
497
932
  });
498
933
  }
499
- // ---- 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) ----
500
935
  const cmsWebDir = findCmsDir();
501
936
  const cmsStaticDir = findCmsStaticDir();
502
937
  const isDevMode = cmsWebDir !== null;
503
938
  if (isDevMode) {
504
- // Dev mode: proxy API to subprocess, Vite middleware for HMR
505
- 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);
506
941
  }
507
942
  else if (cmsStaticDir) {
508
943
  // Production mode: inline CMS API + static files
509
- await setupProductionMode(app, siteDir, storage, cmsStaticDir);
944
+ await setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir);
510
945
  }
511
946
  // ---- 404 ----
512
947
  app.notFound((c) => {
@@ -514,36 +949,38 @@ async function runDev(siteDir, port) {
514
949
  return c.html(`<pre style="padding:2rem">Page not found: ${c.req.path}\n\nAvailable:\n${routes}\n /admin → CMS editor</pre>`, 404);
515
950
  });
516
951
  // ---- Start server ----
517
- let apiProc = null;
518
- if (isDevMode) {
519
- const apiPort = port + 100;
520
- apiProc = spawn('npx', ['tsx', join(cmsWebDir, 'src/server/dev.ts'), siteDir], {
521
- env: { ...process.env, API_PORT: String(apiPort) },
522
- stdio: 'pipe',
523
- });
524
- apiProc.stderr?.on('data', (d) => {
525
- const msg = d.toString().trim();
526
- if (msg)
527
- console.error(` [admin-api] ${msg}`);
528
- });
529
- }
952
+ const startTime = performance.now();
530
953
  const nodeServer = serve({ fetch: app.fetch, port }, async () => {
531
- console.log(`\n Gazetta running at http://localhost:${port}\n`);
532
- console.log(` Site: ${site.manifest.name}`);
533
- console.log(` Pages:`);
534
- for (const [name, page] of site.pages)
535
- console.log(` ${page.route} ${name}`);
536
- 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)')}`);
537
966
  if (isDevMode && cmsWebDir) {
538
967
  try {
539
968
  const { createServer: createViteServer } = await import('vite');
969
+ const { searchForWorkspaceRoot } = await import('vite');
540
970
  const vite = await createViteServer({
541
971
  configFile: join(cmsWebDir, 'vite.config.ts'),
542
972
  root: cmsWebDir,
543
973
  base: '/admin/',
974
+ resolve: {
975
+ alias: {
976
+ '@editors': join(adminDir, 'editors'),
977
+ '@fields': join(adminDir, 'fields'),
978
+ },
979
+ },
544
980
  server: {
545
981
  middlewareMode: true,
546
982
  hmr: { server: nodeServer },
983
+ fs: { allow: [searchForWorkspaceRoot(cmsWebDir), siteDir] },
547
984
  },
548
985
  });
549
986
  const httpServer = nodeServer;
@@ -566,69 +1003,53 @@ async function runDev(siteDir, port) {
566
1003
  honoHandler(req, res);
567
1004
  }
568
1005
  });
569
- console.log(` CMS: http://localhost:${port}/admin (dev mode + HMR)`);
570
1006
  }
571
1007
  catch (err) {
572
1008
  console.warn(` Warning: CMS UI failed to start: ${err.message}`);
573
1009
  }
574
1010
  }
575
- else if (cmsStaticDir) {
576
- console.log(` CMS: http://localhost:${port}/admin`);
577
- }
578
1011
  console.log();
579
1012
  });
580
- // ---- Cleanup ----
581
- process.on('SIGINT', () => {
582
- apiProc?.kill();
583
- process.exit(0);
584
- });
585
1013
  // ---- File watching ----
1014
+ // Watch site dir for content changes (yaml manifests)
586
1015
  watch(siteDir, { recursive: true }, (_event, filename) => {
587
1016
  if (!filename)
588
1017
  return;
589
- if (filename.endsWith('.ts') && filename.includes('templates/')) {
590
- const parts = filename.split('/');
591
- const idx = parts.indexOf('templates');
592
- if (idx >= 0 && idx + 1 < parts.length) {
593
- console.log(` Template changed: ${parts[idx + 1]}`);
594
- invalidateTemplate(parts[idx + 1]);
595
- }
596
- }
597
- else if (filename.endsWith('.yaml')) {
1018
+ if (filename.endsWith('.yaml')) {
598
1019
  console.log(` Manifest changed: ${filename}`);
599
1020
  invalidateAllTemplates();
1021
+ notifyReload();
600
1022
  }
601
- else
602
- return;
603
- notifyReload();
604
1023
  });
605
- }
606
- // ---- Dev mode: proxy /admin/api/* and /admin/preview/* to CMS API subprocess ----
607
- async function setupDevMode(app, _siteDir, port, _cmsWebDir) {
608
- const apiPort = port + 100;
609
- for (const prefix of ['/admin/api', '/admin/preview']) {
610
- app.all(`${prefix}/*`, async (c) => {
611
- try {
612
- const url = new URL(c.req.url);
613
- const path = url.pathname.replace('/admin', '');
614
- const targetUrl = `http://localhost:${apiPort}${path}${url.search}`;
615
- const res = await fetch(targetUrl, {
616
- method: c.req.method,
617
- headers: c.req.raw.headers,
618
- body: c.req.method !== 'GET' && c.req.method !== 'HEAD' ? c.req.raw.body : undefined,
619
- // @ts-expect-error duplex needed for streaming body
620
- duplex: 'half',
621
- });
622
- return new Response(res.body, { status: res.status, headers: res.headers });
623
- }
624
- catch {
625
- 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
+ }
626
1036
  }
627
1037
  });
628
1038
  }
629
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
+ }
630
1051
  // ---- Production mode: inline CMS API + static files from admin-dist/ ----
631
- async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
1052
+ async function setupProductionMode(app, siteDir, storage, cmsStaticDir, templatesDir, adminDir) {
632
1053
  // Read target configs from site.yaml — targets are initialized lazily on first publish/fetch
633
1054
  const siteYamlPath = join(siteDir, 'site.yaml');
634
1055
  let targetConfigs;
@@ -636,10 +1057,10 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
636
1057
  const siteYaml = yaml.load(readFileSync(siteYamlPath, 'utf-8'));
637
1058
  targetConfigs = siteYaml.targets;
638
1059
  }
639
- // Mount CMS API inline at /admin
640
- 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 });
641
1062
  app.route('/admin', cmsApp);
642
- // Serve pre-built CMS static files
1063
+ // Serve pre-built CMS static files (includes bundled editors/fields)
643
1064
  app.use('/admin/*', serveStatic({
644
1065
  root: cmsStaticDir,
645
1066
  rewriteRequestPath: (path) => path.replace(/^\/admin/, ''),
@@ -660,12 +1081,12 @@ async function setupProductionMode(app, siteDir, storage, cmsStaticDir) {
660
1081
  return c.text('CMS admin UI not found', 404);
661
1082
  });
662
1083
  }
663
- /** Find apps/admin-ui source dir (monorepo dev mode) */
1084
+ /** Find apps/admin source dir (monorepo dev mode) */
664
1085
  function findCmsDir() {
665
1086
  const candidates = [
666
- resolve('apps/admin-ui'),
667
- resolve(import.meta.dirname, '../../../../apps/admin-ui'),
668
- 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'),
669
1090
  ];
670
1091
  for (const dir of candidates) {
671
1092
  if (existsSync(join(dir, 'src/server/dev.ts')))
@@ -691,26 +1112,80 @@ async function main() {
691
1112
  process.exit(0);
692
1113
  }
693
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
+ }
694
1170
  switch (command) {
695
- case 'init':
696
- await runInit(args[1] ?? '.');
697
- break;
698
1171
  case 'publish':
699
- await runPublish(parsed.siteDir, parsed.target);
1172
+ await runPublish(siteDir, targetName);
1173
+ break;
1174
+ case 'serve':
1175
+ await runServe(siteDir, parsed.port ?? 3000, targetName);
700
1176
  break;
701
1177
  case 'deploy':
702
- await runDeploy(parsed.siteDir, parsed.target);
1178
+ await runDeploy(siteDir, targetName);
703
1179
  break;
704
1180
  case 'validate':
705
- await runValidate(parsed.siteDir);
1181
+ await runValidate(siteDir);
706
1182
  break;
707
1183
  case 'dev':
708
- 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);
709
1188
  break;
710
- default:
711
- console.error(` Unknown command: ${command}\n`);
712
- printHelp();
713
- process.exit(1);
714
1189
  }
715
1190
  }
716
1191
  main().catch((err) => {