@koehler8/cms 1.0.0-beta.9 → 1.0.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +1491 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +184 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +126 -28
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -0,0 +1,242 @@
1
+ // Shared core for the cms-create-{theme,extension} CLIs. Reads a template
2
+ // directory, substitutes __SLUG__ / __DISPLAY_NAME__ placeholders, writes
3
+ // the result to <cwd>/{themes,extensions}/<slug>/.
4
+ //
5
+ // Intentionally side-effect-free except for the file writes — wiring into
6
+ // the consuming site's package.json / vite.config.js is left to the user
7
+ // via the printed next-steps message. Modifying those files automatically
8
+ // would risk corrupting custom formatting and is harder to reverse than a
9
+ // copy/paste from the terminal.
10
+
11
+ import { mkdir, readdir, readFile, writeFile, access } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ // Don't use `new URL('..', import.meta.url)` — vitest's happy-dom env can
16
+ // patch the URL constructor in ways that make fileURLToPath reject the
17
+ // result. Compute the path directly.
18
+ function defaultFrameworkRoot() {
19
+ try {
20
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ const SLUG_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
26
+
27
+ function toDisplayName(slug) {
28
+ return slug
29
+ .split('-')
30
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
31
+ .join(' ');
32
+ }
33
+
34
+ function parseArgs(argv) {
35
+ const args = { slug: null, out: null, force: false, help: false };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === '--help' || a === '-h') args.help = true;
39
+ else if (a === '--force' || a === '-f') args.force = true;
40
+ else if (a === '--out') args.out = argv[++i];
41
+ else if (a.startsWith('--out=')) args.out = a.slice('--out='.length);
42
+ else if (!args.slug && !a.startsWith('-')) args.slug = a;
43
+ }
44
+ return args;
45
+ }
46
+
47
+ async function pathExists(p) {
48
+ try {
49
+ await access(p);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // Files in the template tree that should never be copied into the
57
+ // scaffolded output (OS junk, editor artifacts). Dotfiles in general
58
+ // ARE copied — site templates need .nvmrc, .gitignore, .env.example.
59
+ const TEMPLATE_SKIP = new Set(['.DS_Store', 'Thumbs.db', '.git']);
60
+
61
+ async function collectTemplateFiles(dir) {
62
+ const out = [];
63
+ const entries = await readdir(dir, { withFileTypes: true });
64
+ for (const entry of entries) {
65
+ if (TEMPLATE_SKIP.has(entry.name)) continue;
66
+ const full = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ out.push(...(await collectTemplateFiles(full)));
69
+ } else {
70
+ out.push(full);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function applySubstitutions(content, subs) {
77
+ let result = content;
78
+ for (const [from, to] of Object.entries(subs)) {
79
+ result = result.split(from).join(to);
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Run the scaffold for one of {theme, extension}.
86
+ *
87
+ * @param {object} opts
88
+ * @param {'theme'|'extension'} opts.kind
89
+ * @param {string[]} opts.argv argv slice excluding `node` + script path
90
+ * @param {string} opts.cwd working directory (defaults to process.cwd())
91
+ * @returns {Promise<{ok: boolean, slug?: string, targetDir?: string, message?: string}>}
92
+ */
93
+ export async function runScaffold({ kind, argv, cwd = process.cwd(), frameworkRoot } = {}) {
94
+ if (kind !== 'theme' && kind !== 'extension' && kind !== 'site') {
95
+ return { ok: false, message: `unknown kind: ${kind}` };
96
+ }
97
+ const root = frameworkRoot || defaultFrameworkRoot();
98
+ if (!root) {
99
+ return {
100
+ ok: false,
101
+ message: 'error: could not resolve @koehler8/cms framework root; pass `frameworkRoot` explicitly',
102
+ };
103
+ }
104
+ const args = parseArgs(argv);
105
+
106
+ if (args.help) {
107
+ return { ok: true, message: usage(kind) };
108
+ }
109
+ if (!args.slug) {
110
+ return { ok: false, message: `error: missing <slug>\n${usage(kind)}` };
111
+ }
112
+ if (!SLUG_PATTERN.test(args.slug)) {
113
+ return {
114
+ ok: false,
115
+ message: `error: slug "${args.slug}" must be lowercase kebab-case (e.g. "coastal", "lush-canopy")`,
116
+ };
117
+ }
118
+
119
+ const slug = args.slug;
120
+ const displayName = toDisplayName(slug);
121
+ const subs = { __SLUG__: slug, __DISPLAY_NAME__: displayName };
122
+ const templateDir = path.join(root, 'templates', 'scaffolds', kind);
123
+ // Default target dir: themes/<slug>/ for themes, extensions/<slug>/ for
124
+ // extensions, site-<slug>/ (sibling of cwd) for sites — matches the
125
+ // conventional koehler8/site-<slug> repo naming.
126
+ let defaultTarget;
127
+ if (kind === 'site') {
128
+ defaultTarget = path.join(cwd, `site-${slug}`);
129
+ } else if (kind === 'theme') {
130
+ defaultTarget = path.join(cwd, 'themes', slug);
131
+ } else {
132
+ defaultTarget = path.join(cwd, 'extensions', slug);
133
+ }
134
+ const targetDir = args.out ? path.resolve(cwd, args.out) : defaultTarget;
135
+
136
+ if (!(await pathExists(templateDir))) {
137
+ return {
138
+ ok: false,
139
+ message: `error: scaffold templates missing at ${templateDir}. Reinstall @koehler8/cms?`,
140
+ };
141
+ }
142
+ if ((await pathExists(targetDir)) && !args.force) {
143
+ return {
144
+ ok: false,
145
+ message: `error: target directory already exists: ${targetDir}\n(use --force to overwrite)`,
146
+ };
147
+ }
148
+
149
+ const files = await collectTemplateFiles(templateDir);
150
+ await mkdir(targetDir, { recursive: true });
151
+ for (const file of files) {
152
+ const rel = path.relative(templateDir, file);
153
+ const outPath = path.join(targetDir, rel);
154
+ await mkdir(path.dirname(outPath), { recursive: true });
155
+ const raw = await readFile(file, 'utf8');
156
+ await writeFile(outPath, applySubstitutions(raw, subs));
157
+ }
158
+
159
+ const targetRel = path.relative(cwd, targetDir) || targetDir;
160
+ return {
161
+ ok: true,
162
+ slug,
163
+ targetDir,
164
+ message: `${successHeader(kind, slug, targetRel)}\n${nextSteps(kind, slug, targetRel)}`,
165
+ };
166
+ }
167
+
168
+ function successHeader(kind, slug, targetRel) {
169
+ return `✓ Scaffolded ${kind} "${slug}" → ${targetRel}/`;
170
+ }
171
+
172
+ function nextSteps(kind, slug, targetRel) {
173
+ if (kind === 'theme') {
174
+ return [
175
+ '',
176
+ 'Next steps:',
177
+ ' 1. Add to your site\'s package.json dependencies:',
178
+ ` "cms-theme-${slug}": "file:./${targetRel}"`,
179
+ ' 2. Run `npm install` to symlink it into node_modules.',
180
+ ' 3. Add to vite.config.js\'s themes array:',
181
+ ` themes: ['cms-theme-${slug}'],`,
182
+ ' 4. Activate it in site/content/{baseLocale}/site.json:',
183
+ ` "theme": "${slug}"`,
184
+ ` 5. Edit ${targetRel}/theme.config.js to define your palette.`,
185
+ '',
186
+ ].join('\n');
187
+ }
188
+ if (kind === 'extension') {
189
+ return [
190
+ '',
191
+ 'Next steps:',
192
+ ' 1. Add to your site\'s package.json dependencies:',
193
+ ` "cms-ext-${slug}": "file:./${targetRel}"`,
194
+ ' 2. Run `npm install` to symlink it into node_modules.',
195
+ ' 3. Add to vite.config.js\'s extensions array:',
196
+ ` extensions: ['cms-ext-${slug}'],`,
197
+ ` 4. Add components to ${targetRel}/components/ as needed (see ${targetRel}/README.md).`,
198
+ ` Or, for one-off site-only components, prefer site/components/ instead.`,
199
+ '',
200
+ ].join('\n');
201
+ }
202
+ // site
203
+ return [
204
+ '',
205
+ 'Next steps:',
206
+ ` 1. cd ${targetRel}`,
207
+ ' 2. nvm use && npm install',
208
+ ' 3. npm run dev # Vite dev server on http://localhost:5173',
209
+ ' 4. Read CLAUDE.md # navigation guide for "where things go"',
210
+ '',
211
+ 'Then start customizing:',
212
+ ` - Edit site/content/en/site.json (title, description, theme key)`,
213
+ ` - Edit site/content/en/pages/home.json (hero copy, components)`,
214
+ ` - Add a theme: npx cms-create-theme <slug>`,
215
+ ` - Add a local extension: npx cms-create-extension <slug>`,
216
+ ` - Add a one-off Vue component: drop a file in site/components/`,
217
+ '',
218
+ ].join('\n');
219
+ }
220
+
221
+ function usage(kind) {
222
+ const cmds = { theme: 'cms-create-theme', extension: 'cms-create-extension', site: 'cms-create-site' };
223
+ const cmd = cmds[kind];
224
+ const defaultDir = {
225
+ theme: './themes/<slug>/',
226
+ extension: './extensions/<slug>/',
227
+ site: './site-<slug>/',
228
+ }[kind];
229
+ return `
230
+ Usage: npx ${cmd} <slug> [--out <directory>] [--force]
231
+
232
+ Scaffolds a new @koehler8/cms ${kind} into the current directory.
233
+
234
+ Arguments:
235
+ <slug> Lowercase kebab-case slug (e.g. "coastal", "lush-canopy").
236
+
237
+ Options:
238
+ --out <dir> Where to create the ${kind}. Defaults to ${defaultDir}.
239
+ --force Overwrite an existing target directory.
240
+ -h, --help Show this message.
241
+ `;
242
+ }
@@ -0,0 +1,42 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ // Detect a page whose root container never mounted any real markup — just
5
+ // Vue's SSR placeholder comment for "nothing rendered here". This is the
6
+ // signature of a rare, silent failure in the onServerPrefetch data-fetch
7
+ // path: @vue/server-renderer's renderComponentVNode gates a component's
8
+ // render on `Promise.all(prefetches).catch(NOOP)` (see server-renderer.cjs.js
9
+ // in any consuming site's node_modules) — if that promise ever rejects, the
10
+ // renderer swallows the error entirely and renders anyway, using whatever
11
+ // default/empty state the component held. No warning, no non-zero exit —
12
+ // just well-formed, blank HTML on disk. A real page from this framework is
13
+ // never legitimately empty (Home.vue always renders at least the skip-link +
14
+ // <main>, DraftGate renders a dialog, 404 renders NotFound), so any match
15
+ // here is unconditionally a bug. Kept in its own module (no side effects on
16
+ // import) so it's unit-testable without tripping index.mjs's top-level
17
+ // main() invocation — see bin/cms-ssg-build.js.
18
+ const EMPTY_ROOT_RE = /<div id="[^"]+"[^>]*>\s*<!---->\s*<\/div>/;
19
+
20
+ export async function findEmptyRenders(dir) {
21
+ const empties = [];
22
+ let entries;
23
+ try {
24
+ entries = await fsp.readdir(dir, { recursive: true });
25
+ } catch {
26
+ return empties;
27
+ }
28
+ for (const entry of entries) {
29
+ if (!entry.endsWith('index.html')) continue;
30
+ const full = path.join(dir, entry);
31
+ let html;
32
+ try {
33
+ html = await fsp.readFile(full, 'utf8');
34
+ } catch {
35
+ continue;
36
+ }
37
+ if (EMPTY_ROOT_RE.test(html)) {
38
+ empties.push(full);
39
+ }
40
+ }
41
+ return empties;
42
+ }
@@ -0,0 +1,253 @@
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
+ import { findEmptyRenders } from './findEmptyRenders.mjs';
30
+
31
+ const CWD = process.cwd();
32
+ const TAG = '[cms-ssg-build]';
33
+
34
+ function parseArgs(argv) {
35
+ const opts = {
36
+ shardSize: 150, // routes per shard
37
+ concurrency: 1, // sequential by default (lowest peak memory + no shared-write race)
38
+ heap: 2048, // per-shard --max-old-space-size (MB); a slice fits comfortably
39
+ siteDir: './site',
40
+ passthrough: [], // forwarded verbatim to `vite-ssg build`
41
+ };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === '--shard-size') opts.shardSize = Number(argv[++i]) || opts.shardSize;
45
+ else if (a === '--concurrency') opts.concurrency = Math.max(1, Number(argv[++i]) || 1);
46
+ else if (a === '--heap') opts.heap = Number(argv[++i]) || opts.heap;
47
+ else if (a === '--site-dir') opts.siteDir = argv[++i] || opts.siteDir;
48
+ else opts.passthrough.push(a);
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ // Estimate the route count the same way the Vite plugin discovers routes
54
+ // (base-locale pages × content locales, + /404). Only used to choose
55
+ // the shard count N; the framework's CMS_SSG_SHARD filter partitions whatever
56
+ // routes actually exist, so an approximate N still yields a correct, complete
57
+ // dist/ — it just tunes slice sizes.
58
+ function estimateRouteCount(siteDir) {
59
+ const contentDir = path.join(siteDir, 'content');
60
+ let baseLocale = 'en';
61
+ try {
62
+ baseLocale = JSON.parse(fs.readFileSync(path.join(contentDir, 'content.config.json'), 'utf8')).baseLocale || 'en';
63
+ } catch { /* default en */ }
64
+
65
+ let pages = 1;
66
+ try {
67
+ pages = fs.readdirSync(path.join(contentDir, baseLocale, 'pages')).filter((f) => f.endsWith('.json')).length || 1;
68
+ } catch { /* single page */ }
69
+
70
+ let locales = 1;
71
+ try {
72
+ locales = fs.readdirSync(contentDir, { withFileTypes: true })
73
+ .filter((d) => d.isDirectory())
74
+ .filter((d) => {
75
+ const dir = path.join(contentDir, d.name);
76
+ return fs.existsSync(path.join(dir, 'site.json'))
77
+ || fs.existsSync(path.join(dir, 'shared.json'))
78
+ || fs.existsSync(path.join(dir, 'pages'));
79
+ }).length || 1;
80
+ } catch { /* single locale */ }
81
+
82
+ return pages * locales + 1; // + /404
83
+ }
84
+
85
+ // Emitted by usePageConfig's SSR catch (see src/composables/usePageConfig.js).
86
+ // Its presence in a child's output means at least one route pre-rendered with
87
+ // no page content — a failure even when vite-ssg exits 0.
88
+ const CONFIG_FAIL_MARKER = 'PAGE CONFIG LOAD FAILED';
89
+
90
+ function resolveViteSsgBin() {
91
+ const local = path.join(CWD, 'node_modules', '.bin', 'vite-ssg');
92
+ return fs.existsSync(local) ? local : 'vite-ssg';
93
+ }
94
+
95
+ function runViteSsg({ shard, outDir, heap, passthrough, stream = false }) {
96
+ return new Promise((resolve) => {
97
+ const env = { ...process.env };
98
+ if (shard) env.CMS_SSG_SHARD = shard;
99
+ if (outDir) env.CMS_SSG_OUTDIR = outDir;
100
+ // A shard renders a bounded slice, so a modest heap suffices — this is what
101
+ // lets sites drop the 8 GB stop-loss flag. Strip any inherited
102
+ // --max-old-space-size and set our own.
103
+ const base = (process.env.NODE_OPTIONS || '').replace(/--max-old-space-size=\d+/g, '').trim();
104
+ env.NODE_OPTIONS = `${base} --max-old-space-size=${heap}`.trim();
105
+
106
+ const child = spawn(resolveViteSsgBin(), ['build', ...passthrough], { cwd: CWD, env });
107
+ // Output is always accumulated (the failure tail and the config-failure
108
+ // marker scan need it); `stream` additionally forwards it live so a
109
+ // multi-minute single build isn't a silent terminal.
110
+ let out = '';
111
+ child.stdout.on('data', (d) => { out += d; if (stream) process.stdout.write(d); });
112
+ child.stderr.on('data', (d) => { out += d; if (stream) process.stderr.write(d); });
113
+ child.on('close', (code) => resolve({ code, out }));
114
+ child.on('error', (err) => resolve({ code: 1, out: String(err && err.stack ? err.stack : err) }));
115
+ });
116
+ }
117
+
118
+ async function pool(items, concurrency, worker) {
119
+ const results = new Array(items.length);
120
+ let next = 0;
121
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
122
+ while (next < items.length) {
123
+ const i = next++;
124
+ results[i] = await worker(items[i], i);
125
+ }
126
+ });
127
+ await Promise.all(runners);
128
+ return results;
129
+ }
130
+
131
+ // Union-merge every shard's output into dist/. Shared files (assets/, sitemap,
132
+ // robots, manifest, favicon, 404.html) are byte-identical across shards, and
133
+ // per-route HTML files are disjoint — so a plain overwrite-merge is a clean
134
+ // union. The ONE exception is dist/index.html: only the shard that rendered `/`
135
+ // produced the real home page; every other shard's client build left a
136
+ // hydration shell there. So after merging, restore the rendered home
137
+ // explicitly (identified by the data-server-rendered marker).
138
+ async function mergeShards(shardDirs, finalDist) {
139
+ await fsp.rm(finalDist, { recursive: true, force: true });
140
+ await fsp.mkdir(finalDist, { recursive: true });
141
+ for (const dir of shardDirs) {
142
+ await fsp.cp(dir, finalDist, { recursive: true, force: true });
143
+ }
144
+
145
+ let renderedHome = null;
146
+ for (const dir of shardDirs) {
147
+ try {
148
+ const html = await fsp.readFile(path.join(dir, 'index.html'), 'utf8');
149
+ if (html.includes('data-server-rendered="true"')) { renderedHome = html; break; }
150
+ } catch { /* shard has no index.html */ }
151
+ }
152
+ if (renderedHome != null) {
153
+ await fsp.writeFile(path.join(finalDist, 'index.html'), renderedHome, 'utf8');
154
+ return true;
155
+ }
156
+ // A client-shell homepage is blank for crawlers and until JS boots — that
157
+ // is a failed build, not a warning (the empty-root detector can't catch a
158
+ // shell: it has no SSR comment node to match).
159
+ console.error(`${TAG} no shard produced a server-rendered home page; dist/index.html would be a client shell.`);
160
+ return false;
161
+ }
162
+
163
+ async function main() {
164
+ const opts = parseArgs(process.argv.slice(2));
165
+ const siteDir = path.resolve(CWD, opts.siteDir);
166
+ const estRoutes = estimateRouteCount(siteDir);
167
+ const N = Math.max(1, Math.ceil(estRoutes / opts.shardSize));
168
+
169
+ if (N <= 1) {
170
+ console.log(`${TAG} ~${estRoutes} routes ≤ shard size ${opts.shardSize}: running a single vite-ssg build (no sharding needed).`);
171
+ const r = await runViteSsg({ heap: opts.heap, passthrough: opts.passthrough, stream: true });
172
+ if (r.code === 0) {
173
+ if (r.out.includes(CONFIG_FAIL_MARKER)) {
174
+ console.error(`${TAG} build exited 0 but at least one route failed to load its page config during pre-render (see "${CONFIG_FAIL_MARKER}" above). Failing the build.`);
175
+ process.exit(1);
176
+ }
177
+ const empties = await findEmptyRenders(path.join(CWD, 'dist'));
178
+ if (empties.length) {
179
+ console.error(`${TAG} build succeeded but ${empties.length} page(s) rendered with an empty body — a known intermittent SSR race (see findEmptyRenders in this file). Re-run the build:`);
180
+ for (const f of empties) console.error(`${TAG} - ${f}`);
181
+ process.exit(1);
182
+ }
183
+ }
184
+ process.exit(r.code);
185
+ }
186
+
187
+ 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.`);
188
+ if (opts.concurrency > 1) {
189
+ console.warn(`${TAG} warning: --concurrency > 1 is experimental — parallel shards race on the plugin's shared site-root writes AND on the shared image-variant cache (node_modules/.cache/@koehler8/cms/image-variants: concurrent evictions, duplicate renders, last-writer-wins manifest), and raise peak memory. Sequential is recommended.`);
190
+ }
191
+
192
+ const tmp = path.join(CWD, '.cms-ssg');
193
+ await fsp.rm(tmp, { recursive: true, force: true });
194
+ await fsp.mkdir(tmp, { recursive: true });
195
+
196
+ const shards = Array.from({ length: N }, (_, k) => ({ k, outDir: path.join('.cms-ssg', `dist-${k}`) }));
197
+ const t0 = Date.now();
198
+ const results = await pool(shards, opts.concurrency, async (s) => {
199
+ const started = Date.now();
200
+ process.stdout.write(`${TAG} shard ${s.k + 1}/${N} rendering…\n`);
201
+ const r = await runViteSsg({ shard: `${s.k}/${N}`, outDir: s.outDir, heap: opts.heap, passthrough: opts.passthrough });
202
+ let empties = [];
203
+ const configFailed = r.code === 0 && r.out.includes(CONFIG_FAIL_MARKER);
204
+ if (r.code === 0 && !configFailed) {
205
+ empties = await findEmptyRenders(path.join(CWD, s.outDir));
206
+ }
207
+ const ok = r.code === 0 && !configFailed && empties.length === 0;
208
+ process.stdout.write(`${TAG} shard ${s.k + 1}/${N} ${ok ? 'ok' : 'FAILED'} (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
209
+ if (configFailed) {
210
+ return {
211
+ ...r,
212
+ code: 1,
213
+ out: `${r.out}\n${TAG} at least one route failed to load its page config during pre-render ("${CONFIG_FAIL_MARKER}").\n`,
214
+ k: s.k,
215
+ };
216
+ }
217
+ if (empties.length) {
218
+ return {
219
+ ...r,
220
+ code: 1,
221
+ out: `${r.out}\n${TAG} ${empties.length} page(s) rendered with an empty body — a known intermittent SSR race (see findEmptyRenders in this file):\n${empties.map((f) => ` - ${f}`).join('\n')}\n`,
222
+ k: s.k,
223
+ };
224
+ }
225
+ return { ...r, k: s.k };
226
+ });
227
+
228
+ const failed = results.filter((r) => r.code !== 0);
229
+ if (failed.length) {
230
+ console.error(`${TAG} ${failed.length}/${N} shard(s) failed:`);
231
+ for (const f of failed) {
232
+ console.error(`${TAG} ---- shard ${f.k} output (tail) ----`);
233
+ console.error(f.out.slice(-4000));
234
+ }
235
+ process.exit(1);
236
+ }
237
+
238
+ const homeOk = await mergeShards(shards.map((s) => path.join(CWD, s.outDir)), path.join(CWD, 'dist'));
239
+ if (!homeOk) {
240
+ process.exit(1);
241
+ }
242
+ // Belt-and-suspenders: re-check the merged output as a whole.
243
+ const mergedEmpties = await findEmptyRenders(path.join(CWD, 'dist'));
244
+ if (mergedEmpties.length) {
245
+ console.error(`${TAG} merged dist/ contains ${mergedEmpties.length} empty-bodied page(s):`);
246
+ for (const f of mergedEmpties) console.error(`${TAG} - ${f}`);
247
+ process.exit(1);
248
+ }
249
+ await fsp.rm(tmp, { recursive: true, force: true });
250
+ console.log(`${TAG} merged ${N} shards → dist/ in ${((Date.now() - t0) / 1000).toFixed(1)}s. Done.`);
251
+ }
252
+
253
+ main().catch((err) => { console.error(TAG, err); process.exit(1); });