@koehler8/cms 1.0.0-beta.35 → 1.0.0-beta.37

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.35",
3
+ "version": "1.0.0-beta.37",
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); });
package/src/main.js CHANGED
@@ -135,6 +135,21 @@ export function createCmsApp() {
135
135
  };
136
136
 
137
137
  const extractCurrentRoute = () => {
138
+ // During SSG, vite-ssg passes the route being pre-rendered as a string
139
+ // on `ctx.routePath` and only pushes the router AFTER this setup fn runs
140
+ // — so `router.currentRoute` is still vue-router's START_LOCATION ('/')
141
+ // here, identically on every page. Resolve `routePath` through the router
142
+ // (a pure match, no navigation) to recover the real path + `params.locale`.
143
+ // Without this, `resolveCurrentPageId` sees '/' for every route, so the
144
+ // payload trim targets the wrong page — or, when '/' is ambiguous, none.
145
+ const routePath = ctx?.routePath;
146
+ if (typeof routePath === 'string' && routePath) {
147
+ try {
148
+ return router.resolve(routePath);
149
+ } catch {
150
+ return { path: routePath, params: {} };
151
+ }
152
+ }
138
153
  if (ctx?.route) {
139
154
  return ctx.route;
140
155
  }
@@ -70,8 +70,24 @@ export function resolvePageIdForRoute(config, routePath, localeParam) {
70
70
  (typeof p === 'string' ? p : '/').replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
71
71
  target = normalize(target);
72
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 : '/';
73
+
74
+ // The SSG pre-renders `/404` from a site's custom `pages/404.json` (mirrors
75
+ // usePageConfig.selectPage's `/404` → `404` mapping). That page is
76
+ // conventionally path-less, so it can't be matched by `path` below — map the
77
+ // route to it directly. It is also deliberately NOT treated as living at `/`
78
+ // (see the id === '404' guard in the matcher) so it never collides with
79
+ // `home` there — the collision that otherwise makes `/` ambiguous on any
80
+ // site shipping a custom 404 page, suppressing the home-page trim.
81
+ const notFound = pages['404'];
82
+ const notFoundPathless = notFound && !(typeof notFound.path === 'string' && notFound.path.trim());
83
+ if (target === '/404' && notFoundPathless) return '404';
84
+
85
+ const matches = Object.entries(pages).filter(([id, data]) => {
86
+ const hasPath = data && typeof data.path === 'string' && data.path.trim();
87
+ // A page with no explicit `path` conventionally lives at `/` (home). The
88
+ // SSG-only `404` page is the exception: it is served at `/404`, never `/`.
89
+ const pagePath = hasPath ? data.path : (id === '404' ? null : '/');
90
+ if (pagePath === null) return false;
75
91
  return normalize(pagePath) === target;
76
92
  });
77
93
  return matches.length === 1 ? matches[0][0] : null;
@@ -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
+ }
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: {
@@ -590,7 +599,7 @@ export default function cmsPlugin(options = {}) {
590
599
  // unmatched URL with HTTP status 404, so this gives sites a
591
600
  // proper not-found response without per-site config.
592
601
  onFinished() {
593
- const distDir = path.join(siteRoot, 'dist');
602
+ const distDir = path.join(siteRoot, process.env.CMS_SSG_OUTDIR || 'dist');
594
603
  const nestedNotFound = path.join(distDir, '404', 'index.html');
595
604
  const flatNotFound = path.join(distDir, '404.html');
596
605
  if (fs.existsSync(nestedNotFound)) {
@@ -630,7 +639,15 @@ export default function cmsPlugin(options = {}) {
630
639
  ...Array.from(staticRoutes),
631
640
  ...Array.from(localizedRoutes),
632
641
  ];
633
- 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);
634
651
  },
635
652
  },
636
653
  };