@koehler8/cms 1.0.0-beta.9 → 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.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -3,7 +3,21 @@ import { h } from 'vue';
3
3
 
4
4
  import Home from '../components/Home.vue';
5
5
 
6
- import { SUPPORTED_LOCALES } from '../constants/locales.js';
6
+ import { availableLocales, baseLocale } from '../utils/loadConfig.js';
7
+ import { readStoredLocale, writeStoredLocale } from '../utils/webStorage.js';
8
+
9
+ // Test for `/{baseLocale}` or `/{baseLocale}/...` paths — these no longer
10
+ // pre-render or match the locale layout (the canonical URL for the base
11
+ // locale is unprefixed). Used by the router guard to redirect in-SPA
12
+ // navigation so external clicks land on the canonical URL instead of the
13
+ // SPA catch-all → 404 path.
14
+ function stripBaseLocalePrefix(targetPath, base) {
15
+ if (!base || !targetPath) return null;
16
+ const root = `/${base}`;
17
+ if (targetPath === root) return '/';
18
+ if (targetPath.startsWith(`${root}/`)) return targetPath.slice(root.length) || '/';
19
+ return null;
20
+ }
7
21
 
8
22
  const LocaleLayout = {
9
23
  name: 'LocaleLayout',
@@ -12,57 +26,96 @@ const LocaleLayout = {
12
26
  },
13
27
  };
14
28
 
15
- export const routes = [
16
- {
29
+ // Locales that should match the `/:locale/...` route — every available
30
+ // locale on disk EXCEPT the base. The base locale is served at the
31
+ // unprefixed path; emitting `/{baseLocale}/path` would be a duplicate URL.
32
+ //
33
+ // Read at module-load time. The vite-plugin's generated entry calls
34
+ // setConfigLoader() synchronously before importing the app, so by the
35
+ // time this module is evaluated (inside main.js), the singletons are
36
+ // populated.
37
+ function computeLocalePrefixes() {
38
+ if (!Array.isArray(availableLocales)) return [];
39
+ return availableLocales.filter((l) => typeof l === 'string' && l !== baseLocale);
40
+ }
41
+
42
+ export function buildRoutes(localePrefixes = computeLocalePrefixes()) {
43
+ const homeRoute = {
17
44
  path: '/',
18
45
  name: 'Home',
19
46
  component: Home,
20
47
  props: { pageId: 'home', pagePath: '/' },
21
- },
22
- {
23
- path: `/:locale(${SUPPORTED_LOCALES.join('|')})`,
24
- component: LocaleLayout,
25
- children: [
26
- {
27
- path: '',
28
- name: 'HomeLocale',
29
- component: Home,
30
- props: (route) => ({ pageId: 'home', pagePath: '/', locale: route.params.locale }),
31
- },
32
- {
33
- path: ':pathMatch(.*)*',
34
- name: 'LocaleDynamicPage',
35
- component: Home,
36
- props: (route) => {
37
- const pathMatch = route.params.pathMatch;
38
- const slugSegments = Array.isArray(pathMatch) ? pathMatch : [pathMatch || ''];
39
- const rawSlug = `/${slugSegments.filter(Boolean).join('/')}`;
40
- const normalizedSlug = rawSlug.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
41
- return { pagePath: normalizedSlug, locale: route.params.locale };
42
- },
43
- },
44
- ],
45
- },
46
- {
48
+ };
49
+
50
+ const catchAllRoute = {
47
51
  path: '/:pathMatch(.*)*',
48
52
  name: 'DynamicPage',
49
53
  component: Home,
50
54
  props: (route) => ({ pagePath: route.path || '/' }),
51
- },
52
- ];
55
+ };
56
+
57
+ if (!localePrefixes.length) {
58
+ // Single-locale site: don't register the locale-prefixed route at all.
59
+ // `/de/about` etc. fall into the catch-all → DynamicPage with a
60
+ // pagePath that won't match any page → home fallback.
61
+ return [homeRoute, catchAllRoute];
62
+ }
63
+
64
+ return [
65
+ homeRoute,
66
+ {
67
+ path: `/:locale(${localePrefixes.join('|')})`,
68
+ component: LocaleLayout,
69
+ children: [
70
+ {
71
+ path: '',
72
+ name: 'HomeLocale',
73
+ component: Home,
74
+ props: (route) => ({ pageId: 'home', pagePath: '/', locale: route.params.locale }),
75
+ },
76
+ {
77
+ path: ':pathMatch(.*)*',
78
+ name: 'LocaleDynamicPage',
79
+ component: Home,
80
+ props: (route) => {
81
+ const pathMatch = route.params.pathMatch;
82
+ const slugSegments = Array.isArray(pathMatch) ? pathMatch : [pathMatch || ''];
83
+ const rawSlug = `/${slugSegments.filter(Boolean).join('/')}`;
84
+ const normalizedSlug = rawSlug.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
85
+ return { pagePath: normalizedSlug, locale: route.params.locale };
86
+ },
87
+ },
88
+ ],
89
+ },
90
+ catchAllRoute,
91
+ ];
92
+ }
93
+
94
+ export const routes = buildRoutes();
53
95
 
