@koehler8/cms 1.0.0-beta.8 → 1.0.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 +1491 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +36 -16
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +184 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +81 -48
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +126 -28
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +545 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- package/scripts/patch-lru-cache-tla.js +0 -164
package/src/router/index.js
CHANGED
|
@@ -3,7 +3,21 @@ import { h } from 'vue';
|
|
|
3
3
|
|
|
4
4
|
import Home from '../components/Home.vue';
|
|
5
5
|
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 === '/' &&
|
|
151
|
+
if (to.path === '/' && localePrefixes.includes(shortLang)) {
|
|
91
152
|
return { path: `/${shortLang}`, replace: true };
|
|
92
153
|
}
|
|
93
154
|
}
|
package/src/styles/base.css
CHANGED
|
@@ -643,7 +643,9 @@ a:focus-visible {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
.ui-form-control::placeholder {
|
|
646
|
-
|
|
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
|
-
|
|
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
|
|
package/src/utils/appInfo.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const score = Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
32
|
+
return acc;
|
|
33
|
+
}, {});
|
|
34
|
+
}
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
111
|
+
}
|
|
42
112
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
+
}
|