@koehler8/cms 1.1.0 → 1.3.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.3.0
4
+
5
+ ### Added
6
+
7
+ - **`meta.lastmod` — a page can date its own sitemap entry.** `buildSitemap`
8
+ now emits `<lastmod>` for any page supplying `meta.lastmod`, as either a
9
+ complete date (`2026-09-06`) or a full ISO 8601 timestamp
10
+ (`2026-09-06T14:30:00Z`). All locale variants of a page inherit it — a
11
+ translation of a page is the same page.
12
+
13
+ This matters most for sites that publish on a cadence. Without `lastmod` a
14
+ crawler has no priority signal distinguishing the piece published last night
15
+ from one published in May, and has to rediscover that by fetching. On the
16
+ publication this was built for, Search Console reported **490 URLs
17
+ "Discovered - currently not indexed" with `Last crawled: N/A`** — found in the
18
+ sitemap and never fetched at all.
19
+
20
+ **The framework never derives a date.** Build time is not modification time,
21
+ and a sitemap whose `lastmod` moves on every deploy is one search engines
22
+ learn to ignore — Google's stated rule is that it must be consistently
23
+ accurate or it is disregarded. Only the site knows when its content actually
24
+ changed, so only the site can say.
25
+
26
+ A malformed value (`2026`, `09/06/2026`, `2026-02-30`, a `Date`, a number) is
27
+ dropped rather than emitted: an invalid `lastmod` invalidates the whole
28
+ `<url>` entry for some parsers, which is worse than having none.
29
+ `normalizeLastmod` is exported for sites that want to validate before writing.
30
+
31
+ Output for pages that omit `meta.lastmod` is unchanged, so existing sites see
32
+ no diff.
33
+
34
+ ## 1.2.0
35
+
36
+ ### Added
37
+
38
+ - **`initialStateTrims` — let a site prune the hydration payload it alone can
39
+ reason about.** `trimConfigToPage` reduces `config.pages` to the page being
40
+ prerendered, but it cannot touch `config.shared`: deciding which shared
41
+ content a given ROUTE needs requires the site's own notion of what that route
42
+ resolves to, which the framework has no access to.
43
+
44
+ That gap gets expensive for content-heavy sites. On a 767-page publication it
45
+ measured at 65.42 MB of `__INITIAL_STATE__` — **75.6% of all built HTML** —
46
+ because every page carried every item's body in every locale, while rendering
47
+ exactly one. With a hook registered: 11.01 MB (-83.2%), and gzipped HTML
48
+ 23.22 MB -> 7.23 MB (-68.9%). Worst-hit locale pages dropped ~60-73% of their
49
+ wire weight.
50
+
51
+ A site registers a synchronous function through the new plugin option; the
52
+ framework calls it once per prerendered route, after the page trim, and
53
+ stamps `sharedPartial: true` if the hook changed anything. `usePageConfig`
54
+ treats that marker exactly like `pagesPartial`, so whatever the hook prunes
55
+ is restored by the post-hydration reload before it can be navigated to.
56
+
57
+ Registration happens at module-eval time in the generated entry, which is the
58
+ only seam that works: extension setups run on the client only and are
59
+ unreachable during SSG. Hooks that throw, return a non-object, or drop
60
+ `site` / `shared` / `pages` wholesale degrade to a no-op — framework
61
+ components on pages the hook never reasons about read those directly, and
62
+ losing one is a hydration mismatch on every page's chrome.
63
+
64
+ `sharedPartial` gets its own reload clause rather than riding on
65
+ `pagesPartial`, because `trimConfigToPage` only sets that flag when a route
66
+ resolves unambiguously to a page id while the site trim fires either way.
67
+
68
+ Purely additive. With no `initialStateTrims` configured the generated entry
69
+ is **byte-identical** — asserted directly in `tests/vite-plugin.spec.js`,
70
+ because every site installs this version from npm before opting in.
71
+
72
+ - **`site.robots.extraSitemaps[]` — advertise a feed as a discovery surface.**
73
+ Google and Bing both accept an RSS/Atom feed as a sitemap format, so a
74
+ publication with a nightly-updating feed has a real second discovery channel
75
+ and no supported way to declare it. This appends one `Sitemap:` line per
76
+ entry after the primary, de-duplicated and trimmed.
77
+
78
+ It has to be a config option for exactly the reason `allowAiCrawlers` did:
79
+ `public/robots.txt` is regenerated from config on every build, so a site that
80
+ hand-edited an extra `Sitemap:` line had it silently reverted on the next
81
+ `build:ssg` — and silently, because nothing fails when a discovery hint
82
+ disappears. Skipped entirely when `site.draft === true`: a pre-launch site
83
+ emits `Disallow: /` and should not be advertising anything.
84
+
85
+ Purely additive. A site that does not set it produces a byte-identical
86
+ `robots.txt`.
87
+
88
+ ### Changed
89
+
90
+ - **The partial-embed config reload now waits for idle.** `pagesPartial`
91
+ triggered `syncPage({ soft: true })` on the critical path of every page load,
92
+ solely so a later in-SPA navigation could resolve other pages — on one site,
93
+ eagerly fetching a 157 KB per-locale chunk at 1.375 pages/session, for a
94
+ navigation most visitors never make.
95
+
96
+ The two reload triggers are not equivalent and are no longer treated the
97
+ same. A saved-locale switch is user-visible — the reader is looking at the
98
+ wrong language until it resolves — and stays eager. A partial embed is not:
99
+ the page has already rendered identically from the embed. That one moves to
100
+ `requestIdleCallback` (with a timeout fallback for Safari before 16.4).
101
+
102
+ `lastLocaleKey` is still cleared eagerly, so a navigation that beats the idle
103
+ callback triggers the full load itself through the route watcher; the idle
104
+ pass then finds the config current and re-applies it harmlessly.
105
+
3
106
  ## 1.1.0
