@koehler8/cms 1.0.0-beta.36 → 1.0.0-beta.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.36",
3
+ "version": "1.0.0-beta.38",
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",
@@ -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
+ }
@@ -26,6 +26,7 @@ import fs from 'node:fs';
26
26
  import fsp from 'node:fs/promises';
27
27
  import path from 'node:path';
28
28
  import os from 'node:os';
29
+ import { findEmptyRenders } from './findEmptyRenders.mjs';
29
30
 
30
31
  const CWD = process.cwd();
31
32
  const TAG = '[cms-ssg-build]';
@@ -157,6 +158,14 @@ async function main() {
157
158
  console.log(`${TAG} ~${estRoutes} routes ≤ shard size ${opts.shardSize}: running a single vite-ssg build (no sharding needed).`);
158
159
  const r = await runViteSsg({ heap: opts.heap, passthrough: opts.passthrough });
159
160
  process.stdout.write(r.out);
161
+ if (r.code === 0) {
162
+ const empties = await findEmptyRenders(path.join(CWD, 'dist'));
163
+ if (empties.length) {
164
+ 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:`);
165
+ for (const f of empties) console.error(`${TAG} - ${f}`);
166
+ process.exit(1);
167
+ }
168
+ }
160
169
  process.exit(r.code);
161
170
  }
162
171
 
@@ -175,7 +184,20 @@ async function main() {
175
184
  const started = Date.now();
176
185
  process.stdout.write(`${TAG} shard ${s.k + 1}/${N} rendering…\n`);
177
186
  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`);
187
+ let empties = [];
188
+ if (r.code === 0) {
189
+ empties = await findEmptyRenders(path.join(CWD, s.outDir));
190
+ }
191
+ const ok = r.code === 0 && empties.length === 0;
192
+ process.stdout.write(`${TAG} shard ${s.k + 1}/${N} ${ok ? 'ok' : 'FAILED'} (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
193
+ if (empties.length) {
194
+ return {
195
+ ...r,
196
+ code: 1,
197
+ 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`,
198
+ k: s.k,
199
+ };
200
+ }
179
201
  return { ...r, k: s.k };
180
202
  });
181
203
 
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;