@koehler8/cms 1.0.0-beta.18 → 1.0.0-beta.19

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.18",
3
+ "version": "1.0.0-beta.19",
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",
@@ -90,7 +90,7 @@ const {
90
90
  attemptUnlock,
91
91
  } = useDraftGate({ siteData, currentPage });
92
92
 
93
- usePageMeta({ siteData, currentPage });
93
+ usePageMeta({ siteData, currentPage, locale: () => props.locale });
94
94
 
95
95
  const {
96
96
  isComingSoonVisible,
@@ -1,6 +1,8 @@
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 { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
4
6
 
5
7
  const SPECIAL_PAGE_LABELS = {
6
8
  home: '',
@@ -36,9 +38,15 @@ function inferPageLabel(page) {
36
38
  .join(' / ');
37
39
  }
38
40
 
39
- export function usePageMeta({ siteData, currentPage }) {
41
+ export function usePageMeta({ siteData, currentPage, locale }) {
40
42
  const siteTitle = computed(() => siteData.value?.site?.title || '');
41
43
  const siteDescription = computed(() => siteData.value?.site?.description || '');
44
+ const siteUrl = computed(() => siteData.value?.site?.url || '');
45
+ const localeValue = computed(() => {
46
+ if (typeof locale === 'function') return locale() || '';
47
+ if (typeof locale === 'string') return locale;
48
+ return '';
49
+ });
42
50
 
43
51
  const isDraft = computed(() =>
44
52
  isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
@@ -76,6 +84,54 @@ export function usePageMeta({ siteData, currentPage }) {
76
84
  return siteDescription.value || '';
77
85
  });
78
86
 
87
+ // Canonical URL — base-locale URLs have no prefix; non-base locales get
88
+ // /{locale}/path. Drafts skip canonical (gated content shouldn't advertise
89
+ // an authoritative URL). Sites without a configured site.url skip too —
90
+ // we can't build an absolute URL.
91
+ const canonicalHref = computed(() => {
92
+ if (isDraft.value) return '';
93
+ if (!siteUrl.value) return '';
94
+ return buildCanonicalUrl({
95
+ siteUrl: siteUrl.value,
96
+ locale: localeValue.value,
97
+ baseLocale: configBaseLocale,
98
+ path: currentPage.value?.path || '/',
99
+ });
100
+ });
101
+
102
+ // hreflang alternates — only emitted when a site has more than one
103
+ // locale's content on disk. Includes one entry per available locale plus
104
+ // an `x-default` pointing at the base-locale URL (Google's preferred
105
+ // pattern for sites without explicit geo-targeting).
106
+ const hreflangLinks = computed(() => {
107
+ if (isDraft.value) return [];
108
+ if (!siteUrl.value) return [];
109
+ if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
110
+ const path = currentPage.value?.path || '/';
111
+ const links = [];
112
+ for (const code of configAvailableLocales) {
113
+ const href = buildCanonicalUrl({
114
+ siteUrl: siteUrl.value,
115
+ locale: code,
116
+ baseLocale: configBaseLocale,
117
+ path,
118
+ });
119
+ if (href) {
120
+ links.push({ rel: 'alternate', hreflang: code, href, key: `hreflang-${code}` });
121
+ }
122
+ }
123
+ const defaultHref = buildCanonicalUrl({
124
+ siteUrl: siteUrl.value,
125
+ locale: configBaseLocale,
126
+ baseLocale: configBaseLocale,
127
+ path,
128
+ });
129
+ if (defaultHref) {
130
+ links.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref, key: 'hreflang-x-default' });
131
+ }
132
+ return links;
133
+ });
134
+
79
135
  useHead(() => {
80
136
  const description = pageMetaDescription.value;
81
137
  const meta = [];
@@ -85,11 +141,19 @@ export function usePageMeta({ siteData, currentPage }) {
85
141
  if (isDraft.value) {
86
142
  meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
87
143
  }
144
+ const link = [];
145
+ if (canonicalHref.value) {
146
+ link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
147
+ }
148
+ for (const entry of hreflangLinks.value) {
149
+ link.push(entry);
150
+ }
88
151
  return {
89
152
  title: pageMetaTitle.value,
90
153
  meta,
154
+ link,
91
155
  };
92
156
  });
93
157
 
94
- return { pageMetaTitle, pageMetaDescription, isDraft };
158
+ return { pageMetaTitle, pageMetaDescription, isDraft, canonicalHref, hreflangLinks };
95
159
  }
@@ -3,7 +3,7 @@ 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
7
 
8
8
  const LocaleLayout = {
9
9
  name: 'LocaleLayout',
@@ -12,57 +12,85 @@ const LocaleLayout = {
12
12
  },
13
13
  };
14
14
 
15
- export const routes = [
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
- 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
- {
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 (!SUPPORTED_LOCALES.includes(normalized)) {
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 === '/' && SUPPORTED_LOCALES.includes(shortLang)) {
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
+ }
@@ -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
- * v1 emits only <loc> per page. No <lastmod>, <changefreq>, <priority>, or
9
- * hreflang alternates yet — a page with a path is listed once with its
10
- * unprefixed canonical URL. Multi-locale sites can layer hreflang in later.
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 urls = new Set();
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 path = normalizeDraftPath(pageData.path) || '/';
42
- if (isPathDraft(siteConfig, path, pageData)) continue;
43
- const url = path === '/' ? `${siteUrl}/` : `${siteUrl}${path}`;
44
- urls.add(url);
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 (urls.size === 0) return '';
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 sortedUrls = Array.from(urls).sort();
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 xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
92
+ <urlset ${urlsetAttrs}>
56
93
  ${urlEntries}
57
94
  </urlset>
58
95
  `;
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
- const baseLocale = contentConfig.baseLocale || 'en';
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
 
@@ -525,8 +542,14 @@ export default function cmsPlugin(options = {}) {
525
542
  const staticRoutes = new Set(['/admin']);
526
543
  pagePaths.forEach((routePath) => staticRoutes.add(routePath));
527
544
 
545
+ // Pre-render every page under each non-base locale prefix that
546
+ // actually has a content directory on disk. The base locale is
547
+ // served at the unprefixed path (it's already covered by
548
+ // staticRoutes) — emitting `/{baseLocale}/path` would create
549
+ // duplicate URLs that all render the same content.
550
+ const localePrefixes = availableLocales.filter((l) => l !== baseLocale);
528
551
  const localizedRoutes = new Set();
529
- locales.forEach((locale) => {
552
+ localePrefixes.forEach((locale) => {
530
553
  pagePaths.forEach((routePath) => {
531
554
  const localized =
532
555
  routePath === '/' ? `/${locale}` : `/${locale}${routePath}`;
@@ -620,7 +643,12 @@ const loader = createConfigLoader(allModules);
620
643
  export const loadConfigData = loader.loadConfigData;
621
644
  export const mergeConfigTrees = loader.mergeConfigTrees;
622
645
  export const cloneConfig = loader.cloneConfig;
623
- export const availableLocales = loader.availableLocales;
646
+ // availableLocales + baseLocale are emitted as build-time literals derived
647
+ // from the same on-disk discovery the plugin uses for SSG includedRoutes
648
+ // and sitemap generation. This guarantees the runtime sees the exact same
649
+ // locale list as the build, regardless of glob-based heuristics.
650
+ export const availableLocales = ${JSON.stringify(availableLocales)};
651
+ export const baseLocale = ${JSON.stringify(baseLocale)};
624
652
  `;
625
653
  }
626
654