4
107
 
5
108
  ### 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.3.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`;
@@ -14,6 +14,16 @@
14
14
  * <xhtml:link rel="alternate" hreflang="..." /> cluster plus an `x-default`
15
15
  * pointing at the base-locale URL. The single-locale output stays
16
16
  * byte-identical to pre-hreflang.
17
+ *
18
+ * <lastmod> is emitted for a page only when it supplies `meta.lastmod`, as
19
+ * either a date (YYYY-MM-DD) or a full ISO 8601 timestamp. The framework never
20
+ * derives one: build time is not modification time, and a sitemap whose
21
+ * lastmod moves on every deploy is one search engines learn to ignore
22
+ * (Google's stated rule is that it must be consistently accurate). A page that
23
+ * omits it is emitted exactly as before, so existing sites are unchanged.
24
+ *
25
+ * All locale variants of a page share its lastmod — a translation of a page is
26
+ * the same page, and the site is the only thing that knows otherwise.
17
27
  */
18
28
 
19
29
  import { isPathDraft, normalizeDraftPath } from './draftMode.js';
@@ -30,6 +40,33 @@ function escapeXml(value) {
30
40
  }[c]));
31
41
  }
32
42
 
43
+ // W3C Datetime, the subset the sitemap protocol allows: a complete date, or a
44
+ // complete date plus hours/minutes/seconds and a timezone. Anything else — a
45
+ // bare year, a US-format date, a Date object stringified, a number — is
46
+ // dropped rather than emitted, because a malformed lastmod invalidates the
47
+ // <url> entry for some parsers and is worse than none at all.
48
+ const LASTMOD_DATE = /^\d{4}-\d{2}-\d{2}$/;
49
+ const LASTMOD_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
50
+
51
+ export function normalizeLastmod(value) {
52
+ if (typeof value !== 'string') return '';
53
+ const raw = value.trim();
54
+ if (!raw) return '';
55
+ if (!LASTMOD_DATE.test(raw) && !LASTMOD_DATETIME.test(raw)) return '';
56
+ // Shape-valid but not a real day (2026-02-30, month 13) — Date rolls those
57
+ // over silently, so compare the round-trip rather than trusting the parse.
58
+ const [y, m, d] = raw.slice(0, 10).split('-').map(Number);
59
+ const probe = new Date(Date.UTC(y, m - 1, d));
60
+ if (
61
+ probe.getUTCFullYear() !== y ||
62
+ probe.getUTCMonth() !== m - 1 ||
63
+ probe.getUTCDate() !== d
64
+ ) {
65
+ return '';
66
+ }
67
+ return raw;
68
+ }
69
+
33
70
  export function buildSitemap(siteConfig, options = {}) {
34
71
  const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
35
72
  if (!siteUrl) return '';
@@ -65,8 +102,10 @@ export function buildSitemap(siteConfig, options = {}) {
65
102
  const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath, trailingSlash });
66
103
  if (!baseUrl) continue;
67
104
 
105
+ const lastmod = normalizeLastmod(pageData?.meta?.lastmod);
106
+
68
107
  if (!isMultiLocale) {
69
- entries.push({ loc: baseUrl, alternates: [] });
108
+ entries.push({ loc: baseUrl, alternates: [], lastmod });
70
109
  continue;
71
110
  }
72
111
 
@@ -81,7 +120,7 @@ export function buildSitemap(siteConfig, options = {}) {
81
120
 
82
121
  for (const loc of availableLocales) {
83
122
  const localizedUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath, trailingSlash });
84
- if (localizedUrl) entries.push({ loc: localizedUrl, alternates });
123
+ if (localizedUrl) entries.push({ loc: localizedUrl, alternates, lastmod });
85
124
  }
86
125
  }
87
126
 
@@ -94,13 +133,24 @@ export function buildSitemap(siteConfig, options = {}) {
94
133
  : 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
95
134
 
96
135
  const renderUrlEntry = (entry) => {
136
+ // <lastmod> follows <loc> and precedes the alternates: the sitemap XSD
137
+ // sequences loc, lastmod, changefreq, priority, and xhtml:link extensions
138
+ // are appended after those.
139
+ const lastmodLine = entry.lastmod
140
+ ? `\n <lastmod>${escapeXml(entry.lastmod)}</lastmod>`
141
+ : '';
142
+
97
143
  if (entry.alternates.length === 0) {
98
- return ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
144
+ // Kept on one line when there is nothing else to emit, so single-locale
145
+ // sites with no lastmod stay byte-identical to previous versions.
146
+ return entry.lastmod
147
+ ? ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmodLine}\n </url>`
148
+ : ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
99
149
  }
100
150
  const altLines = entry.alternates
101
151
  .map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}"/>`)
102
152
  .join('\n');
103
- return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n${altLines}\n </url>`;
153
+ return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmodLine}\n${altLines}\n </url>`;
104
154
  };
105
155
 
106
156
  const urlEntries = entries.map(renderUrlEntry).join('\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}`);