54
96
  function normalizeLocale(value = '') {
55
97
  return value.toLowerCase().split('-')[0];
56
98
  }
57
99
 
58
- export function applyRouterGuards(router) {
100
+ export function applyRouterGuards(router, localePrefixes = computeLocalePrefixes()) {
59
101
  router.beforeEach((to) => {
102
+ // SPA-side redirect for stale base-locale-prefixed URLs. When a user
103
+ // navigates inside the app to `/en/about` (and `en` is base), the URL
104
+ // doesn't pre-render and the catch-all renders the 404 page. Rewrite
105
+ // to the canonical unprefixed equivalent. Direct external hits to
106
+ // these URLs still need an Amplify customRule on the host side — see
107
+ // CLAUDE.md "URL hygiene".
108
+ const stripped = stripBaseLocalePrefix(to.path, baseLocale);
109
+ if (stripped !== null && stripped !== to.path) {
110
+ return { path: stripped, replace: true };
111
+ }
112
+
60
113
  const rawLocale = (to.params.locale || '').toString().trim();
61
114
 
62
115
  if (rawLocale) {
63
116
  const normalized = normalizeLocale(rawLocale);
64
117
 
65
- if (!SUPPORTED_LOCALES.includes(normalized)) {
118
+ if (!localePrefixes.includes(normalized)) {
66
119
  return { path: '/', replace: true };
67
120
  }
68
121
 
@@ -75,19 +128,27 @@ export function applyRouterGuards(router) {
75
128
  try {
76
129
  document.documentElement.lang = normalized;
77
130
  } catch {}
78
- try {
79
- localStorage.setItem('locale', normalized);
80
- } catch {}
131
+ writeStoredLocale(normalized);
81
132
  }
82
133
 
83
134
  return;
84
135
  }
85
136
 
86
137
  if (!import.meta.env.SSR) {
138
+ // Browser-language auto-redirect on first visit only. If the user
139
+ // already has a stored locale preference (set by either an explicit
140
+ // dropdown click or a previous visit to a locale-prefixed URL),
141
+ // respect that — don't second-guess them with browser-language
142
+ // detection. This prevents the bug where clicking "EN" on /de
143
+ // navigates to / and the auto-detect bounces back to /de because
144
+ // the browser is set to German.
145
+ const storedPreference = (readStoredLocale() || '').toString().toLowerCase();
146
+ if (storedPreference) return;
147
+
87
148
  const navLang = (navigator.language || navigator.userLanguage || '').toString();
88
149
  const shortLang = normalizeLocale(navLang || '');
89
150
 
90
- if (to.path === '/' && SUPPORTED_LOCALES.includes(shortLang)) {
151
+ if (to.path === '/' && localePrefixes.includes(shortLang)) {
91
152
  return { path: `/${shortLang}`, replace: true };
92
153
  }
93
154
  }
@@ -643,7 +643,9 @@ a:focus-visible {
643
643
  }
644
644
 
645
645
  .ui-form-control::placeholder {
646
- color: var(--ui-form-placeholder, var(--brand-input-placeholder, rgba(84, 98, 123, 0.64)));
646
+ /* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
647
+ placeholder colors slip below 4.5:1 quickly. */
648
+ color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
647
649
  }
648
650
 
649
651
  .ui-form-control:focus {
@@ -692,7 +694,9 @@ a:focus-visible {
692
694
  }
693
695
 
694
696
  .ui-input-group__input::placeholder {
695
- color: var(--ui-form-placeholder, var(--brand-input-placeholder, rgba(84, 98, 123, 0.64)));
697
+ /* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
698
+ placeholder colors slip below 4.5:1 quickly. */
699
+ color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
696
700
  }
697
701
 
698
702
  .ui-input-group__input:focus {
@@ -852,6 +856,11 @@ a:focus-visible {
852
856
  transform: translate3d(0, 0, 0);
853
857
  }
854
858
 
859
+ .ui-backtotop:focus-visible {
860
+ outline: 2px solid #ffffff;
861
+ outline-offset: 3px;
862
+ }
863
+
855
864
  .ui-backtotop__icon {
856
865
  font-size: 1rem;
857
866
  line-height: 1;
@@ -68,6 +68,12 @@ export function buildCssVarMap(manifest) {
68
68
  const radii = tokens.radii || {};
69
69
  const elevation = tokens.elevation || {};
70
70
  const utility = tokens.utility || {};
71
+ // Section-chrome blocks for surfaces that pair text with non-default
72
+ // backgrounds. Components read these to avoid the fallback-chain trap
73
+ // (e.g. dark hero surface + light --ui-text-primary fallback = invisible).
74
+ const hero = tokens.hero || {};
75
+ const footer = tokens.footer || {};
76
+ const plan = tokens.plan || {};
71
77
 
72
78
  assignPrefixed(
73
79
  vars,
@@ -195,6 +201,15 @@ export function buildCssVarMap(manifest) {
195
201
  setVar(vars, '--ui-field-value', text.primary);
196
202
  setVar(vars, '--ui-status-heading-color', text.accent || palette.primary);
197
203
 
204
+ // Standalone-page tokens consumed by DraftGate and NotFound, which render
205
+ // outside any themed section. Every manifest theme feeds these; the two
206
+ // components keep neutral literal fallbacks for the unthemed case.
207
+ setVar(vars, '--brand-text', text.primary);
208
+ setVar(vars, '--brand-text-muted', text.muted);
209
+ setVar(vars, '--brand-surface', surfaces.base);
210
+ setVar(vars, '--brand-surface-elevated', surfaces.card || surfaces.raised);
211
+ setVar(vars, '--brand-border', surfaces.border);
212
+
198
213
  setVar(vars, '--brand-countdown-digit', palette.primary);
199
214
  setVar(vars, '--brand-countdown-label', text.muted);
200
215
 
@@ -231,6 +246,25 @@ export function buildCssVarMap(manifest) {
231
246
  setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
232
247
  setVar(vars, '--status-price-value-shadow', elevation.raised);
233
248
 
249
+ // Hero text — paired with the hero surface to keep contrast intact when
250
+ // a consumer overrides the hero bg. textOnDark is the fallback when no
251
+ // hero.text is set.
252
+ setVar(vars, '--brand-hero-text', hero.text || text.primary);
253
+ setVar(vars, '--brand-hero-text-on-dark', hero.textOnDark || text.onDark || text.inverse);
254
+ setVar(vars, '--brand-hero-eyebrow', hero.eyebrow || palette.primary);
255
+
256
+ // Footer chrome — FooterMinimal's default bg is dark, so it reads
257
+ // these instead of falling through --ui-text-primary (a light-on-light trap).
258
+ setVar(vars, '--brand-footer-text', footer.text || text.onDark || text.inverse);
259
+ setVar(vars, '--brand-footer-text-muted', footer.textMuted || text.onDarkMuted);
260
+ setVar(vars, '--brand-footer-link-color', footer.linkColor || footer.text || text.onDark || text.inverse);
261
+ setVar(vars, '--brand-footer-link-hover', footer.linkHover || '#ffffff');
262
+
263
+ // Plan card chrome — same dark-surface pairing.
264
+ setVar(vars, '--brand-plan-card-text', plan.cardText || text.inverse);
265
+ setVar(vars, '--brand-plan-card-muted-text', plan.cardMutedText || text.onDarkMuted);
266
+ setVar(vars, '--brand-plan-step-ring', plan.stepRing);
267
+
234
268
  return vars;
235
269
  }
236
270
 
@@ -1,9 +1,12 @@
1
- import packageJsonRaw from '../../package.json?raw';
2
-
3
- const packageJson = JSON.parse(packageJsonRaw);
4
-
1
+ // Framework version as seen by the runtime (used by useComponentResolver's
2
+ // minAppVersion checks and available to extensions).
3
+ //
4
+ // The Vite plugin defines import.meta.env.VITE_APP_VERSION at build time
5
+ // (framework package.json version, overridable by a real VITE_APP_VERSION
6
+ // env var), so no package.json bytes ship in the bundle. Outside the plugin
7
+ // (bare unit tests) the fallback is '0.0.0'.
5
8
  const envVersion = import.meta?.env?.VITE_APP_VERSION;
6
9
 
7
10
  export const APP_VERSION = (typeof envVersion === 'string' && envVersion.trim())
8
11
  ? envVersion.trim()
9
- : packageJson?.version || '0.0.0';
12
+ : '0.0.0';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Build a JSON-LD `BreadcrumbList` for the current page from URL path
3
+ * segments and the inflated pages map. Drives Google's breadcrumb display
4
+ * in search results without authors having to hand-author the schema.
5
+ *
6
+ * Returns `null` when breadcrumbs aren't applicable (home, draft, 404,
7
+ * single-segment path with no resolvable hierarchy, missing site.url, or
8
+ * per-page opt-out via `currentPage.meta.breadcrumbs === false`).
9
+ *
10
+ * Label resolution priority for each path prefix `/seg1/seg2/...`:
11
+ * 1. A page in the inflated config whose `path` matches → its
12
+ * `meta.title`, falling back to a slug-derived title-case label.
13
+ * 2. Otherwise → the last segment formatted (`my-post-title` →
14
+ * `My Post Title`).
15
+ *
16
+ * The first entry is always `Home` linked at the canonical home URL. The
17
+ * last entry is the current page; its `item` URL is included (Google
18
+ * accepts both forms; including is more interoperable).
19
+ */
20
+
21
+ import { buildCanonicalUrl } from './canonicalUrl.js';
22
+
23
+ function normalizePath(value) {
24
+ if (typeof value !== 'string') return '/';
25
+ const trimmed = value.trim();
26
+ if (!trimmed) return '/';
27
+ let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
28
+ p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
29
+ return p || '/';
30
+ }
31
+
32
+ function titleCaseSlug(slug) {
33
+ if (typeof slug !== 'string') return '';
34
+ return slug
35
+ .replace(/[-_]+/g, ' ')
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ .replace(/\b\w/g, (c) => c.toUpperCase());
39
+ }
40
+
41
+ function findPageByPath(pages, targetPath) {
42
+ if (!pages || typeof pages !== 'object') return null;
43
+ const norm = normalizePath(targetPath);
44
+ for (const data of Object.values(pages)) {
45
+ if (!data || typeof data !== 'object') continue;
46
+ if (normalizePath(data.path) === norm) return data;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function resolveLabel(pages, prefixPath, lastSegment) {
52
+ const page = findPageByPath(pages, prefixPath);
53
+ const explicitTitle = page?.meta?.title;
54
+ if (typeof explicitTitle === 'string' && explicitTitle.trim()) {
55
+ return explicitTitle.trim();
56
+ }
57
+ return titleCaseSlug(lastSegment) || lastSegment || '';
58
+ }
59
+
60
+ export function buildBreadcrumbList({
61
+ siteUrl,
62
+ currentPage,
63
+ pages,
64
+ baseLocale,
65
+ locale,
66
+ trailingSlash = false,
67
+ } = {}) {
68
+ if (!currentPage || typeof currentPage !== 'object') return null;
69
+ if (currentPage.isNotFound) return null;
70
+ if (currentPage.draft === true) return null;
71
+ if (currentPage.meta && currentPage.meta.breadcrumbs === false) return null;
72
+
73
+ const host = (typeof siteUrl === 'string' ? siteUrl : '').trim().replace(/\/+$/, '');
74
+ if (!host) return null;
75
+
76
+ const targetPath = normalizePath(currentPage.path);
77
+ if (targetPath === '/' || targetPath === '') return null;
78
+
79
+ const segments = targetPath.split('/').filter(Boolean);
80
+ if (segments.length === 0) return null;
81
+
82
+ const homeHref = buildCanonicalUrl({
83
+ siteUrl: host,
84
+ locale,
85
+ baseLocale,
86
+ path: '/',
87
+ trailingSlash,
88
+ });
89
+
90
+ const items = [
91
+ {
92
+ '@type': 'ListItem',
93
+ position: 1,
94
+ name: 'Home',
95
+ item: homeHref,
96
+ },
97
+ ];
98
+
99
+ for (let i = 0; i < segments.length; i++) {
100
+ const prefixPath = `/${segments.slice(0, i + 1).join('/')}`;
101
+ const href = buildCanonicalUrl({
102
+ siteUrl: host,
103
+ locale,
104
+ baseLocale,
105
+ path: prefixPath,
106
+ trailingSlash,
107
+ });
108
+ items.push({
109
+ '@type': 'ListItem',
110
+ position: items.length + 1,
111
+ name: resolveLabel(pages, prefixPath, segments[i]),
112
+ item: href,
113
+ });
114
+ }
115
+
116
+ return {
117
+ '@context': 'https://schema.org',
118
+ '@type': 'BreadcrumbList',
119
+ itemListElement: items,
120
+ };
121
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Single source of truth for the canonical URL formula.
3
+ *
4
+ * Used by:
5
+ * - usePageMeta.js (emits <link rel="canonical"> + <link rel="alternate" hreflang>)
6
+ * - sitemapGenerator.js (emits <loc> + <xhtml:link rel="alternate" hreflang>)
7
+ *
8
+ * URL space rule (matches Google / Stripe / GitHub conventions):
9
+ * - Base locale → ${siteUrl}${path} e.g. https://example.com/about
10
+ * - Non-base → ${siteUrl}/${locale}${path} e.g. https://example.com/de/about
11
+ *
12
+ * When `trailingSlash` is true, every non-root URL gets a trailing slash
13
+ * (`/about/`, `/de/`, `/de/about/`) — the root (`/`) is unchanged. This aligns
14
+ * the canonical with how a static host serving nested `dirStyle` output
15
+ * (`/about/index.html`) actually serves the page (`/about/`, with `/about`
16
+ * 301-redirecting to it), so the canonical names the URL that returns 200
17
+ * instead of one that redirects. Opt in per-site via `site.trailingSlash`;
18
+ * default (false) is byte-identical to the no-slash scheme.
19
+ *
20
+ * Returns '' when siteUrl is missing — sitemaps require absolute URLs and
21
+ * canonical links without a host are useless. Callers treat '' as "skip".
22
+ */
23
+
24
+ function normalizePath(value) {
25
+ if (typeof value !== 'string') return '/';
26
+ const trimmed = value.trim();
27
+ if (!trimmed) return '/';
28
+ let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
29
+ p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
30
+ return p || '/';
31
+ }
32
+
33
+ function normalizeLocale(value) {
34
+ if (typeof value !== 'string') return '';
35
+ const trimmed = value.trim().toLowerCase();
36
+ return trimmed;
37
+ }
38
+
39
+ export function buildCanonicalUrl({ siteUrl, locale, baseLocale, path, trailingSlash = false } = {}) {
40
+ const host = typeof siteUrl === 'string' ? siteUrl.trim().replace(/\/+$/, '') : '';
41
+ if (!host) return '';
42
+
43
+ const normalizedPath = normalizePath(path);
44
+ const loc = normalizeLocale(locale);
45
+ const base = normalizeLocale(baseLocale);
46
+ const slash = trailingSlash ? '/' : '';
47
+
48
+ if (!loc || loc === base) {
49
+ return normalizedPath === '/' ? `${host}/` : `${host}${normalizedPath}${slash}`;
50
+ }
51
+
52
+ return normalizedPath === '/' ? `${host}/${loc}${slash}` : `${host}/${loc}${normalizedPath}${slash}`;
53
+ }
@@ -1,3 +1,11 @@
1
+ // Bundled CMS components — discovered at module-eval time via the
2
+ // framework's own glob. Site-local components from `site/components/`
3
+ // are added separately at runtime via `setSiteComponents()` (called by
4
+ // the generated entry from a Vite virtual module — see vite-plugin.js
5
+ // VIRTUAL_SITE_COMPONENTS). Site components are checked BEFORE bundled
6
+ // components during resolution (most-specific wins), with a console
7
+ // warning when a site component shadows a bundled component.
8
+
1
9
  const rawModules = import.meta.glob(['../components/**/*.vue', '!../components/Home.vue'], { eager: true });
2
10
 
3
11
  function normalizeComponentEntry(entry) {
@@ -11,7 +19,7 @@ function resolveComponentName(filePath) {
11
19
  }
12
20
 
13
21
  export function createRegistry(modules) {
14
- const registry = Object.entries(modules).reduce((acc, [file, module]) => {
22
+ return Object.entries(modules).reduce((acc, [file, module]) => {
15
23
  const component = normalizeComponentEntry(module);
16
24
  if (!component) return acc;
17
25
 
@@ -21,26 +29,100 @@ export function createRegistry(modules) {
21
29
  acc[componentName] = component;
22
30
  }
23
31
 
24
- if (componentName.startsWith('Spacer') && componentName.length > 'Spacer'.length) {
25
- const suffix = componentName.slice('Spacer'.length);
26
- const parsed = Number.parseInt(suffix, 10);
27
- const score = Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
32
+ return acc;
33
+ }, {});
34
+ }
28
35
 
29
- const existing = acc.__spacerFallback;
30
- if (!existing || score < existing.score) {
31
- acc.__spacerFallback = { component, score };
32
- }
36
+ export const registry = createRegistry(rawModules);
37
+
38
+ // ----------------------------------------------------------------------------
39
+ // Site-local component registry
40
+ //
41
+ // Sites can drop Vue files into `site/components/**/*.vue` and reference them
42
+ // from `pages/{pageId}.json` `components[]` by name — same as bundled or
43
+ // extension components. The vite plugin globs these eagerly into a virtual
44
+ // module that the generated entry imports and hands to `setSiteComponents`.
45
+ //
46
+ // Resolution priority (in useComponentResolver): site → extension → bundled.
47
+ // Source-qualified `site:Name` always resolves to the site-local component;
48
+ // `slug:Name` continues to target a specific extension.
49
+
50
+ const siteRegistry = {};
51
+ const siteShadows = {};
52
+
53
+ function buildSiteRegistry(modules) {
54
+ const next = {};
55
+ const collisions = {};
56
+ for (const [file, module] of Object.entries(modules || {})) {
57
+ const component = normalizeComponentEntry(module);
58
+ if (!component) continue;
59
+ const name = resolveComponentName(file);
60
+ if (!name) continue;
61
+ if (next[name]) {
62
+ // Same basename in two different files under site/components/ — keep
63
+ // the first and warn so the user can rename.
64
+ (collisions[name] = collisions[name] || []).push(file);
65
+ continue;
33
66
  }
67
+ next[name] = component;
68
+ }
69
+ return { entries: next, duplicates: collisions };
70
+ }
34
71
 
35
- return acc;
36
- }, {});
72
+ /**
73
+ * Register components found under `site/components/`. Called once during app
74
+ * initialization by the generated entry. Also accepts the result of an
75
+ * `import.meta.glob('@cms-site/components/**\/*.vue', { eager: true })` call.
76
+ */
77
+ export function setSiteComponents(modules) {
78
+ // Reset prior state so HMR re-registrations don't accumulate stale entries.
79
+ for (const key of Object.keys(siteRegistry)) delete siteRegistry[key];
80
+ for (const key of Object.keys(siteShadows)) delete siteShadows[key];
37
81
 
38
- if (!registry.Spacer && registry.__spacerFallback && registry.__spacerFallback.component) {
39
- registry.Spacer = registry.__spacerFallback.component;
82
+ const source = modules && typeof modules === 'object'
83
+ ? (modules.default && typeof modules.default === 'object' ? modules.default : modules)
84
+ : {};
85
+
86
+ const { entries, duplicates } = buildSiteRegistry(source);
87
+ Object.assign(siteRegistry, entries);
88
+
89
+ // Emit a single concise warning per site-component name that:
90
+ // - shadows a bundled CMS component (overrides are powerful and easy
91
+ // to do by accident — flag them so they're explicit), or
92
+ // - has duplicate basenames across subdirectories (only the first
93
+ // wins; rename or restructure to fix).
94
+ if (typeof console !== 'undefined' && import.meta.env?.DEV) {
95
+ for (const name of Object.keys(entries)) {
96
+ if (registry[name] && registry[name] !== entries[name]) {
97
+ siteShadows[name] = true;
98
+ console.warn(
99
+ `[site-components] "${name}" overrides a bundled CMS component. ` +
100
+ `Use "site:${name}" in pages/*.json to make this explicit, or rename if accidental.`,
101
+ );
102
+ }
103
+ }
104
+ for (const [name, files] of Object.entries(duplicates)) {
105
+ console.warn(
106
+ `[site-components] Multiple files named "${name}.vue" in site/components/; ` +
107
+ `keeping the first registration. Duplicates: ${files.join(', ')}.`,
108
+ );
109
+ }
40
110
  }
41
- delete registry.__spacerFallback;
111
+ }
42
112
 
43
- return registry;
113
+ /**
114
+ * Lookup helper used by useComponentResolver. Returns the site-local component
115
+ * for `name` or `undefined`.
116
+ */
117
+ export function getSiteComponent(name) {
118
+ if (!name) return undefined;
119
+ return siteRegistry[name];
44
120
  }
45
121
 
46
- export const registry = createRegistry(rawModules);
122
+ /**
123
+ * Read-only snapshot of the registered site components. Mainly for tests and
124
+ * tooling; runtime resolution should use getSiteComponent.
125
+ */
126
+ export function getSiteRegistry() {
127
+ return { ...siteRegistry };
128
+ }