@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.
@@ -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.34",
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.33.5"
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.34",
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
- // Preserve the saved-locale auto-switch on base-locale routes: the server
223
- // has no localStorage, so it rendered the base locale. If a returning
224
- // visitor stored a different locale, reconcile AFTER the first paint
225
- // (soft = no blank) so that paint still matches the server but the saved
226
- // choice is honored.
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
- lastLocaleKey = null; // force the reconcile to reload with localStorage resolution
236
- syncPage({ soft: true });
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
  }
@@ -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
- return Array.from(new Set(candidates));
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
  };