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