@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,7 +8,12 @@
8
8
  class="site-header__section ui-header__section"
9
9
  :class="{ 'ui-header__section--compact': isHeaderCompact }"
10
10
  >
11
- <nav class="site-header__nav" aria-label="Primary">
11
+ <nav
12
+ class="site-header__nav"
13
+ aria-label="Primary"
14
+ @keydown.esc="closeNavMenu($event, 'escape')"
15
+ @focusout="onNavFocusOut"
16
+ >
12
17
  <div class="container">
13
18
  <div class="site-header__inner">
14
19
  <a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger" :aria-label="`${siteName || 'Home'} – home`">
@@ -33,18 +38,59 @@
33
38
  Nav slot. Defaults to a horizontal <ul> of links built from
34
39
  `content.header.navItems[]` (each entry: { text, href, target? }).
35
40
  No items, no nav.
41
+
42
+ Below 720px the row collapses into a disclosure menu behind the
43
+ toggle button below. Before this existed the list was simply
44
+ display:none on narrow viewports, which left phone visitors with
45
+ no primary navigation at all.
36
46
  -->
37
47
  <slot name="nav" :items="navItems">
38
- <ul v-if="navItems.length" class="site-header__nav-list">
48
+ <ul
49
+ v-if="navItems.length"
50
+ id="site-header-nav-menu"
51
+ ref="navMenuRef"
52
+ class="site-header__nav-list"
53
+ :class="{ 'site-header__nav-list--open': isNavOpen }"
54
+ >
39
55
  <li v-for="(item, idx) in navItems" :key="`${item.href || item.text}-${idx}`">
40
56
  <a
41
57
  :href="item.href"
42
58
  :target="item.target || null"
43
59
  :rel="item.target === '_blank' ? 'noopener noreferrer' : null"
60
+ @click="closeNavMenu(null, 'selection')"
44
61
  >{{ item.text }}</a>
45
62
  </li>
46
63
  </ul>
47
64
  </slot>
65
+ <button
66
+ v-if="navItems.length && !hasNavSlot"
67
+ ref="navToggleRef"
68
+ class="site-header__nav-toggle"
69
+ type="button"
70
+ aria-controls="site-header-nav-menu"
71
+ :aria-expanded="isNavOpen ? 'true' : 'false'"
72
+ :aria-label="isNavOpen ? 'Close menu' : 'Open menu'"
73
+ @click="toggleNavMenu"
74
+ >
75
+ <svg viewBox="0 0 24 24" aria-hidden="true" width="22" height="22">
76
+ <path
77
+ v-if="isNavOpen"
78
+ d="M6 6l12 12M18 6L6 18"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="1.8"
82
+ stroke-linecap="round"
83
+ />
84
+ <path
85
+ v-else
86
+ d="M4 7h16M4 12h16M4 17h16"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ stroke-width="1.8"
90
+ stroke-linecap="round"
91
+ />
92
+ </svg>
93
+ </button>
48
94
  <div v-if="hasHeaderActions || showLocaleLinks || hasActionsSlot" class="site-header__actions">
49
95
  <!-- Site-provided extra actions, rendered before the locale dropdown. -->
50
96
  <slot name="actions" />
@@ -125,8 +171,12 @@ import { readStoredLocale, writeStoredLocale } from '../utils/webStorage.js';
125
171
 
126
172
  const slots = useSlots();
127
173
  const hasActionsSlot = computed(() => Boolean(slots.actions));
174
+ // A site overriding the nav slot owns its own responsive behaviour, so the
175
+ // bundled toggle must not render alongside it.
176
+ const hasNavSlot = computed(() => Boolean(slots.nav));
128
177
 
129
178
  const isLangOpen = ref(false);
179
+ const isNavOpen = ref(false);
130
180
  const isHeaderCompact = ref(false);
131
181
  const currentLocale = ref('');
132
182
  const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
@@ -189,6 +239,8 @@ const localeEmojis = {
189
239
  };
190
240
  const langButtonRef = ref(null);
191
241
  const langMenuRef = ref(null);
242
+ const navToggleRef = ref(null);
243
+ const navMenuRef = ref(null);
192
244
  const localeWrapperRef = ref(null);
193
245
  const localeMenuStyle = ref({ animationDuration: '500ms' });
194
246
  const logoLinkRef = ref(null);
@@ -228,9 +280,21 @@ const updateLocaleMenuPosition = () => {
228
280
  if (isLangOpen.value) {
229
281
  updateLocaleMenuPosition();
230
282
  }
283
+ // Above the breakpoint the toggle is display:none. Leaving isNavOpen true
284
+ // would strand aria-expanded="true" on a button no longer in the a11y tree.
285
+ if (isNavOpen.value && typeof window !== 'undefined' && window.innerWidth >= 720) {
286
+ isNavOpen.value = false;
287
+ }
231
288
  };
