@koehler8/cms 1.0.0-beta.32 → 1.0.0-beta.34

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.34",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -130,12 +130,26 @@ const isHeaderCompact = ref(false);
130
130
  const currentLocale = ref('');
131
131
  const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
132
132
 
133
- // The base locale is served at the unprefixed root path (`/`) — clicking
134
- // the dropdown item for it should NOT navigate to `/{baseLocale}` because
135
- // that URL doesn't pre-render and would 404. Build the link path
136
- // accordingly: base → `/`, others → `/{locale}`.
133
+ // Switching language keeps the visitor on the SAME page rather than bouncing
134
+ // them to the locale's home. Slugs are shared across locales, so take the
135
+ // current path, strip any leading non-base locale segment, and re-prefix with
136
+ // the target locale. The base locale is served unprefixed (`/path`); others at
137
+ // `/{locale}/path`. (`/{baseLocale}/path` doesn't pre-render and would 404,
138
+ // hence the base stays unprefixed.)
137
139
  const localeHref = (locale) => {
138
- return locale === siteBaseLocale ? '/' : `/${locale}`;
140
+ const segments = (route.path || '/').split('/').filter(Boolean);
141
+ if (segments.length) {
142
+ const first = segments[0].toLowerCase();
143
+ if (first !== siteBaseLocale && availableLocales.value.includes(first)) {
144
+ segments.shift();
145
+ }
146
+ }
147
+ const rest = segments.join('/');
148
+ const slash = injectedSiteData.value?.site?.trailingSlash === true ? '/' : '';
149
+ if (locale === siteBaseLocale) {
150
+ return rest ? `/${rest}${slash}` : '/';
151
+ }
152
+ return rest ? `/${locale}/${rest}${slash}` : `/${locale}${slash}`;
139
153
  };
140
154
  const localeLabels = {
141
155
  en: 'English',
@@ -56,6 +56,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
56
56
  const siteTitle = computed(() => siteData.value?.site?.title || '');
57
57
  const siteDescription = computed(() => siteData.value?.site?.description || '');
58
58
  const siteUrl = computed(() => siteData.value?.site?.url || '');
59
+ const trailingSlash = computed(() => siteData.value?.site?.trailingSlash === true);
59
60
  const localeValue = computed(() => {
60
61
  if (typeof locale === 'function') return locale() || '';
61
62
  if (typeof locale === 'string') return locale;
@@ -116,6 +117,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
116
117
  locale: localeValue.value,
117
118
  baseLocale: configBaseLocale,
118
119
  path: currentPage.value?.path || '/',
120
+ trailingSlash: trailingSlash.value,
119
121
  });
120
122
  });
121
123
 
@@ -135,6 +137,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
135
137
  locale: code,
136
138
  baseLocale: configBaseLocale,
137
139
  path,
140
+ trailingSlash: trailingSlash.value,
138
141
  });
139
142
  if (href) {
140
143
  links.push({ rel: 'alternate', hreflang: code, href, key: `hreflang-${code}` });
@@ -145,6 +148,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
145
148
  locale: configBaseLocale,
146
149
  baseLocale: configBaseLocale,
147
150
  path,
151
+ trailingSlash: trailingSlash.value,
148
152
  });
149
153
  if (defaultHref) {
150
154
  links.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref, key: 'hreflang-x-default' });
@@ -216,6 +220,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
216
220
  pages: siteData.value?.pages,
217
221
  baseLocale: configBaseLocale,
218
222
  locale: localeValue.value,
223
+ trailingSlash: trailingSlash.value,
219
224
  });
