@koehler8/cms 1.1.0 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Added
6
+
7
+ - **`initialStateTrims` — let a site prune the hydration payload it alone can
8
+ reason about.** `trimConfigToPage` reduces `config.pages` to the page being
9
+ prerendered, but it cannot touch `config.shared`: deciding which shared
10
+ content a given ROUTE needs requires the site's own notion of what that route
11
+ resolves to, which the framework has no access to.
12
+
13
+ That gap gets expensive for content-heavy sites. On a 767-page publication it
14
+ measured at 65.42 MB of `__INITIAL_STATE__` — **75.6% of all built HTML** —
15
+ because every page carried every item's body in every locale, while rendering
16
+ exactly one. With a hook registered: 11.01 MB (-83.2%), and gzipped HTML
17
+ 23.22 MB -> 7.23 MB (-68.9%). Worst-hit locale pages dropped ~60-73% of their
18
+ wire weight.
19
+
20
+ A site registers a synchronous function through the new plugin option; the
21
+ framework calls it once per prerendered route, after the page trim, and
22
+ stamps `sharedPartial: true` if the hook changed anything. `usePageConfig`
23
+ treats that marker exactly like `pagesPartial`, so whatever the hook prunes
24
+ is restored by the post-hydration reload before it can be navigated to.
25
+
26
+ Registration happens at module-eval time in the generated entry, which is the
27
+ only seam that works: extension setups run on the client only and are
28
+ unreachable during SSG. Hooks that throw, return a non-object, or drop
29
+ `site` / `shared` / `pages` wholesale degrade to a no-op — framework
30
+ components on pages the hook never reasons about read those directly, and
31
+ losing one is a hydration mismatch on every page's chrome.
32
+
33
+ `sharedPartial` gets its own reload clause rather than riding on
34
+ `pagesPartial`, because `trimConfigToPage` only sets that flag when a route
35
+ resolves unambiguously to a page id while the site trim fires either way.
36
+
37
+ Purely additive. With no `initialStateTrims` configured the generated entry
38
+ is **byte-identical** — asserted directly in `tests/vite-plugin.spec.js`,
39
+ because every site installs this version from npm before opting in.
40
+
41
+ - **`site.robots.extraSitemaps[]` — advertise a feed as a discovery surface.**
42
+ Google and Bing both accept an RSS/Atom feed as a sitemap format, so a
43
+ publication with a nightly-updating feed has a real second discovery channel
44
+ and no supported way to declare it. This appends one `Sitemap:` line per
45
+ entry after the primary, de-duplicated and trimmed.
46
+
47
+ It has to be a config option for exactly the reason `allowAiCrawlers` did:
48
+ `public/robots.txt` is regenerated from config on every build, so a site that
49
+ hand-edited an extra `Sitemap:` line had it silently reverted on the next
50
+ `build:ssg` — and silently, because nothing fails when a discovery hint
51
+ disappears. Skipped entirely when `site.draft === true`: a pre-launch site
52
+ emits `Disallow: /` and should not be advertising anything.
53
+
54
+ Purely additive. A site that does not set it produces a byte-identical
55
+ `robots.txt`.
56
+
57
+ ### Changed
58
+
59
+ - **The partial-embed config reload now waits for idle.** `pagesPartial`
60
+ triggered `syncPage({ soft: true })` on the critical path of every page load,
61
+ solely so a later in-SPA navigation could resolve other pages — on one site,
62
+ eagerly fetching a 157 KB per-locale chunk at 1.375 pages/session, for a
63
+ navigation most visitors never make.
64
+
65
+ The two reload triggers are not equivalent and are no longer treated the
66
+ same. A saved-locale switch is user-visible — the reader is looking at the
67
+ wrong language until it resolves — and stays eager. A partial embed is not:
68
+ the page has already rendered identically from the embed. That one moves to
69
+ `requestIdleCallback` (with a timeout fallback for Safari before 16.4).
70
+
71
+ `lastLocaleKey` is still cleared eagerly, so a navigation that beats the idle
72
+ callback triggers the full load itself through the route watcher; the idle
73
+ pass then finds the config current and re-applies it harmlessly.
74
+
3
75
  ## 1.1.0
4
76
 
5
77
  ### Fixed