232
289
 
233
290
  const onDocumentClick = (e) => {
291
+ const navBtn = navToggleRef.value;
292
+ const navMenu = navMenuRef.value;
293
+ if (isNavOpen.value && !navBtn?.contains(e.target) && !navMenu?.contains(e.target)) {
294
+ isNavOpen.value = false;
295
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger: 'outside_click' });
296
+ }
297
+
234
298
  const btn = langButtonRef.value;
235
299
  const menu = langMenuRef.value;
236
300
  if (btn && btn.contains(e.target)) return; // toggle handled by button
@@ -266,6 +330,41 @@ const updateLocaleMenuPosition = () => {
266
330
  }
267
331
  };
268
332
 
333
+ const toggleNavMenu = () => {
334
+ isNavOpen.value = !isNavOpen.value;
335
+ trackEvent('header_nav_menu_toggle', {
336
+ state: isNavOpen.value ? 'open' : 'close',
337
+ trigger: 'click',
338
+ });
339
+ };
340
+
341
+ // Mirrors closeLocaleMenu: ESC restores focus to the toggle so keyboard
342
+ // dismissal doesn't strand focus inside a hidden menu (WCAG 2.4.3, 1.4.13).
343
+ const closeNavMenu = (event, trigger = 'escape') => {
344
+ if (!isNavOpen.value) return;
345
+ if (event) {
346
+ event.preventDefault?.();
347
+ event.stopPropagation?.();
348
+ }
349
+ isNavOpen.value = false;
350
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger });
351
+ if (trigger === 'escape') {
352
+ nextTick(() => navToggleRef.value?.focus());
353
+ }
354
+ };
355
+
356
+ // Closes the menu when focus leaves both the menu and its toggle. Scoped to
357
+ // those two elements rather than the whole <nav> so tabbing from a menu link
358
+ // to the locale button (also inside <nav>) still dismisses it.
359
+ const onNavFocusOut = (event) => {
360
+ if (!isNavOpen.value) return;
361
+ const next = event.relatedTarget;
362
+ if (!next) return;
363
+ if (navMenuRef.value?.contains(next)) return;
364
+ if (navToggleRef.value?.contains(next)) return;
365
+ closeNavMenu(null, 'focusout');
366
+ };
367
+
269
368
  // Closes the menu when keyboard focus leaves the wrapper entirely.
270
369
  // Without this, Tab would walk past the dropdown but leave it visible
271
370
  // and clickable — also fails 1.4.13's "dismissable" requirement.
@@ -475,6 +574,17 @@ const updateLocaleMenuPosition = () => {
475
574
  currentLocale.value = normalized;
476
575
  }
477
576
  );
577
+
578
+ // In-SPA navigation must dismiss the mobile menu. The link click handler
579
+ // covers taps on the menu's own links; this covers everything else that
580
+ // changes the route while it's open (back button, a link elsewhere on the
581
+ // page, a programmatic push).
582
+ watch(
583
+ () => route.path,
584
+ () => {
585
+ isNavOpen.value = false;
586
+ }
587
+ );
478
588
  </script>
479
589
 
480
590
  <style scoped>
@@ -542,8 +652,13 @@ const updateLocaleMenuPosition = () => {
542
652
  }
543
653
 
