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