package/README.md CHANGED
@@ -85,6 +85,64 @@ fails the build if any page comes out blank.)
85
85
  | `locales` | all 15 supported | Array of locale codes to enable |
86
86
  | `themes` | `[]` | Theme package names to register |
87
87
  | `extensions` | `[]` | Extension package names to register |
88
+ | `initialStateTrims` | `[]` | Module paths exporting a hydration-payload trim hook (see below) |
89
+
90
+ ## Hydration payload
91
+
92
+ Each prerendered page embeds the config it needs as `window.__INITIAL_STATE__`
93
+ so the first client render reproduces the prerendered DOM instead of blanking
94
+ while the async loader runs. On a content-heavy site that blob can dominate
95
+ page weight, so there are two levers.
96
+
97
+ ### `site.trimInitialState` (default: on)
98
+
99
+ Embeds only the current page's entry from `config.pages` rather than every
100
+ page's, and marks the result `pagesPartial`. After hydration the full config is
101
+ fetched once in the background — deferred to `requestIdleCallback`, since the
102
+ current page has already rendered identically and the full config only matters
103
+ once the reader navigates.
104
+
105
+ Set it to `false` to embed the whole config. That flag is the single, complete
106
+ rollback for everything in this section.
107
+
108
+ ### `initialStateTrims`
109
+
110
+ `trimConfigToPage` handles `config.pages`. It cannot touch `config.shared`,
111
+ because knowing which shared content a *route* needs requires the site's own
112
+ notion of what that route resolves to. A site closes that gap with a hook:
113
+
114
+ ```js
115
+ // vite.config.js
116
+ cms({ initialStateTrims: ['./site/components/payloadTrim.js'] })
117
+ ```
118
+
119
+ ```js
120
+ // site/components/payloadTrim.js
121
+ export default function payloadTrim(config, { routePath, locale, pageId }) {
122
+ if (!needsTrimming(config)) return config; // same reference === no-op
123
+ return { ...config, shared: prune(config.shared, routePath) };
124
+ }
125
+ ```
126
+
127
+ The hook must be **synchronous and pure** — it runs once per prerendered route,
128
+ which is thousands of times per build. Return the config unchanged (the same
129
+ reference) to no-op; anything that is not a plain object is ignored.
130
+
131
+ The framework stamps `sharedPartial: true` when a hook actually changes
132
+ something, so a hook cannot forget the marker and ship a payload that is
133
+ permanently short with nothing to repair it. That marker triggers the same
134
+ post-hydration reload as `pagesPartial`, so anything pruned is restored before
135
+ it can be navigated to.
136
+
137
+ **Never drop `site`, `shared` or `pages` wholesale.** Framework components on
138
+ pages your hook never reasons about read them directly (`useIntroGate`,
139
+ `FooterMinimal`, `ComingSoon`, `NotFound`, `Header`), and a first client render
140
+ without them is a hydration mismatch on every page's chrome. Returns that do
141
+ so are discarded, as are hooks that throw.
142
+
143
+ Measured on a 767-page publication whose per-locale item bodies lived in
144
+ `shared.json`: payload 65.42 MB → 11.01 MB (−83.2%), gzipped HTML 23.22 MB →
145
+ 7.23 MB (−68.9%).
88
146
 
89
147
  ## Site Configuration
90
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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",
@@ -13,6 +13,19 @@ function normalizePath(value) {
13
13
  return normalized || '/';
14
14
  }
15
15
 