544
654
  /* Nav menu rendered from content.header.navItems[]. Sits between the logo
545
- and the actions cluster. Hidden on narrow viewports — sites that need
546
- responsive nav should override the slot. */
655
+ and the actions cluster.
656
+
657
+ Below 720px it becomes a disclosure panel dropped beneath the header bar,
658
+ opened by .site-header__nav-toggle. (It used to be plain display:none here,
659
+ with no toggle — phone visitors got no primary navigation at all.) Sites
660
+ that want something else still override the nav slot, which also suppresses
661
+ the bundled toggle. */
547
662
  .site-header__nav-list {
548
663
  display: none;
549
664
  list-style: none;
@@ -552,10 +667,76 @@ const updateLocaleMenuPosition = () => {
552
667
  gap: clamp(0.75rem, 2vw, 1.75rem);
553
668
  align-items: center;
554
669
  }
670
+ .site-header__nav-list--open {
671
+ /* Containing block is .site-header (position: sticky), so this drops the
672
+ panel flush under the full-width bar rather than under the inner column. */
673
+ display: flex;
674
+ position: absolute;
675
+ top: 100%;
676
+ left: 0;
677
+ right: 0;
678
+ flex-direction: column;
679
+ align-items: stretch;
680
+ gap: 0;
681
+ padding: 0.5rem 0;
682
+ background: var(--brand-header-bg, #020409);
683
+ border-top: 1px solid var(--ui-header-divider, rgba(255, 255, 255, 0.12));
684
+ box-shadow: var(--site-header-shadow, 0 18px 45px rgba(0, 0, 0, 0.35));
685
+ max-height: calc(100dvh - 100%);
686
+ overflow-y: auto;
687
+ }
688
+ .site-header__nav-list--open a {
689
+ display: block;
690
+ /* Comfortably past the 24x24 minimum for pointer targets (WCAG 2.5.8). */
691
+ padding: 0.85rem clamp(1rem, 5vw, 2rem);
692
+ }
693
+
694
+ /* Toggle is mobile-only; the row speaks for itself at 720px and up. */
695
+ .site-header__nav-toggle {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ flex: none;
700
+ /* 44px square — the comfortable touch target, well past WCAG 2.5.8's 24px. */
701
+ width: 44px;
702
+ height: 44px;
703
+ margin-left: auto;
704
+ padding: 0;
705
+ border: 0;
706
+ border-radius: 8px;
707
+ background: transparent;
708
+ color: inherit;
709
+ cursor: pointer;
710
+ }
711
+ .site-header__nav-toggle:hover {
712
+ background: var(--ui-header-hover, rgba(255, 255, 255, 0.08));
713
+ }
714
+
555
715
  @media (min-width: 720px) {
556
716
  .site-header__nav-list {
557
717
  display: flex;
558
718
  }
719
+ /* Neutralize the open state above the breakpoint: resizing past it while the
720
+ menu is open must not leave a dropped panel on a desktop layout. */
721
+ .site-header__nav-list--open {
722
+ position: static;
723
+ flex-direction: row;
724
+ align-items: center;
725
+ gap: clamp(0.75rem, 2vw, 1.75rem);
726
+ padding: 0;
727
+ background: none;
728
+ border-top: 0;
729
+ box-shadow: none;
730
+ max-height: none;
731
+ overflow-y: visible;
732
+ }
733
+ .site-header__nav-list--open a {
734
+ display: inline;
735
+ padding: 0;
736
+ }
737
+ .site-header__nav-toggle {
738
+ display: none;
739
+ }
559
740
  }
560
741
  .site-header__nav-list a {
561
742
  color: var(--brand-header-text, var(--brand-fg-100, inherit));
@@ -1,6 +1,7 @@
1
1
  import { onServerPrefetch, ref, watch } from 'vue';
2
2
  import { loadConfigData, mergeConfigTrees, peekConfigSync } from '../utils/loadConfig.js';
3
3
  import { readStoredLocale } from '../utils/webStorage.js';
4
+ import { isNotFoundPage, NOT_FOUND_PAGE_ID } from '../utils/notFound.js';
4
5
 
5
6
  function normalizePath(value) {
6
7
  if (!value || typeof value !== 'string') return '/';
@@ -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
- return {
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('404');
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. Returns '' when site.url is missing (the sitemap protocol requires
5
+ * sitemap. The not-found page is skipped for the same reason: it is a real
6
+ * pre-rendered route (so the plugin can copy it to 404.html) but it carries
7
+ * noindex, and listing a noindex URL is a Search Console warning.
8
+ * Returns '' when site.url is missing (the sitemap protocol requires
6
9
  * absolute URLs); the plugin treats empty output as "do not write the file."
7
10
  *
8
11
  * Single-locale sites emit only <loc> per page. Multi-locale sites emit one
@@ -15,6 +18,7 @@
15
18
 
16
19
  import { isPathDraft, normalizeDraftPath } from './draftMode.js';
17
20
  import { buildCanonicalUrl } from './canonicalUrl.js';
21
+ import { isNotFoundPage } from './notFound.js';
18
22
 
19
23
  function escapeXml(value) {
20
24
  return String(value).replace(/[<>&'"]/g, (c) => ({
@@ -48,10 +52,13 @@ export function buildSitemap(siteConfig, options = {}) {
48
52
  const entries = [];
49
53
  const seenPaths = new Set();
50
54
 
51
- for (const [, pageData] of Object.entries(pages)) {
55
+ for (const [pageId, pageData] of Object.entries(pages)) {
52
56
  if (!pageData || typeof pageData !== 'object') continue;
53
57
  const pagePath = normalizeDraftPath(pageData.path) || '/';
54
58
  if (isPathDraft(siteConfig, pagePath, pageData)) continue;
59
+ // The not-found page is pre-rendered as a real route so the plugin can copy
60
+ // it to 404.html, but it carries noindex and must never be advertised.
61
+ if (isNotFoundPage(pageId, pagePath)) continue;
55
62
  if (seenPaths.has(pagePath)) continue;
56
63
  seenPaths.add(pagePath);
57
64
 
package/vite-plugin.js CHANGED
@@ -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. AWS Amplify (and most
787
- // static hosts) auto-serve a top-level 404.html for any
788
- // unmatched URL with HTTP status 404, so this gives sites a
789
- // proper not-found response without per-site config.
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 so AWS Amplify serves
806
- // it for unmatched URLs with HTTP 404.
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