@koehler8/cms 1.0.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,108 @@
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
+
75
+ ## 1.1.0
76
+
77
+ ### Fixed
78
+
79
+ - **A site-authored `pages/404.json` is no longer indexable.** The framework
80
+ flagged the not-found page via `selectPage`'s fallback branch only, which
81
+ runs when *no* page matches the requested path. But the authored page
82
+ necessarily carries `path: "/404"` so the plugin can pre-render it and copy
83
+ the output to `404.html` — which also makes it an ordinary route. A direct
84
+ hit on `/404` matched the path loop first and returned a page nothing
85
+ downstream recognized as a 404: `usePageMeta` emitted a canonical and skipped
86
+ the `noindex`, and `sitemapGenerator` listed the URL. Sites that followed the
87
+ documented advice to author a 404 for custom chrome got an indexable,
88
+ sitemapped `/404` for their trouble.
89
+
90
+ Detection now lives in `src/utils/notFound.js` and is shared by the runtime
91
+ and the build, so they cannot disagree. The page is matched by its reserved
92
+ `404` id *or* by resolving to `/404`, is flagged at every resolution point,
93
+ and is excluded from the sitemap. Authoring `pages/404.json` now does what
94
+ the docs always said it did: custom chrome *and* correct 404 semantics.
95
+
96
+ - **The bundled `Header` has a mobile navigation menu.** `content.header.navItems[]`
97
+ was `display: none` below 720px with nothing to open it, so phone visitors
98
+ got no primary navigation at all — the header's own comment told sites to
99
+ override the nav slot, which means forking the component. The links now
100
+ collapse into a disclosure panel behind a 44×44 toggle: `aria-expanded` /
101
+ `aria-controls` wired up, ESC closes and restores focus to the toggle,
102
+ focus-out and outside clicks dismiss it, in-SPA navigation and crossing the
103
+ breakpoint close it. Overriding the nav slot still suppresses the bundled
104
+ toggle entirely, so sites with their own responsive header are unaffected.
105
+
3
106
  ## 1.0.0
4
107
 
5
108
  First stable release. The changes below are the three implementation batches
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.0.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",
@@ -8,7 +8,12 @@
8
8
  class="site-header__section ui-header__section"
9
9
  :class="{ 'ui-header__section--compact': isHeaderCompact }"
10
10
  >
11
- <nav class="site-header__nav" aria-label="Primary">
11
+ <nav
12
+ class="site-header__nav"
13
+ aria-label="Primary"
14
+ @keydown.esc="closeNavMenu($event, 'escape')"
15
+ @focusout="onNavFocusOut"
16
+ >
12
17
  <div class="container">
13
18
  <div class="site-header__inner">
14
19
  <a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger" :aria-label="`${siteName || 'Home'} – home`">
@@ -33,18 +38,59 @@
33
38
  Nav slot. Defaults to a horizontal <ul> of links built from
34
39
  `content.header.navItems[]` (each entry: { text, href, target? }).
35
40
  No items, no nav.
41
+
42
+ Below 720px the row collapses into a disclosure menu behind the
43
+ toggle button below. Before this existed the list was simply
44
+ display:none on narrow viewports, which left phone visitors with
45
+ no primary navigation at all.
36
46
  -->
37
47
  <slot name="nav" :items="navItems">
38
- <ul v-if="navItems.length" class="site-header__nav-list">
48
+ <ul
49
+ v-if="navItems.length"
50
+ id="site-header-nav-menu"
51
+ ref="navMenuRef"
52
+ class="site-header__nav-list"
53
+ :class="{ 'site-header__nav-list--open': isNavOpen }"
54
+ >
39
55
  <li v-for="(item, idx) in navItems" :key="`${item.href || item.text}-${idx}`">
40
56
  <a
41
57
  :href="item.href"
42
58
  :target="item.target || null"
43
59
  :rel="item.target === '_blank' ? 'noopener noreferrer' : null"
60
+ @click="closeNavMenu(null, 'selection')"
44
61
  >{{ item.text }}</a>
45
62
  </li>
46
63
  </ul>
47
64
  </slot>
