@koehler8/cms 1.0.0 → 1.1.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 +31 -0
- package/package.json +1 -1
- package/src/components/Header.vue +185 -4
- package/src/composables/usePageConfig.js +13 -11
- package/src/utils/notFound.js +52 -0
- package/src/utils/sitemapGenerator.js +9 -2
- package/vite-plugin.js +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A site-authored `pages/404.json` is no longer indexable.** The framework
|
|
8
|
+
flagged the not-found page via `selectPage`'s fallback branch only, which
|
|
9
|
+
runs when *no* page matches the requested path. But the authored page
|
|
10
|
+
necessarily carries `path: "/404"` so the plugin can pre-render it and copy
|
|
11
|
+
the output to `404.html` — which also makes it an ordinary route. A direct
|
|
12
|
+
hit on `/404` matched the path loop first and returned a page nothing
|
|
13
|
+
downstream recognized as a 404: `usePageMeta` emitted a canonical and skipped
|
|
14
|
+
the `noindex`, and `sitemapGenerator` listed the URL. Sites that followed the
|
|
15
|
+
documented advice to author a 404 for custom chrome got an indexable,
|
|
16
|
+
sitemapped `/404` for their trouble.
|
|
17
|
+
|
|
18
|
+
Detection now lives in `src/utils/notFound.js` and is shared by the runtime
|
|
19
|
+
and the build, so they cannot disagree. The page is matched by its reserved
|
|
20
|
+
`404` id *or* by resolving to `/404`, is flagged at every resolution point,
|
|
21
|
+
and is excluded from the sitemap. Authoring `pages/404.json` now does what
|
|
22
|
+
the docs always said it did: custom chrome *and* correct 404 semantics.
|
|
23
|
+
|
|
24
|
+
- **The bundled `Header` has a mobile navigation menu.** `content.header.navItems[]`
|
|
25
|
+
was `display: none` below 720px with nothing to open it, so phone visitors
|
|
26
|
+
got no primary navigation at all — the header's own comment told sites to
|
|
27
|
+
override the nav slot, which means forking the component. The links now
|
|
28
|
+
collapse into a disclosure panel behind a 44×44 toggle: `aria-expanded` /
|
|
29
|
+
`aria-controls` wired up, ESC closes and restores focus to the toggle,
|
|
30
|
+
focus-out and outside clicks dismiss it, in-SPA navigation and crossing the
|
|
31
|
+
breakpoint close it. Overriding the nav slot still suppresses the bundled
|
|
32
|
+
toggle entirely, so sites with their own responsive header are unaffected.
|
|
33
|
+
|
|
3
34
|
## 1.0.0
|
|
4
35
|
|
|
5
36
|
First stable release. The changes below are the three implementation batches
|
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 '/';
|
|
@@ -44,7 +45,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
44
45
|
const resolveById = (id) => {
|
|
45
46
|
const data = pages[id];
|
|
46
47
|
if (!data) return null;
|
|
47
|
-
|
|
48
|
+
const resolved = {
|
|
48
49
|
id,
|
|
49
50
|
path: data.path ?? '/',
|
|
50
51
|
components: Array.isArray(data.components) ? [...data.components] : [],
|
|
@@ -53,6 +54,13 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
53
54
|
draft: data.draft,
|
|
54
55
|
jsonld: data.jsonld,
|
|
55
56
|
};
|
|
57
|
+
// A site-authored pages/404.json resolves like any other route (it has a
|
|
58
|
+
// real path so the plugin can pre-render it), so the flag has to be set
|
|
59
|
+
// here rather than only on the not-found fallback below. Otherwise a
|
|
60
|
+
// direct hit on /404 yields a page usePageMeta treats as ordinary —
|
|
61
|
+
// canonical emitted, noindex omitted — and sitemapGenerator lists it.
|
|
62
|
+
if (isNotFoundPage(id, resolved.path)) resolved.isNotFound = true;
|
|
63
|
+
return resolved;
|
|
56
64
|
};
|
|
57
65
|
|
|
58
66
|
const currentPageId = typeof pageId === 'function' ? pageId() : pageId;
|
|
@@ -67,15 +75,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
67
75
|
const requestedPath = normalizePath(currentPagePath);
|
|
68
76
|
for (const [id, data] of entries) {
|
|
69
77
|
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
|
-
};
|
|
78
|
+
return resolveById(id);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -88,8 +88,10 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
88
88
|
// with their own components[]; otherwise the synthesized sentinel
|
|
89
89
|
// renders the bundled NotFound component.
|
|
90
90
|
if (requestedPath !== '/') {
|
|
91
|
-
const customNotFound = resolveById(
|
|
91
|
+
const customNotFound = resolveById(NOT_FOUND_PAGE_ID);
|
|
92
92
|
if (customNotFound) {
|
|
93
|
+
// resolveById already flags this; kept explicit so the guarantee is
|
|
94
|
+
// visible at the call site that depends on it.
|
|
93
95
|
return { ...customNotFound, isNotFound: true };
|
|
94
96
|
}
|
|
95
97
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -783,10 +783,11 @@ export default function cmsPlugin(options = {}) {
|
|
|
783
783
|
// not the bound; see SSG-MEMORY-PLAN.md for the durable fix.
|
|
784
784
|
concurrency: 8,
|
|
785
785
|
// After vite-ssg pre-renders every route, copy the rendered
|
|
786
|
-
// dist/404/index.html to dist/404.html
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
786
|
+
// dist/404/index.html to dist/404.html — the not-found target
|
|
787
|
+
// hosts point their catch-all at. (GitHub-Pages-style hosts
|
|
788
|
+
// auto-serve it at HTTP 404; AWS Amplify does NOT — it needs the
|
|
789
|
+
// fleet's customRule `/<*>` → `/404.html` @ `404`, which Amplify
|
|
790
|
+
// implements as a 302 redirect. Measured 2026-08-16.)
|
|
790
791
|
onFinished() {
|
|
791
792
|
const distDir = path.join(siteRoot, process.env.CMS_SSG_OUTDIR || 'dist');
|
|
792
793
|
const nestedNotFound = path.join(distDir, '404', 'index.html');
|
|
@@ -802,8 +803,11 @@ export default function cmsPlugin(options = {}) {
|
|
|
802
803
|
includedRoutes(paths) {
|
|
803
804
|
// /404 is the not-found landing page (rendered via the
|
|
804
805
|
// catch-all + NotFound component). onFinished above copies
|
|
805
|
-
// dist/404/index.html to dist/404.html
|
|
806
|
-
//
|
|
806
|
+
// dist/404/index.html to dist/404.html; the host's catch-all
|
|
807
|
+
// rule sends unmatched URLs there. (On AWS Amplify that rule is
|
|
808
|
+
// a 302 redirect — its `404` rule type redirects rather than
|
|
809
|
+
// rewriting, and it has no native 404.html fallback. Measured
|
|
810
|
+
// 2026-08-16; the page's noindex meta covers the SEO side.)
|
|
807
811
|
const staticRoutes = new Set(['/404']);
|
|
808
812
|
pagePaths.forEach((routePath) => staticRoutes.add(routePath));
|
|
809
813
|
|