@koehler8/cms 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/cms-generate-public-assets.js +27 -0
  4. package/bin/cms-validate-extensions.js +18 -0
  5. package/bin/cms-validate-themes.js +7 -0
  6. package/extensions/manifest.schema.json +125 -0
  7. package/package.json +88 -0
  8. package/public/img/preloaders/preloader-black.svg +1 -0
  9. package/public/img/preloaders/preloader-white.svg +1 -0
  10. package/public/robots.txt +5 -0
  11. package/scripts/check-overflow.mjs +33 -0
  12. package/scripts/generate-public-assets.js +412 -0
  13. package/scripts/migrate-to-content-dirs.mjs +223 -0
  14. package/scripts/patch-lru-cache-tla.js +164 -0
  15. package/scripts/validate-extensions.mjs +392 -0
  16. package/scripts/validate-themes.mjs +64 -0
  17. package/src/App.vue +3 -0
  18. package/src/components/About.vue +481 -0
  19. package/src/components/AboutValue.vue +361 -0
  20. package/src/components/BackToTop.vue +42 -0
  21. package/src/components/ComingSoon.vue +411 -0
  22. package/src/components/ComingSoonModal.vue +230 -0
  23. package/src/components/Contact.vue +518 -0
  24. package/src/components/Footer.vue +65 -0
  25. package/src/components/FooterMinimal.vue +153 -0
  26. package/src/components/Header.vue +583 -0
  27. package/src/components/Hero.vue +327 -0
  28. package/src/components/Home.vue +161 -0
  29. package/src/components/Intro.vue +130 -0
  30. package/src/components/IntroGate.vue +444 -0
  31. package/src/components/Plan.vue +116 -0
  32. package/src/components/Portfolio.vue +459 -0
  33. package/src/components/Preloader.vue +20 -0
  34. package/src/components/Principles.vue +67 -0
  35. package/src/components/Spacer15.vue +9 -0
  36. package/src/components/Spacer30.vue +9 -0
  37. package/src/components/Spacer40.vue +9 -0
  38. package/src/components/Spacer60.vue +9 -0
  39. package/src/components/StickyCTA.vue +263 -0
  40. package/src/components/Team.vue +432 -0
  41. package/src/components/icons/IconLinkedIn.vue +22 -0
  42. package/src/components/icons/IconX.vue +22 -0
  43. package/src/components/ui/SbCard.vue +52 -0
  44. package/src/components/ui/SkeletonPulse.vue +117 -0
  45. package/src/components/ui/UnitChip.vue +69 -0
  46. package/src/composables/useComingSoonConfig.js +120 -0
  47. package/src/composables/useComingSoonInterstitial.js +27 -0
  48. package/src/composables/useComponentResolver.js +235 -0
  49. package/src/composables/useEngagementTracking.js +187 -0
  50. package/src/composables/useIntroGate.js +46 -0
  51. package/src/composables/useLazyImage.js +77 -0
  52. package/src/composables/usePageConfig.js +184 -0
  53. package/src/composables/usePageMeta.js +76 -0
  54. package/src/composables/usePromoBackgroundStyles.js +67 -0
  55. package/src/constants/locales.js +20 -0
  56. package/src/extensions/extensionLoader.js +512 -0
  57. package/src/main.js +187 -0
  58. package/src/router/index.js +112 -0
  59. package/src/styles/base.css +896 -0
  60. package/src/styles/layout.css +342 -0
  61. package/src/styles/theme-base.css +84 -0
  62. package/src/themes/buildCssVarMap.js +246 -0
  63. package/src/themes/themeLoader.js +156 -0
  64. package/src/themes/themeManager.js +34 -0
  65. package/src/themes/themeValidator.js +380 -0
  66. package/src/utils/analytics.js +100 -0
  67. package/src/utils/appInfo.js +9 -0
  68. package/src/utils/assetResolver.js +162 -0
  69. package/src/utils/componentRegistry.js +146 -0
  70. package/src/utils/contentRequirements.js +67 -0
  71. package/src/utils/cookieConsent.js +281 -0
  72. package/src/utils/ctaCopy.js +58 -0
  73. package/src/utils/formatNumber.js +115 -0
  74. package/src/utils/imageSources.js +179 -0
  75. package/src/utils/inflateFlatConfig.js +30 -0
  76. package/src/utils/loadConfig.js +319 -0
  77. package/src/utils/semver.js +49 -0
  78. package/src/utils/siteStyles.js +40 -0
  79. package/src/utils/themeColors.js +65 -0
  80. package/src/utils/trackingContext.js +142 -0
  81. package/src/utils/unwrapDefault.js +14 -0
  82. package/src/utils/useScrollReveal.js +48 -0
  83. package/templates/index.html +36 -0
  84. package/themes/base/README.md +23 -0
  85. package/themes/base/theme.config.js +214 -0
  86. package/vite-plugin.js +726 -0
