@koehler8/cms 1.0.0-beta.34 → 1.0.0-beta.36
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/bin/cms-ssg-build.js +13 -0
- package/package.json +4 -3
- package/scripts/ssg-build/index.mjs +197 -0
- package/src/composables/usePageConfig.js +20 -9
- package/src/main.js +25 -1
- package/src/utils/loadConfig.js +43 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/templates/scaffolds/site/package.json +1 -1
- package/vite-plugin.js +27 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin wrapper that runs the memory-bounded SSG build orchestrator from the
|
|
3
|
+
// consuming site's cwd. The script does its own argument handling, spawns the
|
|
4
|
+
// per-shard `vite-ssg build` processes, and prints its own status.
|
|
5
|
+
// See scripts/ssg-build/index.mjs and SSG-MEMORY-PLAN.md.
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
await import(path.resolve(__dirname, '..', 'scripts', 'ssg-build', 'index.mjs'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koehler8/cms",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.36",
|
|
4
4
|
"description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"bin": {
|
|
56
56
|
"cms-generate-public-assets": "./bin/cms-generate-public-assets.js",
|
|
57
|
+
"cms-ssg-build": "./bin/cms-ssg-build.js",
|
|
57
58
|
"cms-migrate-image-variants": "./bin/cms-migrate-image-variants.js",
|
|
58
59
|
"cms-validate-themes": "./bin/cms-validate-themes.js",
|
|
59
60
|
"cms-validate-extensions": "./bin/cms-validate-extensions.js",
|
|
@@ -87,13 +88,13 @@
|
|
|
87
88
|
"dotenv": "^17.2.3",
|
|
88
89
|
"pinia": "^3.0.1",
|
|
89
90
|
"png-to-ico": "^3.0.1",
|
|
90
|
-
"sharp": "^0.
|
|
91
|
+
"sharp": "^0.35.3"
|
|
91
92
|
},
|
|
92
93
|
"devDependencies": {
|
|
93
94
|
"@vue/test-utils": "^2.4.10",
|
|
94
95
|
"happy-dom": "^20.9.0",
|
|
95
96
|
"vitest": "^4.1.6",
|
|
96
|
-
"vue": "^3.5.
|
|
97
|
+
"vue": "^3.5.39",
|
|
97
98
|
"vue-router": "^5.0.7"
|
|
98
99
|
},
|
|
99
100
|
"engines": {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cms-ssg-build — memory-bounded SSG pre-render (the durable fix for the
|
|
3
|
+
* per-route heap accumulation in vite-ssg's single-process render; see
|
|
4
|
+
* SSG-MEMORY-PLAN.md).
|
|
5
|
+
*
|
|
6
|
+
* vite-ssg renders every route in one Node process and leaves a fixed retained
|
|
7
|
+
* cost per route, so peak heap scales with total routes (pages × locales) and
|
|
8
|
+
* large sites OOM. This orchestrator runs the render as N separate
|
|
9
|
+
* `vite-ssg build` processes, each handling a slice of the routes (via the
|
|
10
|
+
* framework's CMS_SSG_SHARD hook) into its own outDir, then merges the shards
|
|
11
|
+
* into dist/. Because each shard is its own process, memory is reclaimed
|
|
12
|
+
* between shards — peak heap is bounded by SLICE size, not total routes,
|
|
13
|
+
* independent of how large the site grows.
|
|
14
|
+
*
|
|
15
|
+
* Shards run sequentially by default: that gives the LOWEST peak memory (one
|
|
16
|
+
* shard live at a time), which is the whole point. Parallelism would trade that
|
|
17
|
+
* back for build speed and also races on the plugin's shared writes to the site
|
|
18
|
+
* root, so it stays opt-in/experimental.
|
|
19
|
+
*
|
|
20
|
+
* Small sites (≤ one shard) fall through to a single ordinary `vite-ssg build`,
|
|
21
|
+
* byte-identical to today.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import fsp from 'node:fs/promises';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
|
|
30
|
+
const CWD = process.cwd();
|
|
31
|
+
const TAG = '[cms-ssg-build]';
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const opts = {
|
|
35
|
+
shardSize: 150, // routes per shard
|
|
36
|
+
concurrency: 1, // sequential by default (lowest peak memory + no shared-write race)
|
|
37
|
+
heap: 2048, // per-shard --max-old-space-size (MB); a slice fits comfortably
|
|
38
|
+
siteDir: './site',
|
|
39
|
+
passthrough: [], // forwarded verbatim to `vite-ssg build`
|
|
40
|
+
};
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === '--shard-size') opts.shardSize = Number(argv[++i]) || opts.shardSize;
|
|
44
|
+
else if (a === '--concurrency') opts.concurrency = Math.max(1, Number(argv[++i]) || 1);
|
|
45
|
+
else if (a === '--heap') opts.heap = Number(argv[++i]) || opts.heap;
|
|
46
|
+
else if (a === '--site-dir') opts.siteDir = argv[++i] || opts.siteDir;
|
|
47
|
+
else opts.passthrough.push(a);
|
|
48
|
+
}
|
|
49
|
+
return opts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Estimate the route count the same way the Vite plugin discovers routes
|
|
53
|
+
// (base-locale pages × content locales, + /404 + /admin). Only used to choose
|
|
54
|
+
// the shard count N; the framework's CMS_SSG_SHARD filter partitions whatever
|
|
55
|
+
// routes actually exist, so an approximate N still yields a correct, complete
|
|
56
|
+
// dist/ — it just tunes slice sizes.
|
|
57
|
+
function estimateRouteCount(siteDir) {
|
|
58
|
+
const contentDir = path.join(siteDir, 'content');
|
|
59
|
+
let baseLocale = 'en';
|
|
60
|
+
try {
|
|
61
|
+
baseLocale = JSON.parse(fs.readFileSync(path.join(contentDir, 'content.config.json'), 'utf8')).baseLocale || 'en';
|
|
62
|
+
} catch { /* default en */ }
|
|
63
|
+
|
|
64
|
+
let pages = 1;
|
|
65
|
+
try {
|
|
66
|
+
pages = fs.readdirSync(path.join(contentDir, baseLocale, 'pages')).filter((f) => f.endsWith('.json')).length || 1;
|
|
67
|
+
} catch { /* single page */ }
|
|
68
|
+
|
|
69
|
+
let locales = 1;
|
|
70
|
+
try {
|
|
71
|
+
locales = fs.readdirSync(contentDir, { withFileTypes: true })
|
|
72
|
+
.filter((d) => d.isDirectory())
|
|
73
|
+
.filter((d) => {
|
|
74
|
+
const dir = path.join(contentDir, d.name);
|
|
75
|
+
return fs.existsSync(path.join(dir, 'site.json'))
|
|
76
|
+
|| fs.existsSync(path.join(dir, 'shared.json'))
|
|
77
|
+
|| fs.existsSync(path.join(dir, 'pages'));
|
|
78
|
+
}).length || 1;
|
|
79
|
+
} catch { /* single locale */ }
|
|
80
|
+
|
|
81
|
+
return pages * locales + 2; // + /404 + /admin
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveViteSsgBin() {
|
|
85
|
+
const local = path.join(CWD, 'node_modules', '.bin', 'vite-ssg');
|
|
86
|
+
return fs.existsSync(local) ? local : 'vite-ssg';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runViteSsg({ shard, outDir, heap, passthrough }) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const env = { ...process.env };
|
|
92
|
+
if (shard) env.CMS_SSG_SHARD = shard;
|
|
93
|
+
if (outDir) env.CMS_SSG_OUTDIR = outDir;
|
|
94
|
+
// A shard renders a bounded slice, so a modest heap suffices — this is what
|
|
95
|
+
// lets sites drop the 8 GB stop-loss flag. Strip any inherited
|
|
96
|
+
// --max-old-space-size and set our own.
|
|
97
|
+
const base = (process.env.NODE_OPTIONS || '').replace(/--max-old-space-size=\d+/g, '').trim();
|
|
98
|
+
env.NODE_OPTIONS = `${base} --max-old-space-size=${heap}`.trim();
|
|
99
|
+
|
|
100
|
+
const child = spawn(resolveViteSsgBin(), ['build', ...passthrough], { cwd: CWD, env });
|
|
101
|
+
let out = '';
|
|
102
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
103
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
104
|
+
child.on('close', (code) => resolve({ code, out }));
|
|
105
|
+
child.on('error', (err) => resolve({ code: 1, out: String(err && err.stack ? err.stack : err) }));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pool(items, concurrency, worker) {
|
|
110
|
+
const results = new Array(items.length);
|
|
111
|
+
let next = 0;
|
|
112
|
+
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
113
|
+
while (next < items.length) {
|
|
114
|
+
const i = next++;
|
|
115
|
+
results[i] = await worker(items[i], i);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
await Promise.all(runners);
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Union-merge every shard's output into dist/. Shared files (assets/, sitemap,
|
|
123
|
+
// robots, manifest, favicon, 404.html) are byte-identical across shards, and
|
|
124
|
+
// per-route HTML files are disjoint — so a plain overwrite-merge is a clean
|
|
125
|
+
// union. The ONE exception is dist/index.html: only the shard that rendered `/`
|
|
126
|
+
// produced the real home page; every other shard's client build left a
|
|
127
|
+
// hydration shell there. So after merging, restore the rendered home
|
|
128
|
+
// explicitly (identified by the data-server-rendered marker).
|
|
129
|
+
async function mergeShards(shardDirs, finalDist) {
|
|
130
|
+
await fsp.rm(finalDist, { recursive: true, force: true });
|
|
131
|
+
await fsp.mkdir(finalDist, { recursive: true });
|
|
132
|
+
for (const dir of shardDirs) {
|
|
133
|
+
await fsp.cp(dir, finalDist, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let renderedHome = null;
|
|
137
|
+
for (const dir of shardDirs) {
|
|
138
|
+
try {
|
|
139
|
+
const html = await fsp.readFile(path.join(dir, 'index.html'), 'utf8');
|
|
140
|
+
if (html.includes('data-server-rendered="true"')) { renderedHome = html; break; }
|
|
141
|
+
} catch { /* shard has no index.html */ }
|
|
142
|
+
}
|
|
143
|
+
if (renderedHome != null) {
|
|
144
|
+
await fsp.writeFile(path.join(finalDist, 'index.html'), renderedHome, 'utf8');
|
|
145
|
+
} else {
|
|
146
|
+
console.warn(`${TAG} warning: no shard produced a server-rendered home page; dist/index.html left as the client shell.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main() {
|
|
151
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
152
|
+
const siteDir = path.resolve(CWD, opts.siteDir);
|
|
153
|
+
const estRoutes = estimateRouteCount(siteDir);
|
|
154
|
+
const N = Math.max(1, Math.ceil(estRoutes / opts.shardSize));
|
|
155
|
+
|
|
156
|
+
if (N <= 1) {
|
|
157
|
+
console.log(`${TAG} ~${estRoutes} routes ≤ shard size ${opts.shardSize}: running a single vite-ssg build (no sharding needed).`);
|
|
158
|
+
const r = await runViteSsg({ heap: opts.heap, passthrough: opts.passthrough });
|
|
159
|
+
process.stdout.write(r.out);
|
|
160
|
+
process.exit(r.code);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(`${TAG} ~${estRoutes} routes → ${N} shards of ≤${opts.shardSize}, ${opts.heap} MB heap each, ${opts.concurrency === 1 ? 'sequential' : `${opts.concurrency}-way parallel`}. Peak heap is bounded per shard.`);
|
|
164
|
+
if (opts.concurrency > 1) {
|
|
165
|
+
console.warn(`${TAG} warning: --concurrency > 1 is experimental — parallel shards race on the plugin's shared site-root writes and raise peak memory. Sequential is recommended.`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const tmp = path.join(CWD, '.cms-ssg');
|
|
169
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
170
|
+
await fsp.mkdir(tmp, { recursive: true });
|
|
171
|
+
|
|
172
|
+
const shards = Array.from({ length: N }, (_, k) => ({ k, outDir: path.join('.cms-ssg', `dist-${k}`) }));
|
|
173
|
+
const t0 = Date.now();
|
|
174
|
+
const results = await pool(shards, opts.concurrency, async (s) => {
|
|
175
|
+
const started = Date.now();
|
|
176
|
+
process.stdout.write(`${TAG} shard ${s.k + 1}/${N} rendering…\n`);
|
|
177
|
+
const r = await runViteSsg({ shard: `${s.k}/${N}`, outDir: s.outDir, heap: opts.heap, passthrough: opts.passthrough });
|
|
178
|
+
process.stdout.write(`${TAG} shard ${s.k + 1}/${N} ${r.code === 0 ? 'ok' : 'FAILED'} (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
|
|
179
|
+
return { ...r, k: s.k };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const failed = results.filter((r) => r.code !== 0);
|
|
183
|
+
if (failed.length) {
|
|
184
|
+
console.error(`${TAG} ${failed.length}/${N} shard(s) failed:`);
|
|
185
|
+
for (const f of failed) {
|
|
186
|
+
console.error(`${TAG} ---- shard ${f.k} output (tail) ----`);
|
|
187
|
+
console.error(f.out.slice(-4000));
|
|
188
|
+
}
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await mergeShards(shards.map((s) => path.join(CWD, s.outDir)), path.join(CWD, 'dist'));
|
|
193
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
194
|
+
console.log(`${TAG} merged ${N} shards → dist/ in ${((Date.now() - t0) / 1000).toFixed(1)}s. Done.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main().catch((err) => { console.error(TAG, err); process.exit(1); });
|
|
@@ -219,11 +219,20 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
219
219
|
cachedConfig = primed;
|
|
220
220
|
lastLocaleKey = localeKey;
|
|
221
221
|
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
222
|
+
// Decide whether a soft (no blank-out) background reload of the FULL config
|
|
223
|
+
// is needed after this first synchronous paint. Two independent triggers,
|
|
224
|
+
// both satisfied by the same single reload:
|
|
225
|
+
//
|
|
226
|
+
// 1. Partial embed: the server trimmed the embedded config to just this
|
|
227
|
+
// page (site.trimInitialState), so the primed config can't resolve
|
|
228
|
+
// OTHER pages for in-SPA navigation. Load the full config now; the
|
|
229
|
+
// current page re-applies identically, so there's no visible change.
|
|
230
|
+
// 2. Saved-locale switch: the server has no localStorage, so a base-locale
|
|
231
|
+
// route rendered the base locale. If a returning visitor stored a
|
|
232
|
+
// different locale, reconcile to it. (loadConfigData reads localStorage
|
|
233
|
+
// when no explicit locale is passed, so the same reload resolves it.)
|
|
234
|
+
let needsFullReload = Boolean(primed && primed.pagesPartial);
|
|
235
|
+
|
|
227
236
|
if (localeForConfig === undefined && typeof localStorage !== 'undefined') {
|
|
228
237
|
let stored = '';
|
|
229
238
|
try {
|
|
@@ -231,10 +240,12 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
231
240
|
} catch {
|
|
232
241
|
stored = '';
|
|
233
242
|
}
|
|
234
|
-
if (stored)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
if (stored) needsFullReload = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (needsFullReload) {
|
|
247
|
+
lastLocaleKey = null; // force syncPage to reload (localStorage-resolved for base routes)
|
|
248
|
+
syncPage({ soft: true });
|
|
238
249
|
}
|
|
239
250
|
return true;
|
|
240
251
|
}
|
package/src/main.js
CHANGED
|
@@ -6,7 +6,7 @@ import App from './App.vue';
|
|
|
6
6
|
import { routes, resolveHistory, applyRouterGuards } from './router/index.js';
|
|
7
7
|
|
|
8
8
|
import { shouldEnableAnalytics, scheduleAnalyticsLoad } from './utils/cookieConsent.js';
|
|
9
|
-
import { loadConfigData, primeConfigSync } from './utils/loadConfig.js';
|
|
9
|
+
import { loadConfigData, primeConfigSync, trimConfigToPage, resolvePageIdForRoute } from './utils/loadConfig.js';
|
|
10
10
|
import { persistAttributionFromLocation } from './utils/trackingContext.js';
|
|
11
11
|
import { applyThemeVariables } from './themes/themeManager.js';
|
|
12
12
|
import { setActiveThemeKey } from './utils/themeColors.js';
|
|
@@ -145,6 +145,18 @@ export function createCmsApp() {
|
|
|
145
145
|
return null;
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
+
// SSG only: resolve the route being pre-rendered to its page id so the
|
|
149
|
+
// embedded config can be trimmed to that single page when the site opts
|
|
150
|
+
// into `site.trimInitialState`. The matching rules live in the pure,
|
|
151
|
+
// unit-tested resolvePageIdForRoute (returns null on ambiguity → keep the
|
|
152
|
+
// full config, a safe no-op).
|
|
153
|
+
const resolveCurrentPageId = (config) => {
|
|
154
|
+
const route = extractCurrentRoute();
|
|
155
|
+
const rawPath = typeof route?.path === 'string' ? route.path : '';
|
|
156
|
+
const localeParam = pickLocaleParam(route?.params?.locale);
|
|
157
|
+
return resolvePageIdForRoute(config, rawPath, localeParam);
|
|
158
|
+
};
|
|
159
|
+
|
|
148
160
|
const loadSiteConfig = async (options = {}) => {
|
|
149
161
|
const requestedLocale =
|
|
150
162
|
pickLocaleParam(options.locale) ??
|
|
@@ -178,6 +190,18 @@ export function createCmsApp() {
|
|
|
178
190
|
if (!isClient) {
|
|
179
191
|
try {
|
|
180
192
|
await loadSiteConfig();
|
|
193
|
+
// Opt-in payload trim: embed only the current page's config in
|
|
194
|
+
// window.__INITIAL_STATE__ rather than every page's (the full-site
|
|
195
|
+
// blob is byte-identical on every page and can dominate page weight).
|
|
196
|
+
// The rendered HTML body is unaffected — usePageConfig resolves the
|
|
197
|
+
// SSR render from its own loadConfigData() call, not from
|
|
198
|
+
// initialState — so this only shrinks the serialized hydration blob.
|
|
199
|
+
if (initialState.siteConfig?.site?.trimInitialState === true) {
|
|
200
|
+
const currentPageId = resolveCurrentPageId(initialState.siteConfig);
|
|
201
|
+
if (currentPageId) {
|
|
202
|
+
initialState.siteConfig = trimConfigToPage(initialState.siteConfig, currentPageId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
181
205
|
} catch (error) {
|
|
182
206
|
console.error('Failed to load site configuration during SSG build', error);
|
|
183
207
|
}
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -34,6 +34,49 @@ export function cloneConfig(value) {
|
|
|
34
34
|
return value;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Reduce a resolved config's `pages` map to a single page, preserving `site`
|
|
38
|
+
// and `shared` (which every page needs globally). Used by the SSG pass when a
|
|
39
|
+
// site opts into `site.trimInitialState` so each pre-rendered page embeds only
|
|
40
|
+
// its own page config in `window.__INITIAL_STATE__` instead of the entire
|
|
41
|
+
// site's pages (which is byte-identical on every page and can dominate the
|
|
42
|
+
// page weight). Marks the result `pagesPartial: true` so the client knows the
|
|
43
|
+
// embedded config can't resolve other pages and must lazy-load the full config
|
|
44
|
+
// before in-SPA navigation (see usePageConfig.hydrateFromSyncCache). Returns
|
|
45
|
+
// the config untouched when the page id is absent — a safe no-op fallback so a
|
|
46
|
+
// mis-resolved route never trims away the page actually being rendered.
|
|
47
|
+
export function trimConfigToPage(config, pageId) {
|
|
48
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
|
|
49
|
+
if (!config.pages || typeof config.pages !== 'object') return config;
|
|
50
|
+
if (!pageId || !Object.prototype.hasOwnProperty.call(config.pages, pageId)) return config;
|
|
51
|
+
return { ...config, pages: { [pageId]: config.pages[pageId] }, pagesPartial: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Map a route being pre-rendered to its page id, for the SSG trim above.
|
|
55
|
+
// Strips a leading `/{locale}` segment (non-base locales render at
|
|
56
|
+
// `/{locale}/path`), normalizes trailing/duplicate slashes, and matches against
|
|
57
|
+
// each page's `path` (defaulting a path-less page to '/'). Returns the id only
|
|
58
|
+
// on an UNAMBIGUOUS single match — home, path-less, or colliding routes return
|
|
59
|
+
// null so the caller keeps the full config (a safe no-op). Pure/exported so the
|
|
60
|
+
// locale-strip + matching rules are unit-testable without a full SSG build.
|
|
61
|
+
export function resolvePageIdForRoute(config, routePath, localeParam) {
|
|
62
|
+
if (typeof routePath !== 'string' || !routePath) return null;
|
|
63
|
+
let target = routePath;
|
|
64
|
+
if (localeParam && typeof localeParam === 'string') {
|
|
65
|
+
const prefix = `/${localeParam}`;
|
|
66
|
+
if (target === prefix) target = '/';
|
|
67
|
+
else if (target.startsWith(`${prefix}/`)) target = target.slice(prefix.length);
|
|
68
|
+
}
|
|
69
|
+
const normalize = (p) =>
|
|
70
|
+
(typeof p === 'string' ? p : '/').replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
|
|
71
|
+
target = normalize(target);
|
|
72
|
+
const pages = (config && config.pages) || {};
|
|
73
|
+
const matches = Object.entries(pages).filter(([, data]) => {
|
|
74
|
+
const pagePath = data && typeof data.path === 'string' && data.path.trim() ? data.path : '/';
|
|
75
|
+
return normalize(pagePath) === target;
|
|
76
|
+
});
|
|
77
|
+
return matches.length === 1 ? matches[0][0] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
export function mergeConfigTrees(target, source, options = {}) {
|
|
38
81
|
const { cloneTarget = false, skipEmpty = true } = options;
|
|
39
82
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Route sharding for `cms-ssg-build` — the opt-in durable memory bound for the
|
|
2
|
+
// SSG pre-render. vite-ssg renders every route in one Node process and leaves a
|
|
3
|
+
// fixed retained cost per route, so peak heap scales with total routes;
|
|
4
|
+
// `cms-ssg-build` runs the render in N separate processes, each handling a slice
|
|
5
|
+
// of the routes, so peak heap is bounded by slice size instead. This module is
|
|
6
|
+
// the pure partition function so the slicing rules are unit-testable without a
|
|
7
|
+
// build. See SSG-MEMORY-PLAN.md.
|
|
8
|
+
|
|
9
|
+
// Routes every shard renders regardless of its slice. `/404` must be present in
|
|
10
|
+
// each shard's output dir so that shard's `onFinished` can copy 404/index.html
|
|
11
|
+
// → 404.html; `/admin` is a cheap vestigial placeholder kept alongside it.
|
|
12
|
+
export const ALWAYS_SHARDED_ROUTES = ['/404', '/admin'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return shard `k` of `N` from the full SSG route list.
|
|
16
|
+
*
|
|
17
|
+
* `shardSpec` is a `"k/N"` string (e.g. `"2/5"`). Shard k = the always-present
|
|
18
|
+
* routes + every Nth remaining route offset by k, so the N shards partition the
|
|
19
|
+
* non-always routes exactly once each (no route rendered twice, none dropped).
|
|
20
|
+
*
|
|
21
|
+
* Returns the full list unchanged when `shardSpec` is absent or malformed —
|
|
22
|
+
* this is the normal `vite-ssg build` path and must stay byte-identical.
|
|
23
|
+
*/
|
|
24
|
+
export function applyRouteShard(allRoutes, shardSpec) {
|
|
25
|
+
if (!Array.isArray(allRoutes)) return allRoutes;
|
|
26
|
+
const match = typeof shardSpec === 'string' ? shardSpec.trim().match(/^(\d+)\/(\d+)$/) : null;
|
|
27
|
+
if (!match) return allRoutes;
|
|
28
|
+
|
|
29
|
+
const k = Number(match[1]);
|
|
30
|
+
const n = Number(match[2]);
|
|
31
|
+
if (!(n > 0) || k < 0 || k >= n) return allRoutes;
|
|
32
|
+
|
|
33
|
+
const always = new Set(ALWAYS_SHARDED_ROUTES);
|
|
34
|
+
const alwaysRoutes = allRoutes.filter((route) => always.has(route));
|
|
35
|
+
const shardRoutes = allRoutes
|
|
36
|
+
.filter((route) => !always.has(route))
|
|
37
|
+
.filter((_, i) => i % n === k);
|
|
38
|
+
|
|
39
|
+
return Array.from(new Set([...alwaysRoutes, ...shardRoutes]));
|
|
40
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"build": "vite build",
|
|
9
|
-
"build:ssg": "vite-ssg build",
|
|
9
|
+
"build:ssg": "NODE_OPTIONS=--max-old-space-size=4096 vite-ssg build",
|
|
10
10
|
"preview": "vite preview",
|
|
11
11
|
"generate:public-assets": "cms-generate-public-assets --site-dir ./site"
|
|
12
12
|
},
|
package/vite-plugin.js
CHANGED
|
@@ -17,6 +17,7 @@ import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
|
|
|
17
17
|
import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
|
|
18
18
|
import { buildWebAppManifest } from './src/utils/webAppManifest.js';
|
|
19
19
|
import { sha256Hex } from './src/utils/sha256.js';
|
|
20
|
+
import { applyRouteShard } from './src/utils/ssgRoutes.js';
|
|
20
21
|
import {
|
|
21
22
|
computeVariantCacheDir,
|
|
22
23
|
resolveImageVariantConfig,
|
|
@@ -539,6 +540,14 @@ export default function cmsPlugin(options = {}) {
|
|
|
539
540
|
publicDir: path.join(siteRoot, 'public'),
|
|
540
541
|
build: {
|
|
541
542
|
modulePreload: false,
|
|
543
|
+
// `cms-ssg-build` renders each route shard as a separate build into
|
|
544
|
+
// its own outDir (e.g. `.cms-ssg/dist-0`), then merges them into
|
|
545
|
+
// `dist/`. Honor the override for the CLIENT build only — vite-ssg's
|
|
546
|
+
// SSR build sets its own temp outDir and then imports the server
|
|
547
|
+
// bundle back from there, so overriding it would break that import.
|
|
548
|
+
...(process.env.CMS_SSG_OUTDIR && !(env?.isSsrBuild || userConfig?.build?.ssr)
|
|
549
|
+
? { outDir: process.env.CMS_SSG_OUTDIR }
|
|
550
|
+
: {}),
|
|
542
551
|
rollupOptions: {
|
|
543
552
|
input: tempIndexPath,
|
|
544
553
|
output: {
|
|
@@ -576,13 +585,21 @@ export default function cmsPlugin(options = {}) {
|
|
|
576
585
|
},
|
|
577
586
|
ssgOptions: {
|
|
578
587
|
dirStyle: 'nested',
|
|
588
|
+
// Cap vite-ssg's render pool (default is 20). The SSG pre-render
|
|
589
|
+
// leaves a fixed retained cost per route (one jsdom document survives
|
|
590
|
+
// each page inside vite-ssg's loop), so peak heap is dominated by that
|
|
591
|
+
// accumulation rather than the concurrent batch — but a smaller pool
|
|
592
|
+
// still trims the transient working set layered on top and makes peak
|
|
593
|
+
// more predictable on large multi-locale sites. This is a mitigation,
|
|
594
|
+
// not the bound; see SSG-MEMORY-PLAN.md for the durable fix.
|
|
595
|
+
concurrency: 8,
|
|
579
596
|
// After vite-ssg pre-renders every route, copy the rendered
|
|
580
597
|
// dist/404/index.html to dist/404.html. AWS Amplify (and most
|
|
581
598
|
// static hosts) auto-serve a top-level 404.html for any
|
|
582
599
|
// unmatched URL with HTTP status 404, so this gives sites a
|
|
583
600
|
// proper not-found response without per-site config.
|
|
584
601
|
onFinished() {
|
|
585
|
-
const distDir = path.join(siteRoot, 'dist');
|
|
602
|
+
const distDir = path.join(siteRoot, process.env.CMS_SSG_OUTDIR || 'dist');
|
|
586
603
|
const nestedNotFound = path.join(distDir, '404', 'index.html');
|
|
587
604
|
const flatNotFound = path.join(distDir, '404.html');
|
|
588
605
|
if (fs.existsSync(nestedNotFound)) {
|
|
@@ -622,7 +639,15 @@ export default function cmsPlugin(options = {}) {
|
|
|
622
639
|
...Array.from(staticRoutes),
|
|
623
640
|
...Array.from(localizedRoutes),
|
|
624
641
|
];
|
|
625
|
-
|
|
642
|
+
const allRoutes = Array.from(new Set(candidates));
|
|
643
|
+
|
|
644
|
+
// Opt-in durable memory bound: when `cms-ssg-build` sets
|
|
645
|
+
// CMS_SSG_SHARD=k/N in the environment, render only this shard's
|
|
646
|
+
// slice of routes so peak SSG heap is bounded by slice size, not
|
|
647
|
+
// total route count. Unset (the normal `vite-ssg build` path) →
|
|
648
|
+
// the full list, byte-identical to before. (applyRouteShard keeps
|
|
649
|
+
// /404 + /admin in every shard so each can emit its own 404.html.)
|
|
650
|
+
return applyRouteShard(allRoutes, process.env.CMS_SSG_SHARD);
|
|
626
651
|
},
|
|
627
652
|
},
|
|
628
653
|
};
|