@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 +103 -0
- package/README.md +58 -0
- package/package.json +1 -1
- package/src/composables/usePageConfig.js +55 -6
- package/src/main.js +20 -5
- package/src/utils/initialStateTrim.js +94 -0
- package/src/utils/robotsGenerator.js +27 -2
- package/src/utils/sitemapGenerator.js +54 -4
- package/vite-plugin.js +45 -4
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
|
@@ -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
|
-
|
|
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)
|
|
277
|
+
if (stored) {
|
|
278
|
+
needsFullReload = true;
|
|
279
|
+
reloadIsUrgent = true;
|
|
280
|
+
}
|
|
249
281
|
}
|
|
250
282
|
|
|
251
283
|
if (needsFullReload) {
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
() => {
|
|
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
|
|
170
|
-
const rawPath = typeof
|
|
171
|
-
const localeParam = pickLocaleParam(
|
|
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
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
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}`);
|