@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 +103 -0
- package/README.md +58 -0
- package/package.json +1 -1
- package/src/components/Header.vue +185 -4
- package/src/composables/usePageConfig.js +68 -17
- package/src/main.js +20 -5
- package/src/utils/initialStateTrim.js +94 -0
- package/src/utils/notFound.js +52 -0
- package/src/utils/robotsGenerator.js +27 -2
- package/src/utils/sitemapGenerator.js +9 -2
- package/vite-plugin.js +55 -10
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
|
@@ -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
|
|
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
|
|
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.
|
|
546
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
277
|
+
if (stored) {
|
|
278
|
+
needsFullReload = true;
|
|
279
|
+
reloadIsUrgent = true;
|
|
280
|
+
}
|
|
247
281
|
}
|
|
248
282
|
|
|
249
283
|
if (needsFullReload) {
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
() => {
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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`;
|
|
@@ -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.
|
|
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
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
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
|
|
806
|
-
//
|
|
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}`);
|