@koehler8/cms 1.0.0-beta.18 → 1.0.0-beta.20
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/package.json +1 -1
- package/src/components/Home.vue +1 -1
- package/src/components/NotFound.vue +100 -0
- package/src/composables/usePageConfig.js +24 -0
- package/src/composables/usePageMeta.js +89 -4
- package/src/router/index.js +62 -34
- package/src/utils/canonicalUrl.js +44 -0
- package/src/utils/loadConfig.js +3 -0
- package/src/utils/sitemapGenerator.js +52 -15
- package/src/utils/socialMeta.js +83 -0
- package/templates/index.html +7 -11
- package/vite-plugin.js +57 -7
package/package.json
CHANGED
package/src/components/Home.vue
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="not-found" aria-labelledby="not-found-heading">
|
|
3
|
+
<p class="not-found__eyebrow" aria-hidden="true">404</p>
|
|
4
|
+
<h1 id="not-found-heading" class="not-found__title">{{ heading }}</h1>
|
|
5
|
+
<p class="not-found__body">{{ body }}</p>
|
|
6
|
+
<a class="not-found__home" :href="homeHref">{{ homeLabel }}</a>
|
|
7
|
+
</section>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup>
|
|
11
|
+
import { computed, inject } from 'vue';
|
|
12
|
+
|
|
13
|
+
const pageContent = inject('pageContent', null);
|
|
14
|
+
const siteData = inject('siteData', null);
|
|
15
|
+
|
|
16
|
+
const config = computed(() => {
|
|
17
|
+
const fromShared = siteData?.value?.shared?.content?.notFound;
|
|
18
|
+
const fromPage = pageContent?.value?.notFound;
|
|
19
|
+
return { ...(fromShared || {}), ...(fromPage || {}) };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const heading = computed(() => config.value.heading || 'Page not found');
|
|
23
|
+
const body = computed(
|
|
24
|
+
() => config.value.body || "We couldn't find the page you're looking for.",
|
|
25
|
+
);
|
|
26
|
+
const homeLabel = computed(() => config.value.homeLabel || 'Return home');
|
|
27
|
+
const homeHref = computed(() => config.value.homeHref || '/');
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.not-found {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
min-height: 60vh;
|
|
37
|
+
padding: clamp(2rem, 6vw, 4rem) clamp(1rem, 4vw, 2rem);
|
|
38
|
+
text-align: center;
|
|
39
|
+
color: var(--brand-text, #1f2a44);
|
|
40
|
+
background: var(--brand-surface, transparent);
|
|
41
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.not-found__eyebrow {
|
|
45
|
+
margin: 0 0 0.25rem;
|
|
46
|
+
font-size: clamp(2.5rem, 8vw, 4rem);
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
line-height: 1;
|
|
49
|
+
letter-spacing: 0.02em;
|
|
50
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.55));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.not-found__title {
|
|
54
|
+
margin: 0 0 0.75rem;
|
|
55
|
+
font-size: clamp(1.5rem, 4vw, 2.25rem);
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.not-found__body {
|
|
61
|
+
margin: 0 0 1.5rem;
|
|
62
|
+
max-width: 32rem;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
line-height: 1.6;
|
|
65
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.7));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.not-found__home {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
min-height: 2.75rem;
|
|
73
|
+
padding: 0.7rem 1.5rem;
|
|
74
|
+
font-size: 1rem;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
color: var(--brand-primary-cta-text, #ffffff);
|
|
78
|
+
background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
|
|
79
|
+
border-radius: var(--brand-button-radius, 10px);
|
|
80
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.not-found__home:hover {
|
|
84
|
+
transform: translateY(-1px);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.not-found__home:focus-visible {
|
|
88
|
+
outline: 2px solid var(--brand-primary, #1f2a44);
|
|
89
|
+
outline-offset: 3px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
.not-found__home {
|
|
94
|
+
transition: none;
|
|
95
|
+
}
|
|
96
|
+
.not-found__home:hover {
|
|
97
|
+
transform: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</style>
|
|
@@ -75,6 +75,30 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// No page matches the requested path. If it's anything other than
|
|
80
|
+
// the home path "/" — or the explicit /404 SSG route — treat it as
|
|
81
|
+
// a not-found page (instead of silently returning home content with
|
|
82
|
+
// HTTP 200, which is what the framework used to do).
|
|
83
|
+
//
|
|
84
|
+
// Sites can override the rendered 404 by authoring pages/404.json
|
|
85
|
+
// with their own components[]; otherwise the synthesized sentinel
|
|
86
|
+
// renders the bundled NotFound component.
|
|
87
|
+
if (requestedPath !== '/') {
|
|
88
|
+
const customNotFound = resolveById('404');
|
|
89
|
+
if (customNotFound) {
|
|
90
|
+
return { ...customNotFound, isNotFound: true };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id: '__not_found__',
|
|
94
|
+
path: requestedPath,
|
|
95
|
+
components: [{ name: 'NotFound', enabled: true }],
|
|
96
|
+
content: {},
|
|
97
|
+
meta: {},
|
|
98
|
+
draft: undefined,
|
|
99
|
+
isNotFound: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
const homeFallback = resolveById('home');
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { computed } from 'vue';
|
|
2
2
|
import { useHead } from '@unhead/vue';
|
|
3
3
|
import { isPathDraft } from '../utils/draftMode.js';
|
|
4
|
+
import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
|
|
5
|
+
import { buildSocialMeta } from '../utils/socialMeta.js';
|
|
6
|
+
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
4
7
|
|
|
5
8
|
const SPECIAL_PAGE_LABELS = {
|
|
6
9
|
home: '',
|
|
@@ -36,17 +39,29 @@ function inferPageLabel(page) {
|
|
|
36
39
|
.join(' / ');
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
export function usePageMeta({ siteData, currentPage }) {
|
|
42
|
+
export function usePageMeta({ siteData, currentPage, locale }) {
|
|
40
43
|
const siteTitle = computed(() => siteData.value?.site?.title || '');
|
|
41
44
|
const siteDescription = computed(() => siteData.value?.site?.description || '');
|
|
45
|
+
const siteUrl = computed(() => siteData.value?.site?.url || '');
|
|
46
|
+
const localeValue = computed(() => {
|
|
47
|
+
if (typeof locale === 'function') return locale() || '';
|
|
48
|
+
if (typeof locale === 'string') return locale;
|
|
49
|
+
return '';
|
|
50
|
+
});
|
|
42
51
|
|
|
43
52
|
const isDraft = computed(() =>
|
|
44
53
|
isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
|
|
45
54
|
);
|
|
55
|
+
const isNotFound = computed(() => Boolean(currentPage.value?.isNotFound));
|
|
46
56
|
|
|
47
57
|
const pageMetaTitle = computed(() => {
|
|
48
58
|
const page = currentPage.value;
|
|
49
59
|
const site = siteTitle.value;
|
|
60
|
+
// 404 pages: generic "Page not found — Site". The page slug (which is
|
|
61
|
+
// some URL the visitor typed) shouldn't leak into <title>.
|
|
62
|
+
if (isNotFound.value) {
|
|
63
|
+
return site ? `Page not found — ${site}` : 'Page not found';
|
|
64
|
+
}
|
|
50
65
|
// Draft pages emit a generic title so the page slug doesn't leak via
|
|
51
66
|
// <title> in the SSG-rendered HTML on disk. The tab title remains
|
|
52
67
|
// generic even after client-side unlock — for "iterate before launch"
|
|
@@ -66,7 +81,7 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
66
81
|
});
|
|
67
82
|
|
|
68
83
|
const pageMetaDescription = computed(() => {
|
|
69
|
-
if (isDraft.value) {
|
|
84
|
+
if (isDraft.value || isNotFound.value) {
|
|
70
85
|
return '';
|
|
71
86
|
}
|
|
72
87
|
const explicitDescription = currentPage.value?.meta?.description;
|
|
@@ -76,20 +91,90 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
76
91
|
return siteDescription.value || '';
|
|
77
92
|
});
|
|
78
93
|
|
|
94
|
+
// Canonical URL — base-locale URLs have no prefix; non-base locales get
|
|
95
|
+
// /{locale}/path. Drafts and 404s skip canonical (the URL itself isn't
|
|
96
|
+
// authoritative for gated/missing content). Sites without a configured
|
|
97
|
+
// site.url skip too — we can't build an absolute URL.
|
|
98
|
+
const canonicalHref = computed(() => {
|
|
99
|
+
if (isDraft.value || isNotFound.value) return '';
|
|
100
|
+
if (!siteUrl.value) return '';
|
|
101
|
+
return buildCanonicalUrl({
|
|
102
|
+
siteUrl: siteUrl.value,
|
|
103
|
+
locale: localeValue.value,
|
|
104
|
+
baseLocale: configBaseLocale,
|
|
105
|
+
path: currentPage.value?.path || '/',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// hreflang alternates — only emitted when a site has more than one
|
|
110
|
+
// locale's content on disk. Includes one entry per available locale plus
|
|
111
|
+
// an `x-default` pointing at the base-locale URL (Google's preferred
|
|
112
|
+
// pattern for sites without explicit geo-targeting).
|
|
113
|
+
const hreflangLinks = computed(() => {
|
|
114
|
+
if (isDraft.value || isNotFound.value) return [];
|
|
115
|
+
if (!siteUrl.value) return [];
|
|
116
|
+
if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
|
|
117
|
+
const path = currentPage.value?.path || '/';
|
|
118
|
+
const links = [];
|
|
119
|
+
for (const code of configAvailableLocales) {
|
|
120
|
+
const href = buildCanonicalUrl({
|
|
121
|
+
siteUrl: siteUrl.value,
|
|
122
|
+
locale: code,
|
|
123
|
+
baseLocale: configBaseLocale,
|
|
124
|
+
path,
|
|
125
|
+
});
|
|
126
|
+
if (href) {
|
|
127
|
+
links.push({ rel: 'alternate', hreflang: code, href, key: `hreflang-${code}` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const defaultHref = buildCanonicalUrl({
|
|
131
|
+
siteUrl: siteUrl.value,
|
|
132
|
+
locale: configBaseLocale,
|
|
133
|
+
baseLocale: configBaseLocale,
|
|
134
|
+
path,
|
|
135
|
+
});
|
|
136
|
+
if (defaultHref) {
|
|
137
|
+
links.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref, key: 'hreflang-x-default' });
|
|
138
|
+
}
|
|
139
|
+
return links;
|
|
140
|
+
});
|
|
141
|
+
|
|
79
142
|
useHead(() => {
|
|
80
143
|
const description = pageMetaDescription.value;
|
|
81
144
|
const meta = [];
|
|
82
145
|
if (description) {
|
|
83
146
|
meta.push({ name: 'description', content: description, key: 'description' });
|
|
84
147
|
}
|
|
85
|
-
|
|
148
|
+
// 404 pages get noindex defensively — Amplify (or whichever host)
|
|
149
|
+
// already serves them with HTTP 404, but search engines occasionally
|
|
150
|
+
// crawl 404 URLs and the meta is a stronger signal than relying on
|
|
151
|
+
// the status header alone.
|
|
152
|
+
if (isDraft.value || isNotFound.value) {
|
|
86
153
|
meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
|
|
87
154
|
}
|
|
155
|
+
const social = buildSocialMeta({
|
|
156
|
+
siteData: siteData.value,
|
|
157
|
+
currentPage: currentPage.value,
|
|
158
|
+
pageTitle: pageMetaTitle.value,
|
|
159
|
+
pageDescription: pageMetaDescription.value,
|
|
160
|
+
canonicalHref: canonicalHref.value,
|
|
161
|
+
isDraft: isDraft.value,
|
|
162
|
+
isNotFound: isNotFound.value,
|
|
163
|
+
});
|
|
164
|
+
for (const entry of social) meta.push(entry);
|
|
165
|
+
const link = [];
|
|
166
|
+
if (canonicalHref.value) {
|
|
167
|
+
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
168
|
+
}
|
|
169
|
+
for (const entry of hreflangLinks.value) {
|
|
170
|
+
link.push(entry);
|
|
171
|
+
}
|
|
88
172
|
return {
|
|
89
173
|
title: pageMetaTitle.value,
|
|
90
174
|
meta,
|
|
175
|
+
link,
|
|
91
176
|
};
|
|
92
177
|
});
|
|
93
178
|
|
|
94
|
-
return { pageMetaTitle, pageMetaDescription, isDraft };
|
|
179
|
+
return { pageMetaTitle, pageMetaDescription, isDraft, isNotFound, canonicalHref, hreflangLinks };
|
|
95
180
|
}
|
package/src/router/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
7
|
|
|
8
8
|
const LocaleLayout = {
|
|
9
9
|
name: 'LocaleLayout',
|
|
@@ -12,57 +12,85 @@ const LocaleLayout = {
|
|
|
12
12
|
},
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Locales that should match the `/:locale/...` route — every available
|
|
16
|
+
// locale on disk EXCEPT the base. The base locale is served at the
|
|
17
|
+
// unprefixed path; emitting `/{baseLocale}/path` would be a duplicate URL.
|
|
18
|
+
//
|
|
19
|
+
// Read at module-load time. The vite-plugin's generated entry calls
|
|
20
|
+
// setConfigLoader() synchronously before importing the app, so by the
|
|
21
|
+
// time this module is evaluated (inside main.js), the singletons are
|
|
22
|
+
// populated.
|
|
23
|
+
function computeLocalePrefixes() {
|
|
24
|
+
if (!Array.isArray(availableLocales)) return [];
|
|
25
|
+
return availableLocales.filter((l) => typeof l === 'string' && l !== baseLocale);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildRoutes(localePrefixes = computeLocalePrefixes()) {
|
|
29
|
+
const homeRoute = {
|
|
17
30
|
path: '/',
|
|
18
31
|
name: 'Home',
|
|
19
32
|
component: Home,
|
|
20
33
|
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
|
-
{
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const catchAllRoute = {
|
|
47
37
|
path: '/:pathMatch(.*)*',
|
|
48
38
|
name: 'DynamicPage',
|
|
49
39
|
component: Home,
|
|
50
40
|
props: (route) => ({ pagePath: route.path || '/' }),
|
|
51
|
-
}
|
|
52
|
-
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!localePrefixes.length) {
|
|
44
|
+
// Single-locale site: don't register the locale-prefixed route at all.
|
|
45
|
+
// `/de/about` etc. fall into the catch-all → DynamicPage with a
|
|
46
|
+
// pagePath that won't match any page → home fallback.
|
|
47
|
+
return [homeRoute, catchAllRoute];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
homeRoute,
|
|
52
|
+
{
|
|
53
|
+
path: `/:locale(${localePrefixes.join('|')})`,
|
|
54
|
+
component: LocaleLayout,
|
|
55
|
+
children: [
|
|
56
|
+
{
|
|
57
|
+
path: '',
|
|
58
|
+
name: 'HomeLocale',
|
|
59
|
+
component: Home,
|
|
60
|
+
props: (route) => ({ pageId: 'home', pagePath: '/', locale: route.params.locale }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
path: ':pathMatch(.*)*',
|
|
64
|
+
name: 'LocaleDynamicPage',
|
|
65
|
+
component: Home,
|
|
66
|
+
props: (route) => {
|
|
67
|
+
const pathMatch = route.params.pathMatch;
|
|
68
|
+
const slugSegments = Array.isArray(pathMatch) ? pathMatch : [pathMatch || ''];
|
|
69
|
+
const rawSlug = `/${slugSegments.filter(Boolean).join('/')}`;
|
|
70
|
+
const normalizedSlug = rawSlug.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
71
|
+
return { pagePath: normalizedSlug, locale: route.params.locale };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
catchAllRoute,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const routes = buildRoutes();
|
|
53
81
|
|
|
54
82
|
function normalizeLocale(value = '') {
|
|
55
83
|
return value.toLowerCase().split('-')[0];
|
|
56
84
|
}
|
|
57
85
|
|
|
58
|
-
export function applyRouterGuards(router) {
|
|
86
|
+
export function applyRouterGuards(router, localePrefixes = computeLocalePrefixes()) {
|
|
59
87
|
router.beforeEach((to) => {
|
|
60
88
|
const rawLocale = (to.params.locale || '').toString().trim();
|
|
61
89
|
|
|
62
90
|
if (rawLocale) {
|
|
63
91
|
const normalized = normalizeLocale(rawLocale);
|
|
64
92
|
|
|
65
|
-
if (!
|
|
93
|
+
if (!localePrefixes.includes(normalized)) {
|
|
66
94
|
return { path: '/', replace: true };
|
|
67
95
|
}
|
|
68
96
|
|
|
@@ -87,7 +115,7 @@ export function applyRouterGuards(router) {
|
|
|
87
115
|
const navLang = (navigator.language || navigator.userLanguage || '').toString();
|
|
88
116
|
const shortLang = normalizeLocale(navLang || '');
|
|
89
117
|
|
|
90
|
-
if (to.path === '/' &&
|
|
118
|
+
if (to.path === '/' && localePrefixes.includes(shortLang)) {
|
|
91
119
|
return { path: `/${shortLang}`, replace: true };
|
|
92
120
|
}
|
|
93
121
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
* Returns '' when siteUrl is missing — sitemaps require absolute URLs and
|
|
13
|
+
* canonical links without a host are useless. Callers treat '' as "skip".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function normalizePath(value) {
|
|
17
|
+
if (typeof value !== 'string') return '/';
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (!trimmed) return '/';
|
|
20
|
+
let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
21
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
22
|
+
return p || '/';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeLocale(value) {
|
|
26
|
+
if (typeof value !== 'string') return '';
|
|
27
|
+
const trimmed = value.trim().toLowerCase();
|
|
28
|
+
return trimmed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildCanonicalUrl({ siteUrl, locale, baseLocale, path } = {}) {
|
|
32
|
+
const host = typeof siteUrl === 'string' ? siteUrl.trim().replace(/\/+$/, '') : '';
|
|
33
|
+
if (!host) return '';
|
|
34
|
+
|
|
35
|
+
const normalizedPath = normalizePath(path);
|
|
36
|
+
const loc = normalizeLocale(locale);
|
|
37
|
+
const base = normalizeLocale(baseLocale);
|
|
38
|
+
|
|
39
|
+
if (!loc || loc === base) {
|
|
40
|
+
return normalizedPath === '/' ? `${host}/` : `${host}${normalizedPath}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return normalizedPath === '/' ? `${host}/${loc}` : `${host}/${loc}${normalizedPath}`;
|
|
44
|
+
}
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -307,13 +307,16 @@ let _loadConfigData = async () => {
|
|
|
307
307
|
};
|
|
308
308
|
|
|
309
309
|
let _availableLocales = [];
|
|
310
|
+
let _baseLocale = '';
|
|
310
311
|
|
|
311
312
|
export function setConfigLoader(instance) {
|
|
312
313
|
if (!instance) return;
|
|
313
314
|
_loadConfigData = instance.loadConfigData;
|
|
314
315
|
_availableLocales = instance.availableLocales || [];
|
|
316
|
+
_baseLocale = typeof instance.baseLocale === 'string' ? instance.baseLocale : '';
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
export { _availableLocales as availableLocales };
|
|
320
|
+
export { _baseLocale as baseLocale };
|
|
318
321
|
|
|
319
322
|
export const loadConfigData = (...args) => _loadConfigData(...args);
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* sitemap. Returns '' when site.url is missing (the sitemap protocol requires
|
|
6
6
|
* absolute URLs); the plugin treats empty output as "do not write the file."
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* hreflang
|
|
10
|
-
*
|
|
8
|
+
* Single-locale sites emit only <loc> per page. Multi-locale sites add
|
|
9
|
+
* <xhtml:link rel="alternate" hreflang="..." /> annotations per page (one
|
|
10
|
+
* per available locale plus an `x-default` pointing at the base-locale
|
|
11
|
+
* URL). The single-locale output stays byte-identical to pre-hreflang.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { isPathDraft, normalizeDraftPath } from './draftMode.js';
|
|
15
|
+
import { buildCanonicalUrl } from './canonicalUrl.js';
|
|
14
16
|
|
|
15
17
|
function escapeXml(value) {
|
|
16
18
|
return String(value).replace(/[<>&'"]/g, (c) => ({
|
|
@@ -22,7 +24,7 @@ function escapeXml(value) {
|
|
|
22
24
|
}[c]));
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function buildSitemap(siteConfig) {
|
|
27
|
+
export function buildSitemap(siteConfig, options = {}) {
|
|
26
28
|
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
27
29
|
if (!siteUrl) return '';
|
|
28
30
|
|
|
@@ -33,26 +35,61 @@ export function buildSitemap(siteConfig) {
|
|
|
33
35
|
// the runtime gate; it is not advertised to crawlers.
|
|
34
36
|
if (siteConfig?.site?.draft === true) return '';
|
|
35
37
|
|
|
38
|
+
const baseLocale = typeof options.baseLocale === 'string' ? options.baseLocale.toLowerCase() : '';
|
|
39
|
+
const availableLocales = Array.isArray(options.availableLocales)
|
|
40
|
+
? options.availableLocales.map((l) => (typeof l === 'string' ? l.toLowerCase() : '')).filter(Boolean)
|
|
41
|
+
: [];
|
|
42
|
+
const isMultiLocale = availableLocales.length > 1;
|
|
43
|
+
|
|
36
44
|
const pages = siteConfig?.pages || {};
|
|
37
|
-
const
|
|
45
|
+
const entries = [];
|
|
46
|
+
const seenPaths = new Set();
|
|
38
47
|
|
|
39
48
|
for (const [, pageData] of Object.entries(pages)) {
|
|
40
49
|
if (!pageData || typeof pageData !== 'object') continue;
|
|
41
|
-
const
|
|
42
|
-
if (isPathDraft(siteConfig,
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
const pagePath = normalizeDraftPath(pageData.path) || '/';
|
|
51
|
+
if (isPathDraft(siteConfig, pagePath, pageData)) continue;
|
|
52
|
+
if (seenPaths.has(pagePath)) continue;
|
|
53
|
+
seenPaths.add(pagePath);
|
|
54
|
+
|
|
55
|
+
const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath });
|
|
56
|
+
if (!baseUrl) continue;
|
|
57
|
+
|
|
58
|
+
const alternates = isMultiLocale
|
|
59
|
+
? availableLocales.map((loc) => ({
|
|
60
|
+
hreflang: loc,
|
|
61
|
+
href: buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath }),
|
|
62
|
+
}))
|
|
63
|
+
: [];
|
|
64
|
+
if (isMultiLocale) {
|
|
65
|
+
alternates.push({ hreflang: 'x-default', href: baseUrl });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
entries.push({ loc: baseUrl, alternates });
|
|
45
69
|
}
|
|
46
70
|
|
|
47
|
-
if (
|
|
71
|
+
if (entries.length === 0) return '';
|
|
72
|
+
|
|
73
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
74
|
+
|
|
75
|
+
const urlsetAttrs = isMultiLocale
|
|
76
|
+
? 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"'
|
|
77
|
+
: 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
78
|
+
|
|
79
|
+
const renderUrlEntry = (entry) => {
|
|
80
|
+
if (entry.alternates.length === 0) {
|
|
81
|
+
return ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
|
|
82
|
+
}
|
|
83
|
+
const altLines = entry.alternates
|
|
84
|
+
.map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}"/>`)
|
|
85
|
+
.join('\n');
|
|
86
|
+
return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n${altLines}\n </url>`;
|
|
87
|
+
};
|
|
48
88
|
|
|
49
|
-
const
|
|
50
|
-
const urlEntries = sortedUrls
|
|
51
|
-
.map((loc) => ` <url><loc>${escapeXml(loc)}</loc></url>`)
|
|
52
|
-
.join('\n');
|
|
89
|
+
const urlEntries = entries.map(renderUrlEntry).join('\n');
|
|
53
90
|
|
|
54
91
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
55
|
-
<urlset
|
|
92
|
+
<urlset ${urlsetAttrs}>
|
|
56
93
|
${urlEntries}
|
|
57
94
|
</urlset>
|
|
58
95
|
`;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build per-page Open Graph + Twitter Card meta entries.
|
|
3
|
+
*
|
|
4
|
+
* Returns an array of @unhead/vue meta descriptors ready to be spread into
|
|
5
|
+
* useHead's `meta:` array. Returns [] for drafts and 404 pages — drafts
|
|
6
|
+
* shouldn't be advertised socially, and 404 cards would mislead.
|
|
7
|
+
*
|
|
8
|
+
* Title and description are passed in pre-resolved (the caller — usually
|
|
9
|
+
* usePageMeta — already computes them with the site-wide fallback chain).
|
|
10
|
+
*
|
|
11
|
+
* og:image priority:
|
|
12
|
+
* 1. page.meta.image (per-page override; e.g. an article hero)
|
|
13
|
+
* 2. site.image / site.ogImage (site-wide override authored in site.json)
|
|
14
|
+
* 3. /og-image.jpg (the static asset cms-generate-public-assets writes)
|
|
15
|
+
*
|
|
16
|
+
* Relative image paths get an absolute URL built from site.url. If
|
|
17
|
+
* site.url is missing, the relative path is emitted as-is — most social
|
|
18
|
+
* scrapers won't follow it, but that's better than emitting nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const STATIC_OG_IMAGE = '/og-image.jpg';
|
|
22
|
+
|
|
23
|
+
function absolutize(siteUrl, pathOrUrl) {
|
|
24
|
+
if (!pathOrUrl) return '';
|
|
25
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
26
|
+
if (!siteUrl) return pathOrUrl;
|
|
27
|
+
const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
|
28
|
+
return `${siteUrl}${path}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildSocialMeta({
|
|
32
|
+
siteData,
|
|
33
|
+
currentPage,
|
|
34
|
+
pageTitle,
|
|
35
|
+
pageDescription,
|
|
36
|
+
canonicalHref,
|
|
37
|
+
isDraft,
|
|
38
|
+
isNotFound,
|
|
39
|
+
} = {}) {
|
|
40
|
+
if (isDraft || isNotFound) return [];
|
|
41
|
+
|
|
42
|
+
const site = (siteData && siteData.site) || {};
|
|
43
|
+
const page = currentPage || {};
|
|
44
|
+
const pageMeta = page.meta || {};
|
|
45
|
+
|
|
46
|
+
const siteUrl = (typeof site.url === 'string' ? site.url : '').trim().replace(/\/+$/, '');
|
|
47
|
+
const siteTitle = typeof site.title === 'string' ? site.title.trim() : '';
|
|
48
|
+
|
|
49
|
+
const rawImage =
|
|
50
|
+
pageMeta.image ||
|
|
51
|
+
site.image ||
|
|
52
|
+
site.ogImage ||
|
|
53
|
+
STATIC_OG_IMAGE;
|
|
54
|
+
const ogImage = absolutize(siteUrl, rawImage);
|
|
55
|
+
|
|
56
|
+
const ogType = pageMeta.ogType || 'website';
|
|
57
|
+
const twitterCard = pageMeta.twitterCard || 'summary_large_image';
|
|
58
|
+
|
|
59
|
+
const meta = [];
|
|
60
|
+
|
|
61
|
+
if (pageTitle) {
|
|
62
|
+
meta.push({ property: 'og:title', content: pageTitle, key: 'og:title' });
|
|
63
|
+
meta.push({ name: 'twitter:title', content: pageTitle, key: 'twitter:title' });
|
|
64
|
+
}
|
|
65
|
+
if (pageDescription) {
|
|
66
|
+
meta.push({ property: 'og:description', content: pageDescription, key: 'og:description' });
|
|
67
|
+
meta.push({ name: 'twitter:description', content: pageDescription, key: 'twitter:description' });
|
|
68
|
+
}
|
|
69
|
+
if (canonicalHref) {
|
|
70
|
+
meta.push({ property: 'og:url', content: canonicalHref, key: 'og:url' });
|
|
71
|
+
}
|
|
72
|
+
meta.push({ property: 'og:type', content: ogType, key: 'og:type' });
|
|
73
|
+
if (siteTitle) {
|
|
74
|
+
meta.push({ property: 'og:site_name', content: siteTitle, key: 'og:site_name' });
|
|
75
|
+
}
|
|
76
|
+
if (ogImage) {
|
|
77
|
+
meta.push({ property: 'og:image', content: ogImage, key: 'og:image' });
|
|
78
|
+
meta.push({ name: 'twitter:image', content: ogImage, key: 'twitter:image' });
|
|
79
|
+
}
|
|
80
|
+
meta.push({ name: 'twitter:card', content: twitterCard, key: 'twitter:card' });
|
|
81
|
+
|
|
82
|
+
return meta;
|
|
83
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -2,18 +2,14 @@
|
|
|
2
2
|
<html lang="">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<title><%= site %></title>
|
|
6
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<meta name="twitter:title" content="<%= site %>">
|
|
15
|
-
<meta name="twitter:description" content="<%= siteDescription %>">
|
|
16
|
-
<meta name="twitter:image" content="<%= ABSOLUTE_OG_IMAGE %>">
|
|
6
|
+
<!--
|
|
7
|
+
Per-page <title>, <meta name="description">, og:*, twitter:*, canonical,
|
|
8
|
+
and hreflang are emitted by usePageMeta at SSG render time. Site-wide
|
|
9
|
+
structured-data (the JSON-LD Organization snippet below) and the
|
|
10
|
+
static <link rel="icon"> remain in this template since they don't
|
|
11
|
+
vary per page.
|
|
12
|
+
-->
|
|
17
13
|
<link rel="icon" href="/favicon.ico">
|
|
18
14
|
<script type="application/ld+json">
|
|
19
15
|
{
|
package/vite-plugin.js
CHANGED
|
@@ -12,7 +12,6 @@ import { createRequire } from 'node:module';
|
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
13
|
import vue from '@vitejs/plugin-vue';
|
|
14
14
|
|
|
15
|
-
import { SUPPORTED_LOCALES } from './src/constants/locales.js';
|
|
16
15
|
import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
|
|
17
16
|
import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
|
|
18
17
|
import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
|
|
@@ -321,16 +320,22 @@ export default function cmsPlugin(options = {}) {
|
|
|
321
320
|
const {
|
|
322
321
|
siteDir: siteDirOption = './site',
|
|
323
322
|
frameworkRoot: frameworkRootOption = __dirname,
|
|
324
|
-
locales = SUPPORTED_LOCALES,
|
|
325
323
|
themes: themePackages = [],
|
|
326
324
|
extensions: extensionPackages = [],
|
|
327
325
|
} = options;
|
|
326
|
+
// The previous `locales` option is no longer accepted: per-site available
|
|
327
|
+
// locales are now discovered from on-disk content directories. Passing
|
|
328
|
+
// `locales` is silently ignored (kept here as a destructure to swallow it
|
|
329
|
+
// gracefully; the `SUPPORTED_LOCALES` constant remains exported only for
|
|
330
|
+
// any consumer that imports it directly).
|
|
328
331
|
|
|
329
332
|
// These are resolved relative to the project cwd at config time
|
|
330
333
|
let siteDir;
|
|
331
334
|
let siteRoot; // project root — parent of siteDir
|
|
332
335
|
let frameworkRoot;
|
|
333
336
|
let siteConfig;
|
|
337
|
+
let baseLocale = 'en';
|
|
338
|
+
let availableLocales = [];
|
|
334
339
|
let pagePaths;
|
|
335
340
|
let metadata;
|
|
336
341
|
let tempIndexPath;
|
|
@@ -399,7 +404,7 @@ export default function cmsPlugin(options = {}) {
|
|
|
399
404
|
const sitePublicDir = path.join(siteRoot, 'public');
|
|
400
405
|
fs.mkdirSync(sitePublicDir, { recursive: true });
|
|
401
406
|
|
|
402
|
-
const sitemapXml = buildSitemap(siteConfig);
|
|
407
|
+
const sitemapXml = buildSitemap(siteConfig, { availableLocales, baseLocale });
|
|
403
408
|
const sitemapPath = path.join(sitePublicDir, 'sitemap.xml');
|
|
404
409
|
if (sitemapXml) {
|
|
405
410
|
fs.writeFileSync(sitemapPath, sitemapXml, 'utf-8');
|
|
@@ -435,12 +440,24 @@ export default function cmsPlugin(options = {}) {
|
|
|
435
440
|
const contentConfig = fs.existsSync(contentConfigPath)
|
|
436
441
|
? JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'))
|
|
437
442
|
: {};
|
|
438
|
-
|
|
443
|
+
baseLocale = contentConfig.baseLocale || 'en';
|
|
439
444
|
const configDir = path.join(contentDir, baseLocale);
|
|
440
445
|
siteConfig = loadSplitConfig(configDir);
|
|
441
446
|
if (!siteConfig) {
|
|
442
447
|
throw new Error(`[@koehler8/cms] site config not found at ${configDir}`);
|
|
443
448
|
}
|
|
449
|
+
// Discover locales actually present on disk: directories under
|
|
450
|
+
// content/ that contain a site.json. This becomes the authoritative
|
|
451
|
+
// list for both the SSG route fanout and the runtime locale-aware
|
|
452
|
+
// canonical/hreflang emission.
|
|
453
|
+
availableLocales = fs.existsSync(contentDir)
|
|
454
|
+
? fs
|
|
455
|
+
.readdirSync(contentDir, { withFileTypes: true })
|
|
456
|
+
.filter((d) => d.isDirectory())
|
|
457
|
+
.map((d) => d.name)
|
|
458
|
+
.filter((name) => fs.existsSync(path.join(contentDir, name, 'site.json')))
|
|
459
|
+
.sort()
|
|
460
|
+
: [];
|
|
444
461
|
pagePaths = collectPagePaths(siteConfig);
|
|
445
462
|
metadata = extractSiteMetadata(siteConfig);
|
|
446
463
|
|
|
@@ -521,12 +538,40 @@ export default function cmsPlugin(options = {}) {
|
|
|
521
538
|
},
|
|
522
539
|
ssgOptions: {
|
|
523
540
|
dirStyle: 'nested',
|
|
541
|
+
// After vite-ssg pre-renders every route, copy the rendered
|
|
542
|
+
// dist/404/index.html to dist/404.html. AWS Amplify (and most
|
|
543
|
+
// static hosts) auto-serve a top-level 404.html for any
|
|
544
|
+
// unmatched URL with HTTP status 404, so this gives sites a
|
|
545
|
+
// proper not-found response without per-site config.
|
|
546
|
+
onFinished() {
|
|
547
|
+
const distDir = path.join(siteRoot, 'dist');
|
|
548
|
+
const nestedNotFound = path.join(distDir, '404', 'index.html');
|
|
549
|
+
const flatNotFound = path.join(distDir, '404.html');
|
|
550
|
+
if (fs.existsSync(nestedNotFound)) {
|
|
551
|
+
try {
|
|
552
|
+
fs.copyFileSync(nestedNotFound, flatNotFound);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.warn(`[@koehler8/cms] failed to write 404.html: ${err.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
},
|
|
524
558
|
includedRoutes(paths) {
|
|
525
|
-
|
|
559
|
+
// /admin is a vestigial placeholder; /404 is the not-found
|
|
560
|
+
// landing page (rendered via the catch-all + NotFound
|
|
561
|
+
// component). onFinished above copies dist/404/index.html
|
|
562
|
+
// to dist/404.html so AWS Amplify serves it for unmatched
|
|
563
|
+
// URLs with HTTP 404.
|
|
564
|
+
const staticRoutes = new Set(['/admin', '/404']);
|
|
526
565
|
pagePaths.forEach((routePath) => staticRoutes.add(routePath));
|
|
527
566
|
|
|
567
|
+
// Pre-render every page under each non-base locale prefix that
|
|
568
|
+
// actually has a content directory on disk. The base locale is
|
|
569
|
+
// served at the unprefixed path (it's already covered by
|
|
570
|
+
// staticRoutes) — emitting `/{baseLocale}/path` would create
|
|
571
|
+
// duplicate URLs that all render the same content.
|
|
572
|
+
const localePrefixes = availableLocales.filter((l) => l !== baseLocale);
|
|
528
573
|
const localizedRoutes = new Set();
|
|
529
|
-
|
|
574
|
+
localePrefixes.forEach((locale) => {
|
|
530
575
|
pagePaths.forEach((routePath) => {
|
|
531
576
|
const localized =
|
|
532
577
|
routePath === '/' ? `/${locale}` : `/${locale}${routePath}`;
|
|
@@ -620,7 +665,12 @@ const loader = createConfigLoader(allModules);
|
|
|
620
665
|
export const loadConfigData = loader.loadConfigData;
|
|
621
666
|
export const mergeConfigTrees = loader.mergeConfigTrees;
|
|
622
667
|
export const cloneConfig = loader.cloneConfig;
|
|
623
|
-
|
|
668
|
+
// availableLocales + baseLocale are emitted as build-time literals derived
|
|
669
|
+
// from the same on-disk discovery the plugin uses for SSG includedRoutes
|
|
670
|
+
// and sitemap generation. This guarantees the runtime sees the exact same
|
|
671
|
+
// locale list as the build, regardless of glob-based heuristics.
|
|
672
|
+
export const availableLocales = ${JSON.stringify(availableLocales)};
|
|
673
|
+
export const baseLocale = ${JSON.stringify(baseLocale)};
|
|
624
674
|
`;
|
|
625
675
|
}
|
|
626
676
|
|