220
225
  if (breadcrumb && !isDraft.value && !isNotFound.value) {
221
226
  script.push({
@@ -226,6 +231,11 @@ export function usePageMeta({ siteData, currentPage, locale }) {
226
231
  }
227
232
 
228
233
  return {
234
+ // Reflect the page's locale on <html lang> during SSR/prerender so the
235
+ // on-disk HTML for /{locale}/... ships the right language (the router
236
+ // guard only sets this client-side, after hydration). Base-locale pages
237
+ // fall back to the configured base locale.
238
+ htmlAttrs: { lang: localeValue.value || configBaseLocale || 'en' },
229
239
  title: pageMetaTitle.value,
230
240
  meta,
231
241
  link,
@@ -63,6 +63,7 @@ export function buildBreadcrumbList({
63
63
  pages,
64
64
  baseLocale,
65
65
  locale,
66
+ trailingSlash = false,
66
67
  } = {}) {
67
68
  if (!currentPage || typeof currentPage !== 'object') return null;
68
69
  if (currentPage.isNotFound) return null;
@@ -83,6 +84,7 @@ export function buildBreadcrumbList({
83
84
  locale,
84
85
  baseLocale,
85
86
  path: '/',
87
+ trailingSlash,
86
88
  });
87
89
 
88
90
  const items = [
@@ -101,6 +103,7 @@ export function buildBreadcrumbList({
101
103
  locale,
102
104
  baseLocale,
103
105
  path: prefixPath,
106
+ trailingSlash,
104
107
  });
105
108
  items.push({
106
109
  '@type': 'ListItem',
@@ -9,6 +9,14 @@
9
9
  * - Base locale → ${siteUrl}${path} e.g. https://example.com/about
10
10
  * - Non-base → ${siteUrl}/${locale}${path} e.g. https://example.com/de/about
11
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
+ *
12
20
  * Returns '' when siteUrl is missing — sitemaps require absolute URLs and
13
21
  * canonical links without a host are useless. Callers treat '' as "skip".
14
22
  */
@@ -28,17 +36,18 @@ function normalizeLocale(value) {
28
36
  return trimmed;
29
37
  }
30
38
 
31
- export function buildCanonicalUrl({ siteUrl, locale, baseLocale, path } = {}) {
39
+ export function buildCanonicalUrl({ siteUrl, locale, baseLocale, path, trailingSlash = false } = {}) {
32
40
  const host = typeof siteUrl === 'string' ? siteUrl.trim().replace(/\/+$/, '') : '';
33
41
  if (!host) return '';
34
42
 
35
43
  const normalizedPath = normalizePath(path);
36
44
  const loc = normalizeLocale(locale);
37
45
  const base = normalizeLocale(baseLocale);
46
+ const slash = trailingSlash ? '/' : '';
38
47
 
39
48
  if (!loc || loc === base) {
40
- return normalizedPath === '/' ? `${host}/` : `${host}${normalizedPath}`;
49
+ return normalizedPath === '/' ? `${host}/` : `${host}${normalizedPath}${slash}`;
41
50
  }
42
51
 
43
- return normalizedPath === '/' ? `${host}/${loc}` : `${host}/${loc}${normalizedPath}`;
52
+ return normalizedPath === '/' ? `${host}/${loc}${slash}` : `${host}/${loc}${normalizedPath}${slash}`;
44
53
  }
@@ -27,6 +27,7 @@ function escapeXml(value) {
27
27
  export function buildSitemap(siteConfig, options = {}) {
28
28
  const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
29
29
  if (!siteUrl) return '';
30
+ const trailingSlash = siteConfig?.site?.trailingSlash === true;
30
31
 
31
32
  // Whole-site draft → no sitemap. robots.txt emits "Disallow: /" in this
32
33
  // state, and listing URLs in the sitemap that the same robots blocks
@@ -52,13 +53,13 @@ export function buildSitemap(siteConfig, options = {}) {
52
53
  if (seenPaths.has(pagePath)) continue;
53
54
  seenPaths.add(pagePath);
54
55
 
55
- const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath });
56
+ const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath, trailingSlash });
56
57
  if (!baseUrl) continue;
57
58
 
58
59
  const alternates = isMultiLocale
59
60
  ? availableLocales.map((loc) => ({
60
61
  hreflang: loc,
61
- href: buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath }),
62
+ href: buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath, trailingSlash }),
62
63
  }))
63
64
  : [];
64
65
  if (isMultiLocale) {