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

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.34",
3
+ "version": "1.0.0-beta.35",
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",
@@ -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
 
@@ -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
@@ -576,6 +576,14 @@ export default function cmsPlugin(options = {}) {
576
576
  },
577
577
  ssgOptions: {
578
578
  dirStyle: 'nested',
579
+ // Cap vite-ssg's render pool (default is 20). The SSG pre-render
580
+ // leaves a fixed retained cost per route (one jsdom document survives
581
+ // each page inside vite-ssg's loop), so peak heap is dominated by that
582
+ // accumulation rather than the concurrent batch — but a smaller pool
583
+ // still trims the transient working set layered on top and makes peak
584
+ // more predictable on large multi-locale sites. This is a mitigation,
585
+ // not the bound; see SSG-MEMORY-PLAN.md for the durable fix.
586
+ concurrency: 8,
579
587
  // After vite-ssg pre-renders every route, copy the rendered
580
588
  // dist/404/index.html to dist/404.html. AWS Amplify (and most
581
589
  // static hosts) auto-serve a top-level 404.html for any