65
+ <button
66
+ v-if="navItems.length && !hasNavSlot"
67
+ ref="navToggleRef"
68
+ class="site-header__nav-toggle"
69
+ type="button"
70
+ aria-controls="site-header-nav-menu"
71
+ :aria-expanded="isNavOpen ? 'true' : 'false'"
72
+ :aria-label="isNavOpen ? 'Close menu' : 'Open menu'"
73
+ @click="toggleNavMenu"
74
+ >
75
+ <svg viewBox="0 0 24 24" aria-hidden="true" width="22" height="22">
76
+ <path
77
+ v-if="isNavOpen"
78
+ d="M6 6l12 12M18 6L6 18"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="1.8"
82
+ stroke-linecap="round"
83
+ />
84
+ <path
85
+ v-else
86
+ d="M4 7h16M4 12h16M4 17h16"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ stroke-width="1.8"
90
+ stroke-linecap="round"
91
+ />
92
+ </svg>
93
+ </button>
48
94
  <div v-if="hasHeaderActions || showLocaleLinks || hasActionsSlot" class="site-header__actions">
49
95
  <!-- Site-provided extra actions, rendered before the locale dropdown. -->
50
96
  <slot name="actions" />
@@ -125,8 +171,12 @@ import { readStoredLocale, writeStoredLocale } from '../utils/webStorage.js';
125
171
 
126
172
  const slots = useSlots();
127
173
  const hasActionsSlot = computed(() => Boolean(slots.actions));
174
+ // A site overriding the nav slot owns its own responsive behaviour, so the
175
+ // bundled toggle must not render alongside it.
176
+ const hasNavSlot = computed(() => Boolean(slots.nav));
128
177
 
129
178
  const isLangOpen = ref(false);
179
+ const isNavOpen = ref(false);
130
180
  const isHeaderCompact = ref(false);
131
181
  const currentLocale = ref('');
132
182
  const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
@@ -189,6 +239,8 @@ const localeEmojis = {
189
239
  };
190
240
  const langButtonRef = ref(null);
191
241
  const langMenuRef = ref(null);
242
+ const navToggleRef = ref(null);
243
+ const navMenuRef = ref(null);
192
244
  const localeWrapperRef = ref(null);
193
245
  const localeMenuStyle = ref({ animationDuration: '500ms' });
194
246
  const logoLinkRef = ref(null);
@@ -228,9 +280,21 @@ const updateLocaleMenuPosition = () => {
228
280
  if (isLangOpen.value) {
229
281
  updateLocaleMenuPosition();
230
282
  }
283
+ // Above the breakpoint the toggle is display:none. Leaving isNavOpen true
284
+ // would strand aria-expanded="true" on a button no longer in the a11y tree.
285
+ if (isNavOpen.value && typeof window !== 'undefined' && window.innerWidth >= 720) {
286
+ isNavOpen.value = false;
287
+ }
231
288
  };
232
289
 
233
290
  const onDocumentClick = (e) => {
291
+ const navBtn = navToggleRef.value;
292
+ const navMenu = navMenuRef.value;
293
+ if (isNavOpen.value && !navBtn?.contains(e.target) && !navMenu?.contains(e.target)) {
294
+ isNavOpen.value = false;
295
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger: 'outside_click' });
296
+ }
297
+
234
298
  const btn = langButtonRef.value;
235
299
  const menu = langMenuRef.value;
236
300
  if (btn && btn.contains(e.target)) return; // toggle handled by button
@@ -266,6 +330,41 @@ const updateLocaleMenuPosition = () => {
266
330
  }
267
331
  };
268
332
 
333
+ const toggleNavMenu = () => {
334
+ isNavOpen.value = !isNavOpen.value;
335
+ trackEvent('header_nav_menu_toggle', {
336
+ state: isNavOpen.value ? 'open' : 'close',
337
+ trigger: 'click',
338
+ });
339
+ };
340
+
341
+ // Mirrors closeLocaleMenu: ESC restores focus to the toggle so keyboard
342
+ // dismissal doesn't strand focus inside a hidden menu (WCAG 2.4.3, 1.4.13).
343
+ const closeNavMenu = (event, trigger = 'escape') => {
344
+ if (!isNavOpen.value) return;
345
+ if (event) {
346
+ event.preventDefault?.();
347
+ event.stopPropagation?.();
348
+ }
349
+ isNavOpen.value = false;
350
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger });
351
+ if (trigger === 'escape') {
352
+ nextTick(() => navToggleRef.value?.focus());
353
+ }
354
+ };
355
+
356
+ // Closes the menu when focus leaves both the menu and its toggle. Scoped to
357
+ // those two elements rather than the whole <nav> so tabbing from a menu link
358
+ // to the locale button (also inside <nav>) still dismisses it.
359
+ const onNavFocusOut = (event) => {
360
+ if (!isNavOpen.value) return;
361
+ const next = event.relatedTarget;
362
+ if (!next) return;
363
+ if (navMenuRef.value?.contains(next)) return;
364
+ if (navToggleRef.value?.contains(next)) return;
365
+ closeNavMenu(null, 'focusout');
366
+ };
367
+
269
368
  // Closes the menu when keyboard focus leaves the wrapper entirely.