@@ -0,0 +1,58 @@
1
+ const camelToKebab = (value) =>
2
+ value.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`).toLowerCase();
3
+
4
+ const kebabToCamel = (value) =>
5
+ value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
6
+
7
+ function normalizeKey(type) {
8
+ if (!type) return '';
9
+ if (type.includes('-')) return type.toLowerCase();
10
+ const kebab = camelToKebab(type);
11
+ return kebab;
12
+ }
13
+
14
+ function getVariantConfig(siteData) {
15
+ if (!siteData || typeof siteData !== 'object') return null;
16
+ const root = siteData.site || siteData;
17
+ if (!root || typeof root !== 'object') return null;
18
+ const config = root.ctaCopy;
19
+ if (!config || typeof config !== 'object') return null;
20
+ return config;
21
+ }
22
+
23
+ function pickLabel(variant, key) {
24
+ if (!variant || typeof variant !== 'object') return '';
25
+ const kebabKey = normalizeKey(key);
26
+ const camelKey = kebabToCamel(kebabKey);
27
+ const direct = variant[key];
28
+ if (typeof direct === 'string' && direct.trim()) return direct.trim();
29
+ const camel = variant[camelKey];
30
+ if (typeof camel === 'string' && camel.trim()) return camel.trim();
31
+ const kebab = variant[kebabKey];
32
+ if (typeof kebab === 'string' && kebab.trim()) return kebab.trim();
33
+ return '';
34
+ }
35
+
36
+ export function resolveCtaCopy(siteDataRef, type, fallback) {
37
+ const rawSite = siteDataRef && typeof siteDataRef === 'object' && 'value' in siteDataRef
38
+ ? siteDataRef.value
39
+ : siteDataRef;
40
+
41
+ const config = getVariantConfig(rawSite);
42
+ if (!config) return fallback;
43
+
44
+ const { variants = {}, activeVariant, fallbackVariant } = config;
45
+
46
+ const normalizedActive = typeof activeVariant === 'string' ? activeVariant.trim().toLowerCase() : '';
47
+ const normalizedFallback = typeof fallbackVariant === 'string' ? fallbackVariant.trim().toLowerCase() : '';
48
+
49
+ const variant =
50
+ variants[normalizedActive] ||
51
+ variants[normalizedFallback] ||
52
+ variants.default ||
53
+ null;
54
+
55
+ const label = pickLabel(variant, type) || pickLabel(variants.default, type);
56
+
57
+ return label || fallback;
58
+ }
@@ -0,0 +1,115 @@
1
+ const DEFAULT_LOCALE = 'en-US';
2
+
3
+ const formatterCache = new Map();
4
+
5
+ function getFormatter(options) {
6
+ const normalizedOptions = Object.entries(options)
7
+ .filter(([, value]) => value !== undefined)
8
+ .sort(([a], [b]) => (a > b ? 1 : -1));
9
+ const key = JSON.stringify(normalizedOptions);
10
+ if (!formatterCache.has(key)) {
11
+ formatterCache.set(key, new Intl.NumberFormat(DEFAULT_LOCALE, Object.fromEntries(normalizedOptions)));
12
+ }
13
+ return formatterCache.get(key);
14
+ }
15
+
16
+ function coerceNumber(value) {
17
+ if (value === null || value === undefined) return 0;
18
+ if (typeof value === 'bigint') return Number(value);
19
+ const numeric = Number(value);
20
+ return Number.isFinite(numeric) ? numeric : 0;
21
+ }
22
+
23
+ export function formatDecimal(value, {
24
+ minimumFractionDigits = 0,
25
+ maximumFractionDigits = 2,
26
+ compact = false,
27
+ useGrouping = true,
28
+ signDisplay,
29
+ } = {}) {
30
+ const numeric = coerceNumber(value);
31
+ const formatter = getFormatter({
32
+ style: 'decimal',
33
+ notation: compact ? 'compact' : 'standard',
34
+ minimumFractionDigits,
35
+ maximumFractionDigits,
36
+ useGrouping,
37
+ signDisplay,
38
+ });
39
+ return formatter.format(numeric);
40
+ }
41
+
42
+ export function formatCurrency(value, {
43
+ currency = 'USD',
44
+ minimumFractionDigits = 2,
45
+ maximumFractionDigits = 2,
46
+ compact = false,
47
+ useGrouping = true,
48
+ } = {}) {
49
+ const numeric = coerceNumber(value);
50
+ const formatter = getFormatter({
51
+ style: 'currency',
52
+ currency,
53
+ notation: compact ? 'compact' : 'standard',
54
+ minimumFractionDigits,
55
+ maximumFractionDigits,
56
+ useGrouping,
57
+ });
58
+ return formatter.format(numeric);
59
+ }
60
+
61
+ export function formatCompact(value, {
62
+ maximumFractionDigits = 1,
63
+ minimumFractionDigits,
64
+ useGrouping = true,
65
+ } = {}) {
66
+ const numeric = coerceNumber(value);
67
+ const formatter = getFormatter({
68
+ style: 'decimal',
69
+ notation: 'compact',
70
+ minimumFractionDigits,
71
+ maximumFractionDigits,
72
+ useGrouping,
73
+ });
74
+ return formatter.format(numeric);
75
+ }
76
+
77
+ export function formatPercent(value, {
78
+ maximumFractionDigits = 1,
79
+ minimumFractionDigits = 0,
80
+ assumeFraction = false,
81
+ useGrouping = false,
82
+ } = {}) {
83
+ const numericInput = coerceNumber(value);
84
+ const scaled = assumeFraction ? numericInput : numericInput / 100;
85
+ const formatter = getFormatter({
86
+ style: 'percent',
87
+ minimumFractionDigits,
88
+ maximumFractionDigits,
89
+ useGrouping,
90
+ });
91
+ return formatter.format(scaled);
92
+ }
93
+
94
+ export function formatTokenAmount(value, {
95
+ minimumFractionDigits = 0,
96
+ maximumFractionDigits = 2,
97
+ compact = false,
98
+ } = {}) {
99
+ return formatDecimal(value, {
100
+ minimumFractionDigits,
101
+ maximumFractionDigits,
102
+ compact,
103
+ });
104
+ }
105
+
106
+ export function formatUsd(value, options = {}) {
107
+ return formatCurrency(value, { currency: 'USD', ...options });
108
+ }
109
+
110
+ export function formatWithFallback(value, fallback = '0', formatter = formatDecimal) {
111
+ if (value === null || value === undefined) return fallback;
112
+ const numeric = coerceNumber(value);
113
+ if (!Number.isFinite(numeric)) return fallback;
114
+ return formatter(numeric);
115
+ }
@@ -0,0 +1,179 @@
1
+ import { computed, ref, watch, unref } from 'vue';
2
+ import { resolveAsset } from './assetResolver.js';
3
+
4
+ const DEFAULT_FORMATS = ['avif', 'webp'];
5
+
6
+ function normalizeBasePath(relativePath = '') {
7
+ if (!relativePath || typeof relativePath !== 'string') {
8
+ return '';
9
+ }
10
+ const trimmed = relativePath.replace(/^\/+/, '');
11
+ return trimmed.replace(/\.[^/.]+$/, '');
12
+ }
13
+
14
+ function normalizeWidths(widths) {
15
+ const unique = new Set();
16
+ (Array.isArray(widths) ? widths : [])
17
+ .map((value) => Number.parseInt(value, 10))
18
+ .filter((value) => Number.isFinite(value) && value > 0)
19
+ .forEach((value) => unique.add(value));
20
+ return Array.from(unique).sort((a, b) => a - b);
21
+ }
22
+
23
+ function buildSrcSet(basePath, widthList, format) {
24
+ const entries = [];
25
+ for (const width of widthList) {
26
+ const candidate = `${basePath}-${width}.${format}`;
27
+ const url = resolveAsset(candidate);
28
+ if (url) {
29
+ entries.push(`${url} ${width}w`);
30
+ }
31
+ }
32
+
33
+ // Fallback to a non-suffixed asset when available.
34
+ if (entries.length === 0) {
35
+ const fallbackUrl = resolveAsset(`${basePath}.${format}`);
36
+ if (fallbackUrl) {
37
+ entries.push(`${fallbackUrl} 1x`);
38
+ }
39
+ }
40
+
41
+ return entries.join(', ');
42
+ }
43
+
44
+ function resolveBestFallback(basePath, widthList, format) {
45
+ const reversed = [...widthList].sort((a, b) => b - a);
46
+ for (const width of reversed) {
47
+ const candidate = resolveAsset(`${basePath}-${width}.${format}`);
48
+ if (candidate) {
49
+ return candidate;
50
+ }
51
+ }
52
+ return resolveAsset(`${basePath}.${format}`) || '';
53
+ }
54
+
55
+ /**
56
+ * Build responsive image sources for site media.
57
+ * @param {string} relativePath - Path relative to site asset directory (e.g. "img/promo").
58
+ * @param {object} [options]
59
+ * @param {number[]} [options.widths] - Explicit width breakpoints.
60
+ * @param {string} [options.fallbackFormat='jpg'] - Format for the <img> fallback.
61
+ * @param {string[]} [options.formats] - Additional <source> formats (default: avif + webp).
62
+ * @returns {{
63
+ * sources: import('vue').ComputedRef<Array<{ type: string, srcset: string }>>,
64
+ * fallbackSrc: import('vue').ComputedRef<string>,
65
+ * fallbackSrcSet: import('vue').ComputedRef<string>,
66
+ * placeholderSrc: import('vue').ComputedRef<string>
67
+ * }}
68
+ */
69
+ export function useResponsiveImage(relativePath, options = {}) {
70
+ const basePath = computed(() => normalizeBasePath(unref(relativePath)));
71
+ const widthInput = options.widths;
72
+ const widths = computed(() => normalizeWidths(unref(widthInput)));
73
+ const fallbackFormat = computed(() => unref(options.fallbackFormat) || 'jpg');
74
+ const formats = computed(() => {
75
+ const provided = unref(options.formats);
76
+ return Array.isArray(provided) && provided.length ? provided : DEFAULT_FORMATS;
77
+ });
78
+ const errorHandled = ref(false);
79
+
80
+ const sources = computed(() => {
81
+ const base = basePath.value;
82
+ if (!base) return [];
83
+
84
+ return formats.value
85
+ .map((format) => {
86
+ const srcset = buildSrcSet(base, widths.value, format);
87
+ if (!srcset) return null;
88
+ return {
89
+ type: `image/${format}`,
90
+ srcset,
91
+ };
92
+ })
93
+ .filter(Boolean);
94
+ });
95
+
96
+ const fallbackSrc = computed(() => {
97
+ const base = basePath.value;
98
+ if (!base) return '';
99
+ return resolveBestFallback(base, widths.value, fallbackFormat.value);
100
+ });
101
+
102
+ const fallbackSrcSet = computed(() => {
103
+ const base = basePath.value;
104
+ if (!base) return '';
105
+ return buildSrcSet(base, widths.value, fallbackFormat.value);
106
+ });
107
+
108
+ const placeholderSrc = computed(() => {
109
+ const base = basePath.value;
110
+ if (!base) return '';
111
+ const widthList = widths.value;
112
+ if (widthList.length === 0) {
113
+ return resolveAsset(`${base}.${fallbackFormat.value}`) || '';
114
+ }
115
+ const smallestWidth = widthList[0];
116
+ return (
117
+ resolveAsset(`${base}-${smallestWidth}.${fallbackFormat.value}`) ||
118
+ resolveAsset(`${base}-${smallestWidth}.webp`) ||
119
+ resolveAsset(`${base}.${fallbackFormat.value}`) ||
120
+ ''
121
+ );
122
+ });
123
+
124
+ watch([fallbackSrc, fallbackSrcSet], () => {
125
+ errorHandled.value = false;
126
+ });
127
+
128
+ const handleError = (event) => {
129
+ if (errorHandled.value) return;
130
+ const target = event?.target;
131
+ if (!target || target.tagName !== 'IMG') return;
132
+ const fallback = fallbackSrc.value;
133
+ if (!fallback) return;
134
+ errorHandled.value = true;
135
+
136
+ const picture = target.closest('picture');
137
+ if (picture) {
138
+ picture.querySelectorAll('source').forEach((source) => {
139
+ source.removeAttribute('srcset');
140
+ source.removeAttribute('sizes');
141
+ });
142
+ }
143
+
144
+ target.removeAttribute('srcset');
145
+ target.removeAttribute('sizes');
146
+ target.onerror = null;
147
+
148
+ let absoluteFallback = fallback;
149
+ if (typeof window !== 'undefined') {
150
+ try {
151
+ absoluteFallback = new URL(fallback, window.location.href).href;
152
+ } catch (error) {
153
+ // Relative URL fallback; ignore.
154
+ }
155
+ }
156
+
157
+ if (target.src === absoluteFallback) {
158
+ target.removeAttribute('src');
159
+ const schedule =
160
+ typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
161
+ ? window.requestAnimationFrame.bind(window)
162
+ : (fn) => setTimeout(fn, 0);
163
+ schedule(() => {
164
+ target.src = fallback;
165
+ });
166
+ } else {
167
+ target.src = fallback;
168
+ }
169
+ };
170
+
171
+ return {
172
+ sources,
173
+ fallbackSrc,
174
+ fallbackSrcSet,
175
+ placeholderSrc,
176
+ fallbackFormat,
177
+ handleError,
178
+ };
179
+ }
@@ -0,0 +1,30 @@
1
+ const ARRAY_INDEX_RE = /\[(\d+)]/g;
2
+
3
+ function setNestedPath(root, dotPath, value) {
4
+ const segments = dotPath.replace(ARRAY_INDEX_RE, '.$1').split('.');
5
+ let current = root;
6
+
7
+ for (let i = 0; i < segments.length - 1; i++) {
8
+ const seg = segments[i];
9
+ const nextSeg = segments[i + 1];
10
+ const nextIsIndex = /^\d+$/.test(nextSeg);
11
+
12
+ if (current[seg] === undefined || current[seg] === null) {
13
+ current[seg] = nextIsIndex ? [] : {};
14
+ }
15
+ current = current[seg];
16
+ }
17
+
18
+ current[segments[segments.length - 1]] = value;
19
+ }
20
+
21
+ export function inflateFlatConfig(flat) {
22
+ if (!flat || typeof flat !== 'object') return flat;
23
+
24
+ const result = {};
25
+ for (const [key, value] of Object.entries(flat)) {
26
+ if (key.startsWith('$')) continue;
27
+ setNestedPath(result, key, value);
28
+ }
29
+ return result;
30
+ }
@@ -0,0 +1,319 @@
1
+ import { inflateFlatConfig } from './inflateFlatConfig.js';
2
+ import { unwrapDefault } from './unwrapDefault.js';
3
+
4
+ function normalizeLocaleInput(value) {
5
+ if (value === undefined || value === null) {
6
+ return undefined;
7
+ }
8
+ if (Array.isArray(value)) {
9
+ for (const entry of value) {
10
+ const candidate = normalizeLocaleInput(entry);
11
+ if (candidate) {
12
+ return candidate;
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ if (typeof value !== 'string') {
18
+ return undefined;
19
+ }
20
+ const trimmed = value.trim();
21
+ return trimmed.length ? trimmed : undefined;
22
+ }
23
+
24
+ export function cloneConfig(value) {
25
+ if (Array.isArray(value)) {
26
+ return value.map(cloneConfig);
27
+ }
28
+ if (value && typeof value === 'object') {
29
+ return Object.entries(value).reduce((acc, [key, val]) => {
30
+ acc[key] = cloneConfig(val);
31
+ return acc;
32
+ }, {});
33
+ }
34
+ return value;
35
+ }
36
+
37
+ export function mergeConfigTrees(target, source, options = {}) {
38
+ const { cloneTarget = false, skipEmpty = true } = options;
39
+
40
+ const result =
41
+ cloneTarget && target && typeof target === 'object'
42
+ ? cloneConfig(target)
43
+ : Array.isArray(target)
44
+ ? [...(target || [])]
45
+ : target && typeof target === 'object'
46
+ ? { ...(target || {}) }
47
+ : target;
48
+
49
+ const isEmptyish = (value) => {
50
+ if (!skipEmpty) return false;
51
+ if (value === undefined || value === null) return true;
52
+ if (typeof value === 'string' && value.trim() === '') return true;
53
+ if (Array.isArray(value) && value.length === 0) return true;
54
+ if (typeof value === 'object' && Object.keys(value).length === 0) return true;
55
+ return false;
56
+ };
57
+
58
+ if (Array.isArray(result) && Array.isArray(source)) {
59
+ const maxLength = Math.max(result.length, source.length);
60
+ const output = new Array(maxLength);
61
+
62
+ for (let i = 0; i < maxLength; i++) {
63
+ const baseVal = result[i];
64
+ const srcVal = source[i];
65
+
66
+ if (srcVal === undefined || isEmptyish(srcVal)) {
67
+ output[i] = cloneConfig(baseVal);
68
+ continue;
69
+ }
70
+
71
+ if (baseVal === undefined) {
72
+ output[i] = cloneConfig(srcVal);
73
+ continue;
74
+ }
75
+
76
+ if (Array.isArray(baseVal) && Array.isArray(srcVal)) {
77
+ output[i] = mergeConfigTrees(baseVal, srcVal, { cloneTarget: false, skipEmpty });
78
+ continue;
79
+ }
80
+
81
+ if (
82
+ baseVal &&
83
+ srcVal &&
84
+ typeof baseVal === 'object' &&
85
+ typeof srcVal === 'object' &&
86
+ !Array.isArray(baseVal) &&
87
+ !Array.isArray(srcVal)
88
+ ) {
89
+ output[i] = mergeConfigTrees(baseVal, srcVal, { cloneTarget: false, skipEmpty });
90
+ continue;
91
+ }
92
+
93
+ output[i] = cloneConfig(srcVal);
94
+ }
95
+
96
+ return output;
97
+ }
98
+
99
+ if (Array.isArray(source) && !Array.isArray(result)) {
100
+ return skipEmpty && isEmptyish(source) ? result : [...source];
101
+ }
102
+
103
+ if (source && typeof source === 'object') {
104
+ const base = result && typeof result === 'object' && !Array.isArray(result) ? result : {};
105
+ const output = cloneTarget ? cloneConfig(base) : { ...base };
106
+
107
+ for (const [key, value] of Object.entries(source)) {
108
+ if (isEmptyish(value)) continue;
109
+
110
+ const existing = output[key];
111
+ if (Array.isArray(existing) && Array.isArray(value)) {
112
+ output[key] = mergeConfigTrees(existing, value, { cloneTarget: false, skipEmpty });
113
+ } else if (
114
+ existing &&
115
+ value &&
116
+ typeof existing === 'object' &&
117
+ typeof value === 'object' &&
118
+ !Array.isArray(existing) &&
119
+ !Array.isArray(value)
120
+ ) {
121
+ output[key] = mergeConfigTrees(existing, value, { cloneTarget: false, skipEmpty });
122
+ } else {
123
+ output[key] = Array.isArray(value) ? [...value] : cloneConfig(value);
124
+ }
125
+ }
126
+
127
+ return output;
128
+ }
129
+
130
+ return source;
131
+ }
132
+
133
+ export function createConfigLoader(allModules) {
134
+ const toPlain = unwrapDefault;
135
+
136
+ // Discover which locale directories exist in the content folder by
137
+ // extracting unique locale codes from the allModules glob keys.
138
+ function discoverAvailableLocales() {
139
+ const locales = new Set();
140
+ const re = /\/content\/([a-z]{2,3}(?:-[a-zA-Z]{2,4})?)\//;
141
+ for (const key of Object.keys(allModules)) {
142
+ const match = key.match(re);
143
+ if (match) locales.add(match[1].toLowerCase());
144
+ }
145
+ return Array.from(locales).sort();
146
+ }
147
+
148
+ const availableLocales = discoverAvailableLocales();
149
+
150
+ // Find the content root (path ending in `/content/`) and resolve the base
151
+ // locale from content.config.json.
152
+ function findContentRoot() {
153
+ const keys = Object.keys(allModules);
154
+ const configKey = keys.find((key) => key.endsWith('/content/content.config.json'));
155
+ if (configKey) {
156
+ return configKey.slice(0, configKey.length - 'content.config.json'.length);
157
+ }
158
+ return null;
159
+ }
160
+
161
+ async function resolveBaseLocale(contentRoot) {
162
+ const configLoader = allModules[`${contentRoot}content.config.json`];
163
+ if (!configLoader) return 'en';
164
+ const config = toPlain(await configLoader());
165
+ return (config && config.baseLocale) || 'en';
166
+ }
167
+
168
+ // Assemble a config from a locale directory: inflates flat-format files
169
+ // (site.json, shared.json, pages/*.json) and returns { site, shared, pages }.
170
+ // When require is true, returns null if site.json is missing (used for
171
+ // base config). When false, assembles whatever files exist (used for
172
+ // locale overrides that may only contain partial translations).
173
+ async function assembleSplitConfig(localePrefix, { require: requireSite = false } = {}) {
174
+ const siteLoader = allModules[`${localePrefix}site.json`];
175
+ if (!siteLoader && requireSite) return null;
176
+
177
+ const sharedLoader = allModules[`${localePrefix}shared.json`];
178
+ const [rawSite, rawShared] = await Promise.all([
179
+ siteLoader ? siteLoader().then(toPlain) : Promise.resolve({}),
180
+ sharedLoader ? sharedLoader().then(toPlain) : Promise.resolve({}),
181
+ ]);
182
+ const siteData = inflateFlatConfig(rawSite);
183
+ const sharedData = inflateFlatConfig(rawShared);
184
+
185
+ const pagePrefix = `${localePrefix}pages/`;
186
+ const pages = {};
187
+ const pageLoads = [];
188
+ for (const [path, loader] of Object.entries(allModules)) {
189
+ if (path.startsWith(pagePrefix) && path.endsWith('.json')) {
190
+ const pageId = path.slice(pagePrefix.length, -5);
191
+ pageLoads.push(
192
+ loader().then((mod) => { pages[pageId] = inflateFlatConfig(toPlain(mod)); })
193
+ );
194
+ }
195
+ }
196
+ await Promise.all(pageLoads);
197
+
198
+ // Return null if no files were found at all for this locale
199
+ if (!siteLoader && !sharedLoader && Object.keys(pages).length === 0) return null;
200
+
201
+ return { site: siteData, shared: sharedData, pages };
202
+ }
203
+
204
+ async function assembleBaseConfig() {
205
+ const contentRoot = findContentRoot();
206
+ if (!contentRoot) return null;
207
+
208
+ const baseLocale = await resolveBaseLocale(contentRoot);
209
+ const basePrefix = `${contentRoot}${baseLocale}/`;
210
+ const config = await assembleSplitConfig(basePrefix, { require: true });
211
+ if (!config) return null;
212
+
213
+ return { ...config, _contentRoot: contentRoot, _baseLocale: baseLocale };
214
+ }
215
+
216
+ async function loadConfigData(options) {
217
+ let explicitLocale;
218
+
219
+ if (typeof options === 'string' || Array.isArray(options)) {
220
+ explicitLocale = normalizeLocaleInput(options);
221
+ } else if (options && typeof options === 'object') {
222
+ explicitLocale = normalizeLocaleInput(options.locale);
223
+ }
224
+
225
+ let locale = explicitLocale;
226
+
227
+ if (!locale && typeof localStorage !== 'undefined') {
228
+ try {
229
+ locale = normalizeLocaleInput(localStorage.getItem('locale')) || undefined;
230
+ } catch {
231
+ locale = undefined;
232
+ }
233
+ }
234
+
235
+ const normalizedLocale = typeof locale === 'string' ? locale.toLowerCase() : undefined;
236
+
237
+ const getBaseConfig = async () => {
238
+ const assembled = await assembleBaseConfig();
239
+ if (assembled) return assembled;
240
+ throw new Error('Config file not found');
241
+ };
242
+
243
+ if (normalizedLocale === undefined) {
244
+ const config = await getBaseConfig();
245
+ delete config._contentRoot;
246
+ delete config._baseLocale;
247
+ return config;
248
+ }
249
+
250
+ const baseConfig = await getBaseConfig();
251
+
252
+ let mergedConfig = baseConfig;
253
+ const contentRoot = baseConfig._contentRoot;
254
+ const baseLocale = baseConfig._baseLocale;
255
+
256
+ // Only load locale overrides if the requested locale differs from the base
257
+ if (contentRoot && normalizedLocale !== baseLocale) {
258
+ const localePrefix = `${contentRoot}${normalizedLocale}/`;
259
+ const localeConfig = await assembleSplitConfig(localePrefix);
260
+ if (localeConfig) {
261
+ mergedConfig = mergeConfigTrees(baseConfig, localeConfig, { cloneTarget: true, skipEmpty: true });
262
+ }
263
+ }
264
+ if (mergedConfig && mergedConfig.pages && typeof mergedConfig.pages === 'object') {
265
+ for (const page of Object.values(mergedConfig.pages)) {
266
+ if (!page || typeof page !== 'object') continue;
267
+ if (!Array.isArray(page.components)) {
268
+ page.components = [];
269
+ continue;
270
+ }
271
+ page.components = page.components.map((entry) => {
272
+ if (!entry || typeof entry !== 'object') {
273
+ return entry;
274
+ }
275
+ const normalized = { ...entry };
276
+ if (typeof normalized.enabled !== 'boolean') {
277
+ normalized.enabled = true;
278
+ }
279
+ if (normalized.name && typeof normalized.name === 'string') {
280
+ normalized.name = normalized.name.trim();
281
+ }
282
+ if (normalized.source && typeof normalized.source === 'string') {
283
+ normalized.source = normalized.source.trim();
284
+ }
285
+ if (normalized.configKey && typeof normalized.configKey === 'string') {
286
+ normalized.configKey = normalized.configKey.trim();
287
+ }
288
+ return normalized;
289
+ });
290
+ }
291
+ }
292
+ delete mergedConfig._contentRoot;
293
+ delete mergedConfig._baseLocale;
294
+ return mergedConfig;
295
+ }
296
+
297
+ return { loadConfigData, mergeConfigTrees, cloneConfig, availableLocales };
298
+ }
299
+
300
+ // ---- Runtime singleton ----
301
+ // Components/composables import `loadConfigData` directly. The Vite plugin's
302
+ // generated entry calls `setConfigLoader()` with the configured instance at
303
+ // startup.
304
+
305
+ let _loadConfigData = async () => {
306
+ throw new Error('[@koehler8/cms] loadConfigData() called before config loader was initialized');
307
+ };
308
+
309
+ let _availableLocales = [];
310
+
311
+ export function setConfigLoader(instance) {
312
+ if (!instance) return;
313
+ _loadConfigData = instance.loadConfigData;
314
+ _availableLocales = instance.availableLocales || [];
315
+ }
316
+
317
+ export { _availableLocales as availableLocales };
318
+
319
+ export const loadConfigData = (...args) => _loadConfigData(...args);