16
+ // Run a callback when the browser is next idle, falling back to a timeout on
17
+ // engines without requestIdleCallback (Safari before 16.4). Used to move the
18
+ // background config reload off the critical path — see hydrateFromSyncCache.
19
+ function whenIdle(fn, timeout = 4000) {
20
+ if (typeof window === 'undefined') return () => {};
21
+ if (typeof window.requestIdleCallback === 'function') {
22
+ const handle = window.requestIdleCallback(fn, { timeout });
23
+ return () => window.cancelIdleCallback?.(handle);
24
+ }
25
+ const handle = setTimeout(fn, 1);
26
+ return () => clearTimeout(handle);
27
+ }
28
+
16
29
  function mergeWithSharedContent(sharedContent, pageSpecificContent) {
17
30
  const base = sharedContent && typeof sharedContent === 'object' ? sharedContent : {};
18
31
  const overrides = pageSpecificContent && typeof pageSpecificContent === 'object' ? pageSpecificContent : {};
@@ -37,6 +50,8 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
37
50
  let cachedConfig = null;
38
51
  let lastLocaleKey = null;
39
52
  let activeRequest = 0;
53
+ // Cancels a pending idle-scheduled background reload (see hydrateFromSyncCache).
54
+ let cancelIdleReload = () => {};
40
55
 
41
56
  function selectPage(config) {
42
57
  const pages = config?.pages || {};
@@ -241,16 +256,43 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
241
256
  // route rendered the base locale. If a returning visitor stored a
242
257
  // different locale, reconcile to it. (loadConfigData reads localStorage
243
258
  // when no explicit locale is passed, so the same reload resolves it.)
244
- let needsFullReload = Boolean(primed && primed.pagesPartial);
245
-
259
+ // `sharedPartial` is the site-trim marker (see utils/initialStateTrim.js).
260
+ // It needs the same reload and must be checked independently: a route whose
261
+ // page id is ambiguous gets no `pagesPartial` at all, so relying on that
262
+ // flag alone would leave a shared-trimmed payload permanently short with
263
+ // nothing to repair it.
264
+ let needsFullReload = Boolean(primed && (primed.pagesPartial || primed.sharedPartial));
265
+
266
+ // Only trigger (2) is user-visible: until it resolves the reader is looking
267
+ // at the WRONG LOCALE. Trigger (1) is not — the current page has already
268
+ // rendered identically from the partial embed, and the full config only
269
+ // matters once the reader navigates. So (2) stays eager and (1) waits for
270
+ // idle. That distinction is the whole point: on a site whose visitors read
271
+ // one page and leave, the partial-embed reload was fetching a large
272
+ // per-locale chunk on the critical path for a navigation most of them never
273
+ // make.
274
+ let reloadIsUrgent = false;
246
275
  if (localeForConfig === undefined) {
247
276
  const stored = (readStoredLocale() || '').trim().toLowerCase();
248
- if (stored) needsFullReload = true;
277
+ if (stored) {
278
+ needsFullReload = true;
279
+ reloadIsUrgent = true;
280
+ }
249
281
  }
250
282
 
251
283
  if (needsFullReload) {
252
- lastLocaleKey = null; // force syncPage to reload (localStorage-resolved for base routes)
253
- syncPage({ soft: true });
284
+ // Set eagerly, NOT inside the idle callback. syncPage() reloads only when
285
+ // `!cachedConfig || lastLocaleKey !== localeKey`, and hydrateFromSyncCache
286
+ // just set both from the partial embed. Clearing the key here means an
287
+ // in-SPA navigation that beats the idle callback triggers the full load
288
+ // itself through the route watcher; the idle pass then finds the config
289
+ // already current and re-applies it harmlessly.
290
+ lastLocaleKey = null;
291
+ if (reloadIsUrgent) {
292
+ syncPage({ soft: true });
293
+ } else {
294
+ cancelIdleReload = whenIdle(() => { syncPage({ soft: true }); });
295
+ }
254
296
  }
255
297
  return true;
256
298
  }
@@ -269,7 +311,14 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
269
311
 
270
312
  watch(
271
313
  () => [getPageId(), getPagePath(), getLocale()],
272
- () => { syncPage(); },
314
+ () => {
315
+ // A navigation supersedes any idle-scheduled reload: syncPage below does
316
+ // the full load itself, because hydrateFromSyncCache already cleared
317
+ // lastLocaleKey.
318
+ cancelIdleReload();
319
+ cancelIdleReload = () => {};
320
+ syncPage();
321
+ },
273
322
  );
274
323
 
275
324
  return {
package/src/main.js CHANGED
@@ -7,6 +7,7 @@ import { routes, resolveHistory, applyRouterGuards } from './router/index.js';
7
7
 
8
8
  import { shouldEnableAnalytics, scheduleAnalyticsLoad, configureAnalyticsConsentMode } from './utils/cookieConsent.js';
9
9
  import { loadConfigData, primeConfigSync, trimConfigToPage, resolvePageIdForRoute } from './utils/loadConfig.js';
10
+ import { applyInitialStateTrims } from './utils/initialStateTrim.js';
10
11
  import { persistAttributionFromLocation } from './utils/trackingContext.js';
11
12
  import { applyThemeVariables } from './themes/themeManager.js';
12
13
  import { setActiveThemeKey } from './utils/themeColors.js';
@@ -165,10 +166,10 @@ export function createCmsApp() {
165
166
  // into `site.trimInitialState`. The matching rules live in the pure,
166
167
  // unit-tested resolvePageIdForRoute (returns null on ambiguity → keep the
167
168
  // full config, a safe no-op).
168
- const resolveCurrentPageId = (config) => {
169
- const route = extractCurrentRoute();
170
- const rawPath = typeof route?.path === 'string' ? route.path : '';
171
- const localeParam = pickLocaleParam(route?.params?.locale);
169
+ const resolveCurrentPageId = (config, route) => {
170
+ const resolved = route === undefined ? extractCurrentRoute() : route;
171
+ const rawPath = typeof resolved?.path === 'string' ? resolved.path : '';
172
+ const localeParam = pickLocaleParam(resolved?.params?.locale);
172
173
  return resolvePageIdForRoute(config, rawPath, localeParam);
173
174
  };
174
175
 
@@ -216,10 +217,24 @@ export function createCmsApp() {
216
217
  // Safety valve: an ambiguous route→page match returns null and the
217
218
  // full config is embedded (a no-op, never a broken page).
218
219
  if (initialState.siteConfig?.site?.trimInitialState !== false) {
219
- const currentPageId = resolveCurrentPageId(initialState.siteConfig);
220
+ const currentRoute = extractCurrentRoute();
221
+ const currentPageId = resolveCurrentPageId(initialState.siteConfig, currentRoute);
220
222
  if (currentPageId) {
221
223
  initialState.siteConfig = trimConfigToPage(initialState.siteConfig, currentPageId);
222
224
  }
225
+ // Then let the site prune `shared` — the half trimConfigToPage
226
+ // cannot reason about, because only the site knows what a route
227
+ // resolves to. Stamps `sharedPartial: true` iff a hook actually
228
+ // changed something; usePageConfig treats that exactly like
229
+ // `pagesPartial` and reloads the full config after hydration.
230
+ // Gated on the same trimInitialState flag so that one setting stays
231
+ // a single, complete rollback for the whole payload optimisation.
232
+ initialState.siteConfig = applyInitialStateTrims(initialState.siteConfig, {
233
+ route: currentRoute || null,
234
+ routePath: typeof currentRoute?.path === 'string' ? currentRoute.path : '',
235
+ locale: pickLocaleParam(currentRoute?.params?.locale),
236
+ pageId: currentPageId,
237
+ });
223
238
  }
224
239
  } catch (error) {
225
240
  console.error('Failed to load site configuration during SSG build', error);
@@ -0,0 +1,94 @@
1
+ // Site-supplied trimming of the serialized hydration payload.
2
+ //
3
+ // `trimConfigToPage` (utils/loadConfig.js) prunes `config.pages` to the page
4
+ // being prerendered. It cannot touch `config.shared`, because deciding which
5
+ // parts of shared content a given ROUTE needs requires knowledge the framework
6
+ // does not have — a site's own notion of what a route resolves to.
7
+ //
8
+ // That gap is expensive for content-heavy sites. On one 737-page site, shared
9
+ // content accounted for 61% of ALL built HTML bytes: every page carried every
10
+ // item's body, in every locale, so a single story page shipped fifty times the
11
+ // prose it rendered.
12
+ //
13
+ // A site registers a function here; the framework calls it once per prerendered
14
+ // route, after the page trim, and stamps `sharedPartial: true` when the hook
15
+ // actually changed something. usePageConfig treats that marker exactly like
16
+ // `pagesPartial` — a background reload of the full config after hydration — so
17
+ // anything the hook prunes is restored before it can be navigated to.
18
+ //
19
+ // Contract for hook authors:
20
+ // * SYNCHRONOUS and pure. This runs once per route (hundreds to thousands of
21
+ // times per build, per shard); an async hook that does I/O multiplies build
22
+ // time by the route count.
23
+ // * Return the config UNCHANGED (same reference) to no-op. Anything that is
24
+ // not a plain object is ignored.
25
+ // * Never drop `site`, `shared` or `pages` wholesale. Framework components on
26
+ // pages your hook never reasons about read those directly (useIntroGate,
27
+ // FooterMinimal, ComingSoon, NotFound, Header), and a first client render
28
+ // without them is a hydration mismatch on every page's chrome.
29
+ //
30
+ // The framework stamps the marker rather than the site, so a hook cannot forget
31
+ // it and ship a payload that is permanently short with nothing to repair it.
32
+
33
+ const hooks = [];
34
+
35
+ /**
36
+ * Register a trim hook. Called at module-eval time from the generated entry
37
+ * (see vite-plugin.js `initialStateTrims`), which is what makes this reachable
38
+ * during SSG — extension setups run only on the client.
39
+ *
40
+ * @param {(config: object, context: {routePath: string, locale: string|undefined, pageId: string|null, route: object|null}) => object} fn
41
+ */
42
+ export function registerInitialStateTrim(fn) {
43
+ if (typeof fn === 'function') hooks.push(fn);
44
+ }
45
+
46
+ /** Test helper — drops every registered hook. */
47
+ export function clearInitialStateTrims() {
48
+ hooks.length = 0;
49
+ }
50
+
51
+ /** Test helper — how many hooks are registered. */
52
+ export function initialStateTrimCount() {
53
+ return hooks.length;
54
+ }
55
+
56
+ /**
57
+ * Run every registered hook in registration order, threading the config
58
+ * through. Returns the ORIGINAL reference when nothing changed, so the caller
59
+ * can distinguish "trimmed" from "no-op" without the hook having to say so.
60
+ */
61
+ export function applyInitialStateTrims(config, context) {
62
+ if (!hooks.length) return config;
63
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
64
+
65
+ let current = config;
66
+ let changed = false;
67
+
68
+ for (const fn of hooks) {
69
+ let next;
70
+ try {
71
+ next = fn(current, context);
72
+ } catch (error) {
73
+ // A payload optimisation failing is not a page failing. Deliberately NOT
74
+ // the '[cms-ssg] PAGE CONFIG LOAD FAILED' marker the build greps for.
75
+ console.warn('[@koehler8/cms] an initialStateTrim hook threw; skipping it', error);
76
+ continue;
77
+ }
78
+
79
+ if (!next || typeof next !== 'object' || Array.isArray(next)) continue; // ignored
80
+ if (next === current) continue; // explicit no-op
81
+
82
+ // Blast-radius guard: a hook must not be able to blank the config for a
83
+ // whole build. Losing any of these three is a hydration mismatch on chrome
84
+ // that appears on every page.
85
+ if (current.site && !next.site) continue;
86
+ if (current.shared && !next.shared) continue;
87
+ if (current.pages && !next.pages) continue;
88
+
89
+ current = next;
90
+ changed = true;
91
+ }
92
+
93
+ return changed ? { ...current, sharedPartial: true } : config;
94
+ }
@@ -32,6 +32,17 @@
32
32
  * pre-launch site shouldn't explicitly invite crawlers.
33
33
  *
34
34
  * If sitemapUrl is provided, appends a "Sitemap: <url>" line.
35
+ *
36
+ * site.robots.extraSitemaps (opt-in, default none): appends one additional
37
+ * "Sitemap:" line per entry, after the primary. Google and Bing both accept an
38
+ * RSS/Atom feed as a sitemap format, so a publication with a nightly-updating
39
+ * feed can advertise it as a discovery surface alongside sitemap.xml. This has
40
+ * to be a config option for the same reason allowAiCrawlers does: public/
41
+ * robots.txt is regenerated on every build, so hand edits are silently
42
+ * reverted on the next build:ssg. Entries are de-duplicated against each other
43
+ * and against the primary sitemap, trimmed, and — like allowAiCrawlers —
44
+ * skipped entirely when site.draft === true, since a pre-launch site should not
45
+ * be advertising discovery surfaces at all.
35
46
  */
36
47
 
37
48
  // No framework-default disallows: every framework route is either public
@@ -86,9 +97,23 @@ export function buildRobotsTxt(siteConfig, sitemapUrl = '') {
86
97
  }
87
98
 
88
99
  const trimmedSitemap = (sitemapUrl || '').trim();
89
- if (trimmedSitemap) {
100
+ const sitemaps = [];
101
+ if (trimmedSitemap) sitemaps.push(trimmedSitemap);
102
+
103
+ if (site.draft !== true) {
104
+ const extras = Array.isArray(site.robots?.extraSitemaps) ? site.robots.extraSitemaps : [];
105
+ for (const entry of extras) {
106
+ if (typeof entry !== 'string') continue;
107
+ const url = entry.trim();
108
+ // A duplicate line is not an error to a crawler, but it reads as a
109
+ // config bug to anyone opening the file.
110
+ if (url && !sitemaps.includes(url)) sitemaps.push(url);
111
+ }
112
+ }
113
+
114
+ if (sitemaps.length) {
90
115
  lines.push('');
91
- lines.push(`Sitemap: ${trimmedSitemap}`);
116
+ for (const url of sitemaps) lines.push(`Sitemap: ${url}`);
92
117
  }
93
118
 
94
119
  return `${lines.join('\n')}\n`;
package/vite-plugin.js CHANGED
@@ -172,7 +172,7 @@ const ENTRY_FILENAME = '.cms-entry.js';
172
172
  // When external theme packages are provided via the `themes` option, the plugin
173
173
  // generates additional import + registerTheme() calls so themes are available
174
174
  // before the app renders.
175
- function buildEntrySource(themePackages = [], extensionPackages = []) {
175
+ function buildEntrySource(themePackages = [], extensionPackages = [], trimSpecs = []) {
176
176
  const themeImports = themePackages.map(
177
177
  (pkg, i) => `import __theme${i} from '${pkg}';`
178
178
  ).join('\n');
@@ -189,8 +189,20 @@ function buildEntrySource(themePackages = [], extensionPackages = []) {
189
189
  (_, i) => `await registerExtension(__ext${i});`
190
190
  ).join('\n');
191
191
 
192
+ // Site-supplied hydration-payload trim hooks. Imported and registered at
193
+ // module-eval time, BEFORE createCmsApp() — extension setups run only on the
194
+ // client, so they are unreachable during SSG and cannot serve this purpose.
195
+ const trimImports = trimSpecs.map(
196
+ (spec, i) => `import __trim${i} from '${spec}';`
197
+ ).join('\n');
198
+
199
+ const trimRegistrations = trimSpecs.map(
200
+ (_, i) => `registerInitialStateTrim(__trim${i});`
201
+ ).join('\n');
202
+
192
203
  const needsThemeRegister = themePackages.length > 0;
193
204
  const needsExtRegister = extensionPackages.length > 0;
205
+ const needsTrimRegister = trimSpecs.length > 0;
194
206
 
195
207
  // Use dynamic import for createCmsApp so that setConfigLoader/etc run
196
208
  // BEFORE ViteSSG's auto-mount IIFE. Static imports are hoisted and
@@ -203,8 +215,10 @@ import { setAssetResolver } from '@koehler8/cms/utils/assetResolver';
203
215
  import { setSiteComponents } from '@koehler8/cms/utils/componentRegistry';
204
216
  ${needsThemeRegister ? `import { registerTheme } from '@koehler8/cms/themes/themeLoader';` : ''}
205
217
  ${needsExtRegister ? `import { registerExtension } from '@koehler8/cms/extensions/extensionLoader';` : ''}
218
+ ${needsTrimRegister ? `import { registerInitialStateTrim } from '@koehler8/cms/utils/initialStateTrim';` : ''}
206
219
  ${themeImports}
207
220
  ${extImports}
221
+ ${trimImports}
208
222
 
209
223
  import * as __cmsConfig from '${VIRTUAL_CONFIG}';
210
224
  import * as __cmsStyles from '${VIRTUAL_STYLES}';
@@ -219,6 +233,7 @@ setSiteComponents(__cmsSiteComponents);
219
233
 
220
234
  ${themeRegistrations}
221
235
  ${extRegistrations}
236
+ ${trimRegistrations}
222
237
 
223
238
  const { createCmsApp } = await import('@koehler8/cms/app');
224
239
  export const createApp = createCmsApp();
@@ -355,7 +370,7 @@ function discoverExtensionCjsDeps(extensionPackages, projectRoot) {
355
370
  // cms-validate-extensions CLI shares the exact same implementation. Imported
356
371
  // above (a bare `export ... from` re-export would not bind the name for the
357
372
  // plugin's own use) and re-exported here for tests and API stability.
358
- export { validateExtensionManifests };
373
+ export { validateExtensionManifests, buildEntrySource };
359
374
 
360
375
  // List every *.vue basename under a directory (recursive). Mirrors the
361
376
  // import.meta.glob discovery the runtime registries use.
@@ -441,6 +456,7 @@ export default function cmsPlugin(options = {}) {
441
456
  frameworkRoot: frameworkRootOption = __dirname,
442
457
  themes: themePackages = [],
443
458
  extensions: extensionPackages = [],
459
+ initialStateTrims: initialStateTrimOption = [],
444
460
  } = options;
445
461
  // The previous `locales` option is no longer accepted: per-site available
446
462
  // locales are now discovered from on-disk content directories. Passing
@@ -462,6 +478,9 @@ export default function cmsPlugin(options = {}) {
462
478
  let variantCacheDir;
463
479
  let variantManifestPath;
464
480
  let imageVariantWidths = [];
481
+ // Site-supplied initialStateTrim module specifiers, resolved at config time
482
+ // to something the generated entry (written into siteRoot) can import.
483
+ let initialStateTrims = [];
465
484
  // Closure-scoped (NOT this._isDevServer): Vite 8 binds configureServer to a
466
485
  // shared minimal context, not the object buildEnd/closeBundle see, so a
467
486
  // property stashed on `this` in one hook is invisible in the others.
@@ -469,7 +488,7 @@ export default function cmsPlugin(options = {}) {
469
488
 
470
489
  const writeTempFiles = () => {
471
490
  // Write entry file (with optional external theme registrations)
472
- fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages), 'utf-8');
491
+ fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages, initialStateTrims), 'utf-8');
473
492
 
474
493
  // Write index.html with site metadata injected and script src pointing
475
494
  // at the entry file
@@ -589,6 +608,28 @@ export default function cmsPlugin(options = {}) {
589
608
  variantCacheDir = computeVariantCacheDir(siteRoot, siteDir);
590
609
  variantManifestPath = path.join(variantCacheDir, '.manifest.json');
591
610
 
611
+ // Resolve initialStateTrims to specifiers the generated entry can import.
612
+ // A relative path that does not exist is a HARD failure at config time:
613
+ // this hook's failure mode is an invisibly-larger payload, so a typo must
614
+ // not degrade to a silent no-op.
615
+ initialStateTrims = (Array.isArray(initialStateTrimOption) ? initialStateTrimOption : [initialStateTrimOption])
616
+ .filter((spec) => typeof spec === 'string' && spec.trim())
617
+ .map((spec) => {
618
+ const trimmed = spec.trim();
619
+ // Bare package specifier — hand it to the bundler untouched.
620
+ if (!trimmed.startsWith('.') && !path.isAbsolute(trimmed)) return trimmed;
621
+ const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(siteRoot, trimmed);
622
+ if (!fs.existsSync(abs)) {
623
+ throw new Error(
624
+ `[cms] initialStateTrims: no such file: ${trimmed}\n` +
625
+ ` resolved to: ${abs}\n` +
626
+ ` (paths are resolved relative to the project root, ${siteRoot})`,
627
+ );
628
+ }
629
+ const rel = path.relative(siteRoot, abs).split(path.sep).join('/');
630
+ return rel.startsWith('.') ? rel : `./${rel}`;
631
+ });
632
+
592
633
  // Invalid extension manifests fail the build here, loudly, instead of
593
634
  // being silently skipped in the visitor's browser. The returned info
594
635
  // (slug + declared component names) feeds components[] validation below.
@@ -1101,7 +1142,7 @@ export const assetUrlMap = resolver.assetUrlMap;
1101
1142
  try {
1102
1143
  // Ensure the temp file exists for Vite's module graph
1103
1144
  if (!fs.existsSync(tempEntryPath)) {
1104
- fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages), 'utf-8');
1145
+ fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages, initialStateTrims), 'utf-8');
1105
1146
  }
1106
1147
  // Let Vite transform it (resolves bare specifiers, applies plugins)
1107
1148
  const result = await server.transformRequest(`/${ENTRY_FILENAME}`);