@koehler8/cms 1.0.0-beta.5

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -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 +84 -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 +401 -0
  13. package/scripts/patch-lru-cache-tla.js +164 -0
  14. package/scripts/validate-extensions.mjs +392 -0
  15. package/scripts/validate-themes.mjs +64 -0
  16. package/src/App.vue +3 -0
  17. package/src/components/About.vue +481 -0
  18. package/src/components/AboutValue.vue +361 -0
  19. package/src/components/BackToTop.vue +42 -0
  20. package/src/components/ComingSoon.vue +411 -0
  21. package/src/components/ComingSoonModal.vue +230 -0
  22. package/src/components/Contact.vue +518 -0
  23. package/src/components/Footer.vue +65 -0
  24. package/src/components/FooterMinimal.vue +153 -0
  25. package/src/components/Header.vue +583 -0
  26. package/src/components/Hero.vue +327 -0
  27. package/src/components/Home.vue +144 -0
  28. package/src/components/Intro.vue +130 -0
  29. package/src/components/IntroGate.vue +444 -0
  30. package/src/components/Plan.vue +116 -0
  31. package/src/components/Portfolio.vue +459 -0
  32. package/src/components/Preloader.vue +20 -0
  33. package/src/components/Principles.vue +67 -0
  34. package/src/components/Spacer15.vue +9 -0
  35. package/src/components/Spacer30.vue +9 -0
  36. package/src/components/Spacer40.vue +9 -0
  37. package/src/components/Spacer60.vue +9 -0
  38. package/src/components/StickyCTA.vue +263 -0
  39. package/src/components/Team.vue +432 -0
  40. package/src/components/icons/IconLinkedIn.vue +22 -0
  41. package/src/components/icons/IconX.vue +22 -0
  42. package/src/components/ui/SbCard.vue +52 -0
  43. package/src/components/ui/SkeletonPulse.vue +117 -0
  44. package/src/components/ui/UnitChip.vue +69 -0
  45. package/src/composables/useComingSoonConfig.js +120 -0
  46. package/src/composables/useComingSoonInterstitial.js +27 -0
  47. package/src/composables/useComponentResolver.js +196 -0
  48. package/src/composables/useEngagementTracking.js +187 -0
  49. package/src/composables/useIntroGate.js +46 -0
  50. package/src/composables/useLazyImage.js +77 -0
  51. package/src/composables/usePageConfig.js +184 -0
  52. package/src/composables/usePageMeta.js +76 -0
  53. package/src/composables/usePromoBackgroundStyles.js +67 -0
  54. package/src/constants/locales.js +20 -0
  55. package/src/extensions/extensionLoader.js +512 -0
  56. package/src/main.js +175 -0
  57. package/src/router/index.js +112 -0
  58. package/src/styles/base.css +896 -0
  59. package/src/styles/layout.css +342 -0
  60. package/src/styles/theme-base.css +84 -0
  61. package/src/themes/themeLoader.js +124 -0
  62. package/src/themes/themeManager.js +257 -0
  63. package/src/themes/themeValidator.js +380 -0
  64. package/src/utils/analytics.js +100 -0
  65. package/src/utils/appInfo.js +9 -0
  66. package/src/utils/assetResolver.js +162 -0
  67. package/src/utils/componentRegistry.js +46 -0
  68. package/src/utils/contentRequirements.js +67 -0
  69. package/src/utils/cookieConsent.js +281 -0
  70. package/src/utils/ctaCopy.js +58 -0
  71. package/src/utils/formatNumber.js +115 -0
  72. package/src/utils/imageSources.js +179 -0
  73. package/src/utils/inflateFlatConfig.js +30 -0
  74. package/src/utils/loadConfig.js +271 -0
  75. package/src/utils/semver.js +49 -0
  76. package/src/utils/siteStyles.js +40 -0
  77. package/src/utils/themeColors.js +65 -0
  78. package/src/utils/trackingContext.js +142 -0
  79. package/src/utils/unwrapDefault.js +14 -0
  80. package/src/utils/useScrollReveal.js +48 -0
  81. package/templates/index.html +36 -0
  82. package/themes/base/README.md +23 -0
  83. package/themes/base/theme.config.js +214 -0
  84. package/vite-plugin.js +637 -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,271 @@
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
+ // Find the site.json path and derive the config prefix (everything up to
137
+ // and including `/config/`) so subsequent lookups are scoped correctly.
138
+ function findSiteConfigPrefix() {
139
+ const keys = Object.keys(allModules);
140
+ const siteJsonKeys = keys.filter((key) => key.endsWith('/config/site.json'));
141
+ if (siteJsonKeys.length === 1) {
142
+ const key = siteJsonKeys[0];
143
+ return key.slice(0, key.length - 'site.json'.length);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ async function assembleBaseConfig() {
149
+ const prefix = findSiteConfigPrefix();
150
+ if (!prefix) return null;
151
+
152
+ const siteLoader = allModules[`${prefix}site.json`];
153
+ if (!siteLoader) return null;
154
+
155
+ const sharedLoader = allModules[`${prefix}shared.json`];
156
+ const [siteData, sharedData] = await Promise.all([
157
+ siteLoader().then(toPlain),
158
+ sharedLoader ? sharedLoader().then(toPlain) : Promise.resolve({}),
159
+ ]);
160
+
161
+ // Collect only the pages that belong to this site's config directory
162
+ const pagePrefix = `${prefix}pages/`;
163
+ const pages = {};
164
+ const pageLoads = [];
165
+ for (const [path, loader] of Object.entries(allModules)) {
166
+ if (path.startsWith(pagePrefix) && path.endsWith('.json')) {
167
+ const pageId = path.slice(pagePrefix.length, -5);
168
+ pageLoads.push(loader().then((mod) => { pages[pageId] = toPlain(mod); }));
169
+ }
170
+ }
171
+ await Promise.all(pageLoads);
172
+
173
+ return { site: siteData, shared: sharedData, pages, _configPrefix: prefix };
174
+ }
175
+
176
+ async function loadConfigData(options) {
177
+ let explicitLocale;
178
+
179
+ if (typeof options === 'string' || Array.isArray(options)) {
180
+ explicitLocale = normalizeLocaleInput(options);
181
+ } else if (options && typeof options === 'object') {
182
+ explicitLocale = normalizeLocaleInput(options.locale);
183
+ }
184
+
185
+ let locale = explicitLocale;
186
+
187
+ if (!locale && typeof localStorage !== 'undefined') {
188
+ try {
189
+ locale = normalizeLocaleInput(localStorage.getItem('locale')) || undefined;
190
+ } catch {
191
+ locale = undefined;
192
+ }
193
+ }
194
+
195
+ const normalizedLocale = typeof locale === 'string' ? locale.toLowerCase() : undefined;
196
+
197
+ const getBaseConfig = async () => {
198
+ const assembled = await assembleBaseConfig();
199
+ if (assembled) return assembled;
200
+ throw new Error('Config file not found');
201
+ };
202
+
203
+ if (normalizedLocale === undefined) {
204
+ const config = await getBaseConfig();
205
+ delete config._configPrefix;
206
+ return config;
207
+ }
208
+
209
+ const baseConfig = await getBaseConfig();
210
+
211
+ let mergedConfig = baseConfig;
212
+ // Find locale override scoped to the same config directory as the site
213
+ const configPrefix = baseConfig._configPrefix;
214
+ const localizedLoader = configPrefix
215
+ ? allModules[`${configPrefix}i18n/${normalizedLocale}.json`]
216
+ : undefined;
217
+ if (localizedLoader) {
218
+ const rawLocalized = toPlain(await localizedLoader());
219
+ const localizedConfig = inflateFlatConfig(rawLocalized);
220
+ mergedConfig = mergeConfigTrees(baseConfig, localizedConfig, { cloneTarget: true, skipEmpty: true });
221
+ }
222
+ if (mergedConfig && mergedConfig.pages && typeof mergedConfig.pages === 'object') {
223
+ for (const page of Object.values(mergedConfig.pages)) {
224
+ if (!page || typeof page !== 'object') continue;
225
+ if (!Array.isArray(page.components)) {
226
+ page.components = [];
227
+ continue;
228
+ }
229
+ page.components = page.components.map((entry) => {
230
+ if (!entry || typeof entry !== 'object') {
231
+ return entry;
232
+ }
233
+ const normalized = { ...entry };
234
+ if (typeof normalized.enabled !== 'boolean') {
235
+ normalized.enabled = true;
236
+ }
237
+ if (normalized.name && typeof normalized.name === 'string') {
238
+ normalized.name = normalized.name.trim();
239
+ }
240
+ if (normalized.source && typeof normalized.source === 'string') {
241
+ normalized.source = normalized.source.trim();
242
+ }
243
+ if (normalized.configKey && typeof normalized.configKey === 'string') {
244
+ normalized.configKey = normalized.configKey.trim();
245
+ }
246
+ return normalized;
247
+ });
248
+ }
249
+ }
250
+ delete mergedConfig._configPrefix;
251
+ return mergedConfig;
252
+ }
253
+
254
+ return { loadConfigData, mergeConfigTrees, cloneConfig };
255
+ }
256
+
257
+ // ---- Runtime singleton ----
258
+ // Components/composables import `loadConfigData` directly. The Vite plugin's
259
+ // generated entry calls `setConfigLoader()` with the configured instance at
260
+ // startup.
261
+
262
+ let _loadConfigData = async () => {
263
+ throw new Error('[@koehler8/cms] loadConfigData() called before config loader was initialized');
264
+ };
265
+
266
+ export function setConfigLoader(instance) {
267
+ if (!instance) return;
268
+ _loadConfigData = instance.loadConfigData;
269
+ }
270
+
271
+ export const loadConfigData = (...args) => _loadConfigData(...args);
@@ -0,0 +1,49 @@
1
+ function toParts(version) {
2
+ if (!version || typeof version !== 'string') {
3
+ return {
4
+ parts: [0, 0, 0],
5
+ prerelease: '',
6
+ };
7
+ }
8
+ const trimmed = version.trim();
9
+ const [core = '', prerelease = ''] = trimmed.split('-', 2);
10
+ const parts = core
11
+ .split('.')
12
+ .map((segment) => Number.parseInt(segment, 10))
13
+ .filter(Number.isFinite);
14
+ while (parts.length < 3) {
15
+ parts.push(0);
16
+ }
17
+ return {
18
+ parts: parts.slice(0, 3),
19
+ prerelease: prerelease || '',
20
+ };
21
+ }
22
+
23
+ export function compareVersions(aVersion = '', bVersion = '') {
24
+ const a = toParts(aVersion);
25
+ const b = toParts(bVersion);
26
+
27
+ for (let i = 0; i < 3; i += 1) {
28
+ if (a.parts[i] > b.parts[i]) return 1;
29
+ if (a.parts[i] < b.parts[i]) return -1;
30
+ }
31
+
32
+ if (a.prerelease === b.prerelease) {
33
+ return 0;
34
+ }
35
+
36
+ if (a.prerelease && !b.prerelease) {
37
+ return -1;
38
+ }
39
+ if (!a.prerelease && b.prerelease) {
40
+ return 1;
41
+ }
42
+
43
+ return a.prerelease.localeCompare(b.prerelease);
44
+ }
45
+
46
+ export function satisfiesMinVersion(current, required) {
47
+ if (!required) return true;
48
+ return compareVersions(current, required) >= 0;
49
+ }