270
369
  // Without this, Tab would walk past the dropdown but leave it visible
271
370
  // and clickable — also fails 1.4.13's "dismissable" requirement.
@@ -475,6 +574,17 @@ const updateLocaleMenuPosition = () => {
475
574
  currentLocale.value = normalized;
476
575
  }
477
576
  );
577
+
578
+ // In-SPA navigation must dismiss the mobile menu. The link click handler
579
+ // covers taps on the menu's own links; this covers everything else that
580
+ // changes the route while it's open (back button, a link elsewhere on the
581
+ // page, a programmatic push).
582
+ watch(
583
+ () => route.path,
584
+ () => {
585
+ isNavOpen.value = false;
586
+ }
587
+ );
478
588
  </script>
479
589
 
480
590
  <style scoped>
@@ -542,8 +652,13 @@ const updateLocaleMenuPosition = () => {
542
652
  }
543
653
 
544
654
  /* Nav menu rendered from content.header.navItems[]. Sits between the logo
545
- and the actions cluster. Hidden on narrow viewports — sites that need
546
- responsive nav should override the slot. */
655
+ and the actions cluster.
656
+
657
+ Below 720px it becomes a disclosure panel dropped beneath the header bar,
658
+ opened by .site-header__nav-toggle. (It used to be plain display:none here,
659
+ with no toggle — phone visitors got no primary navigation at all.) Sites
660
+ that want something else still override the nav slot, which also suppresses
661
+ the bundled toggle. */
547
662
  .site-header__nav-list {
548
663
  display: none;
549
664
  list-style: none;
@@ -552,10 +667,76 @@ const updateLocaleMenuPosition = () => {
552
667
  gap: clamp(0.75rem, 2vw, 1.75rem);
553
668
  align-items: center;
554
669
  }
670
+ .site-header__nav-list--open {
671
+ /* Containing block is .site-header (position: sticky), so this drops the
672
+ panel flush under the full-width bar rather than under the inner column. */
673
+ display: flex;
674
+ position: absolute;
675
+ top: 100%;
676
+ left: 0;
677
+ right: 0;
678
+ flex-direction: column;
679
+ align-items: stretch;
680
+ gap: 0;
681
+ padding: 0.5rem 0;
682
+ background: var(--brand-header-bg, #020409);
683
+ border-top: 1px solid var(--ui-header-divider, rgba(255, 255, 255, 0.12));
684
+ box-shadow: var(--site-header-shadow, 0 18px 45px rgba(0, 0, 0, 0.35));
685
+ max-height: calc(100dvh - 100%);
686
+ overflow-y: auto;
687
+ }
688
+ .site-header__nav-list--open a {
689
+ display: block;
690
+ /* Comfortably past the 24x24 minimum for pointer targets (WCAG 2.5.8). */
691
+ padding: 0.85rem clamp(1rem, 5vw, 2rem);
692
+ }
693
+
694
+ /* Toggle is mobile-only; the row speaks for itself at 720px and up. */
695
+ .site-header__nav-toggle {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ flex: none;
700
+ /* 44px square — the comfortable touch target, well past WCAG 2.5.8's 24px. */
701
+ width: 44px;
702
+ height: 44px;
703
+ margin-left: auto;
704
+ padding: 0;
705
+ border: 0;
706
+ border-radius: 8px;
707
+ background: transparent;
708
+ color: inherit;
709
+ cursor: pointer;
710
+ }
711
+ .site-header__nav-toggle:hover {
712
+ background: var(--ui-header-hover, rgba(255, 255, 255, 0.08));
713
+ }
714
+
555
715
  @media (min-width: 720px) {
556
716
  .site-header__nav-list {
557
717
  display: flex;
558
718
  }
719
+ /* Neutralize the open state above the breakpoint: resizing past it while the
720
+ menu is open must not leave a dropped panel on a desktop layout. */
721
+ .site-header__nav-list--open {
722
+ position: static;
723
+ flex-direction: row;
724
+ align-items: center;
725
+ gap: clamp(0.75rem, 2vw, 1.75rem);
726
+ padding: 0;
727
+ background: none;
728
+ border-top: 0;
729
+ box-shadow: none;
730
+ max-height: none;
731
+ overflow-y: visible;
732
+ }
733
+ .site-header__nav-list--open a {
734
+ display: inline;
735
+ padding: 0;
736
+ }
737
+ .site-header__nav-toggle {
738
+ display: none;
739
+ }
559
740
  }
560
741
  .site-header__nav-list a {
561
742
  color: var(--brand-header-text, var(--brand-fg-100, inherit));
@@ -1,6 +1,7 @@
1
1
  import { onServerPrefetch, ref, watch } from 'vue';
2
2
  import { loadConfigData, mergeConfigTrees, peekConfigSync } from '../utils/loadConfig.js';
3
3
  import { readStoredLocale } from '../utils/webStorage.js';
4
+ import { isNotFoundPage, NOT_FOUND_PAGE_ID } from '../utils/notFound.js';
4
5
 
5
6
  function normalizePath(value) {
6
7
  if (!value || typeof value !== 'string') return '/';
@@ -12,6 +13,19 @@ function normalizePath(value) {
12
13
  return normalized || '/';
13
14
  }
14
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
+
15
29
  function mergeWithSharedContent(sharedContent, pageSpecificContent) {
16
30
  const base = sharedContent && typeof sharedContent === 'object' ? sharedContent : {};
17
31
  const overrides = pageSpecificContent && typeof pageSpecificContent === 'object' ? pageSpecificContent : {};
@@ -36,6 +50,8 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
36
50
  let cachedConfig = null;
37
51
  let lastLocaleKey = null;
38
52
  let activeRequest = 0;
53
+ // Cancels a pending idle-scheduled background reload (see hydrateFromSyncCache).
54
+ let cancelIdleReload = () => {};
39
55
 
40
56
  function selectPage(config) {
41
57
  const pages = config?.pages || {};
@@ -44,7 +60,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
44
60
  const resolveById = (id) => {
45
61
  const data = pages[id];
46
62
  if (!data) return null;
47
- return {
63
+ const resolved = {
48
64
  id,
49
65
  path: data.path ?? '/',
50
66
  components: Array.isArray(data.components) ? [...data.components] : [],
@@ -53,6 +69,13 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
53
69
  draft: data.draft,
54
70
  jsonld: data.jsonld,
55
71
  };
72
+ // A site-authored pages/404.json resolves like any other route (it has a
73
+ // real path so the plugin can pre-render it), so the flag has to be set
74
+ // here rather than only on the not-found fallback below. Otherwise a
75
+ // direct hit on /404 yields a page usePageMeta treats as ordinary —
76
+ // canonical emitted, noindex omitted — and sitemapGenerator lists it.
77
+ if (isNotFoundPage(id, resolved.path)) resolved.isNotFound = true;
78
+ return resolved;
56
79
  };
57
80
 
58
81
  const currentPageId = typeof pageId === 'function' ? pageId() : pageId;
@@ -67,15 +90,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
67
90
  const requestedPath = normalizePath(currentPagePath);
68
91
  for (const [id, data] of entries) {
69
92
  if (normalizePath(data.path) === requestedPath) {
70
- return {
71
- id,
72
- path: data.path ?? '/',
73
- components: Array.isArray(data.components) ? [...data.components] : [],
74
- content: data.content || {},
75
- meta: data.meta || {},
76
- draft: data.draft,
77
- jsonld: data.jsonld,
78
- };
93
+ return resolveById(id);
79
94
  }
80
95
  }
81
96
 
@@ -88,8 +103,10 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
88
103
  // with their own components[]; otherwise the synthesized sentinel
89
104
  // renders the bundled NotFound component.
90
105
  if (requestedPath !== '/') {
91
- const customNotFound = resolveById('404');
106
+ const customNotFound = resolveById(NOT_FOUND_PAGE_ID);
92
107
  if (customNotFound) {
108
+ // resolveById already flags this; kept explicit so the guarantee is
109
+ // visible at the call site that depends on it.
93
110
  return { ...customNotFound, isNotFound: true };
94
111
  }
95
112
  return {
@@ -239,16 +256,43 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
239
256
  // route rendered the base locale. If a returning visitor stored a
240
257
  // different locale, reconcile to it. (loadConfigData reads localStorage
241
258
  // when no explicit locale is passed, so the same reload resolves it.)
242
- let needsFullReload = Boolean(primed && primed.pagesPartial);
243
-
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;
244
275
  if (localeForConfig === undefined) {
245
276
  const stored = (readStoredLocale() || '').trim().toLowerCase();
246
- if (stored) needsFullReload = true;
277
+ if (stored) {
278
+ needsFullReload = true;
279
+ reloadIsUrgent = true;
280
+ }
247
281
  }
248
282
 
249
283
  if (needsFullReload) {
250
- lastLocaleKey = null; // force syncPage to reload (localStorage-resolved for base routes)
251
- 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
+ }
252
296
  }
253
297
  return true;
254
298
  }
@@ -267,7 +311,14 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
267
311
 
268
312
  watch(
269
313
  () => [getPageId(), getPagePath(), getLocale()],
270
- () => { 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
+ },
271
322
  );
272
323
 
273
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
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Single source of truth for "is this page the not-found page".
3
+ *
4
+ * A site can author `pages/404.json` to give the 404 its own chrome and copy.
5
+ * That page necessarily carries `path: "/404"` so the plugin pre-renders it and
6
+ * can copy `dist/404/index.html` → `dist/404.html`. The consequence is that it
7
+ * is also an ordinary route: resolving by id or by path matches it directly,
8
+ * before `selectPage`'s not-found fallback ever runs.
9
+ *
10
+ * Without a shared check, those direct resolutions produce a page that looks
11
+ * ordinary to everything downstream — `usePageMeta` emits a canonical and skips
12
+ * the `noindex`, `sitemapGenerator` lists it, and breadcrumbs are derived for
13
+ * it. The result is an indexable, sitemapped /404, which is the opposite of what
14
+ * authoring the page was supposed to achieve.
15
+ *
16
+ * Both the runtime (usePageConfig → usePageMeta) and the build (sitemapGenerator)
17
+ * import from here so they cannot disagree about what counts.
18
+ */
19
+
20
+ /** Page id the framework reserves for a site-authored not-found page. */
21
+ export const NOT_FOUND_PAGE_ID = '404';
22
+
23
+ /** Route the not-found page is pre-rendered at. */
24
+ export const NOT_FOUND_PATH = '/404';
25
+
26
+ /**
27
+ * Normalize a page path for comparison: leading slash, no duplicate slashes,
28
+ * no trailing slash. Mirrors the normalization in usePageConfig so `/404/`,
29
+ * `404`, and `/404` all compare equal.
30
+ */
31
+ export function normalizeNotFoundPath(value) {
32
+ if (!value || typeof value !== 'string') return '/';
33
+ const trimmed = value.trim();
34
+ if (!trimmed) return '/';
35
+ let normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
36
+ normalized = normalized.replace(/\/+/g, '/');
37
+ normalized = normalized.replace(/\/+$/, '');
38
+ return normalized || '/';
39
+ }
40
+
41
+ /**
42
+ * True when a page is the not-found page — either by its reserved id or by
43
+ * being routed at /404. Either alone is enough: a site may name the file
44
+ * something else but point it at /404, or vice versa.
45
+ *
46
+ * @param {string} [id] page id (the content filename without extension)
47
+ * @param {string} [path] the page's `path` value
48
+ */
49
+ export function isNotFoundPage(id, path) {
50
+ if (id === NOT_FOUND_PAGE_ID) return true;
51
+ return normalizeNotFoundPath(path) === NOT_FOUND_PATH;
52
+ }
@@ -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`;
@@ -2,7 +2,10 @@
2
2
  * Build sitemap.xml from the inflated site config.
3
3
  *
4
4
  * Pages where isPathDraft() is true are skipped — drafts must not be in the
5
- * sitemap. Returns '' when site.url is missing (the sitemap protocol requires
5
+ * sitemap. The not-found page is skipped for the same reason: it is a real
6
+ * pre-rendered route (so the plugin can copy it to 404.html) but it carries
7
+ * noindex, and listing a noindex URL is a Search Console warning.
8
+ * Returns '' when site.url is missing (the sitemap protocol requires
6
9
  * absolute URLs); the plugin treats empty output as "do not write the file."
7
10
  *
8
11
  * Single-locale sites emit only <loc> per page. Multi-locale sites emit one
@@ -15,6 +18,7 @@
15
18
 
16
19
  import { isPathDraft, normalizeDraftPath } from './draftMode.js';
17
20
  import { buildCanonicalUrl } from './canonicalUrl.js';
21
+ import { isNotFoundPage } from './notFound.js';
18
22
 
19
23
  function escapeXml(value) {
20
24
  return String(value).replace(/[<>&'"]/g, (c) => ({
@@ -48,10 +52,13 @@ export function buildSitemap(siteConfig, options = {}) {
48
52
  const entries = [];
49
53
  const seenPaths = new Set();
50
54
 
51
- for (const [, pageData] of Object.entries(pages)) {
55
+ for (const [pageId, pageData] of Object.entries(pages)) {
52
56
  if (!pageData || typeof pageData !== 'object') continue;
53
57
  const pagePath = normalizeDraftPath(pageData.path) || '/';
54
58
  if (isPathDraft(siteConfig, pagePath, pageData)) continue;
59
+ // The not-found page is pre-rendered as a real route so the plugin can copy
60
+ // it to 404.html, but it carries noindex and must never be advertised.
61
+ if (isNotFoundPage(pageId, pagePath)) continue;
55
62
  if (seenPaths.has(pagePath)) continue;
56
63
  seenPaths.add(pagePath);
57
64
 
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.
@@ -783,10 +824,11 @@ export default function cmsPlugin(options = {}) {
783
824
  // not the bound; see SSG-MEMORY-PLAN.md for the durable fix.
784
825
  concurrency: 8,
785
826
  // After vite-ssg pre-renders every route, copy the rendered
786
- // dist/404/index.html to dist/404.html. AWS Amplify (and most
787
- // static hosts) auto-serve a top-level 404.html for any
788
- // unmatched URL with HTTP status 404, so this gives sites a
789
- // proper not-found response without per-site config.
827
+ // dist/404/index.html to dist/404.html — the not-found target
828
+ // hosts point their catch-all at. (GitHub-Pages-style hosts
829
+ // auto-serve it at HTTP 404; AWS Amplify does NOT — it needs the
830
+ // fleet's customRule `/<*>` → `/404.html` @ `404`, which Amplify
831
+ // implements as a 302 redirect. Measured 2026-08-16.)
790
832
  onFinished() {
791
833
  const distDir = path.join(siteRoot, process.env.CMS_SSG_OUTDIR || 'dist');
792
834
  const nestedNotFound = path.join(distDir, '404', 'index.html');
@@ -802,8 +844,11 @@ export default function cmsPlugin(options = {}) {
802
844
  includedRoutes(paths) {
803
845
  // /404 is the not-found landing page (rendered via the
804
846
  // catch-all + NotFound component). onFinished above copies
805
- // dist/404/index.html to dist/404.html so AWS Amplify serves
806
- // it for unmatched URLs with HTTP 404.
847
+ // dist/404/index.html to dist/404.html; the host's catch-all
848
+ // rule sends unmatched URLs there. (On AWS Amplify that rule is
849
+ // a 302 redirect — its `404` rule type redirects rather than
850
+ // rewriting, and it has no native 404.html fallback. Measured
851
+ // 2026-08-16; the page's noindex meta covers the SEO side.)
807
852
  const staticRoutes = new Set(['/404']);
808
853
  pagePaths.forEach((routePath) => staticRoutes.add(routePath));
809
854
 
@@ -1097,7 +1142,7 @@ export const assetUrlMap = resolver.assetUrlMap;
1097
1142
  try {
1098
1143
  // Ensure the temp file exists for Vite's module graph
1099
1144
  if (!fs.existsSync(tempEntryPath)) {
1100
- fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages), 'utf-8');
1145
+ fs.writeFileSync(tempEntryPath, buildEntrySource(themePackages, extensionPackages, initialStateTrims), 'utf-8');
1101
1146
  }
1102
1147
  // Let Vite transform it (resolves bare specifiers, applies plugins)
1103
1148
  const result = await server.transformRequest(`/${ENTRY_FILENAME}`);