@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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/cms-generate-public-assets.js +27 -0
- package/bin/cms-validate-extensions.js +18 -0
- package/bin/cms-validate-themes.js +7 -0
- package/extensions/manifest.schema.json +125 -0
- package/package.json +84 -0
- package/public/img/preloaders/preloader-black.svg +1 -0
- package/public/img/preloaders/preloader-white.svg +1 -0
- package/public/robots.txt +5 -0
- package/scripts/check-overflow.mjs +33 -0
- package/scripts/generate-public-assets.js +401 -0
- package/scripts/patch-lru-cache-tla.js +164 -0
- package/scripts/validate-extensions.mjs +392 -0
- package/scripts/validate-themes.mjs +64 -0
- package/src/App.vue +3 -0
- package/src/components/About.vue +481 -0
- package/src/components/AboutValue.vue +361 -0
- package/src/components/BackToTop.vue +42 -0
- package/src/components/ComingSoon.vue +411 -0
- package/src/components/ComingSoonModal.vue +230 -0
- package/src/components/Contact.vue +518 -0
- package/src/components/Footer.vue +65 -0
- package/src/components/FooterMinimal.vue +153 -0
- package/src/components/Header.vue +583 -0
- package/src/components/Hero.vue +327 -0
- package/src/components/Home.vue +144 -0
- package/src/components/Intro.vue +130 -0
- package/src/components/IntroGate.vue +444 -0
- package/src/components/Plan.vue +116 -0
- package/src/components/Portfolio.vue +459 -0
- package/src/components/Preloader.vue +20 -0
- package/src/components/Principles.vue +67 -0
- package/src/components/Spacer15.vue +9 -0
- package/src/components/Spacer30.vue +9 -0
- package/src/components/Spacer40.vue +9 -0
- package/src/components/Spacer60.vue +9 -0
- package/src/components/StickyCTA.vue +263 -0
- package/src/components/Team.vue +432 -0
- package/src/components/icons/IconLinkedIn.vue +22 -0
- package/src/components/icons/IconX.vue +22 -0
- package/src/components/ui/SbCard.vue +52 -0
- package/src/components/ui/SkeletonPulse.vue +117 -0
- package/src/components/ui/UnitChip.vue +69 -0
- package/src/composables/useComingSoonConfig.js +120 -0
- package/src/composables/useComingSoonInterstitial.js +27 -0
- package/src/composables/useComponentResolver.js +196 -0
- package/src/composables/useEngagementTracking.js +187 -0
- package/src/composables/useIntroGate.js +46 -0
- package/src/composables/useLazyImage.js +77 -0
- package/src/composables/usePageConfig.js +184 -0
- package/src/composables/usePageMeta.js +76 -0
- package/src/composables/usePromoBackgroundStyles.js +67 -0
- package/src/constants/locales.js +20 -0
- package/src/extensions/extensionLoader.js +512 -0
- package/src/main.js +175 -0
- package/src/router/index.js +112 -0
- package/src/styles/base.css +896 -0
- package/src/styles/layout.css +342 -0
- package/src/styles/theme-base.css +84 -0
- package/src/themes/themeLoader.js +124 -0
- package/src/themes/themeManager.js +257 -0
- package/src/themes/themeValidator.js +380 -0
- package/src/utils/analytics.js +100 -0
- package/src/utils/appInfo.js +9 -0
- package/src/utils/assetResolver.js +162 -0
- package/src/utils/componentRegistry.js +46 -0
- package/src/utils/contentRequirements.js +67 -0
- package/src/utils/cookieConsent.js +281 -0
- package/src/utils/ctaCopy.js +58 -0
- package/src/utils/formatNumber.js +115 -0
- package/src/utils/imageSources.js +179 -0
- package/src/utils/inflateFlatConfig.js +30 -0
- package/src/utils/loadConfig.js +271 -0
- package/src/utils/semver.js +49 -0
- package/src/utils/siteStyles.js +40 -0
- package/src/utils/themeColors.js +65 -0
- package/src/utils/trackingContext.js +142 -0
- package/src/utils/unwrapDefault.js +14 -0
- package/src/utils/useScrollReveal.js +48 -0
- package/templates/index.html +36 -0
- package/themes/base/README.md +23 -0
- package/themes/base/theme.config.js +214 -0
- package/vite-plugin.js +637 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { onMounted, onBeforeUnmount } from 'vue';
|
|
2
|
+
import { trackEvent } from '../utils/analytics.js';
|
|
3
|
+
|
|
4
|
+
export function useEngagementTracking(options = {}) {
|
|
5
|
+
const isClient = typeof window !== 'undefined' && !import.meta.env.SSR;
|
|
6
|
+
if (!isClient) {
|
|
7
|
+
return {
|
|
8
|
+
refreshVisibilityTargets: () => {},
|
|
9
|
+
resetEngagementTracking: () => {},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const scrollMilestones = Array.isArray(options.scrollMilestones)
|
|
14
|
+
? options.scrollMilestones.slice().sort((a, b) => a - b)
|
|
15
|
+
: [25, 50, 75, 90, 100];
|
|
16
|
+
const sectionSelector = options.sectionSelector || '[data-analytics-section]';
|
|
17
|
+
const sectionThreshold = typeof options.sectionThreshold === 'number'
|
|
18
|
+
? Math.min(Math.max(options.sectionThreshold, 0), 1)
|
|
19
|
+
: 0.5;
|
|
20
|
+
const getContext = typeof options.getContext === 'function'
|
|
21
|
+
? options.getContext
|
|
22
|
+
: () => ({});
|
|
23
|
+
|
|
24
|
+
let firedScrollMilestones = new Set();
|
|
25
|
+
let highestScrollPercent = 0;
|
|
26
|
+
let scrollListenerAttached = false;
|
|
27
|
+
let ticking = false;
|
|
28
|
+
let observer = null;
|
|
29
|
+
let observerReady = false;
|
|
30
|
+
let isMounted = false;
|
|
31
|
+
const seenSections = new Set();
|
|
32
|
+
|
|
33
|
+
function buildPayload(extra = {}) {
|
|
34
|
+
return {
|
|
35
|
+
page_path: window.location?.pathname || '',
|
|
36
|
+
...getContext(),
|
|
37
|
+
...extra,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function evaluateScrollDepth() {
|
|
42
|
+
const doc = document.documentElement;
|
|
43
|
+
const body = document.body;
|
|
44
|
+
const scrollTop = window.scrollY || doc.scrollTop || body.scrollTop || 0;
|
|
45
|
+
const viewportHeight = window.innerHeight || doc.clientHeight || body.clientHeight || 0;
|
|
46
|
+
const docHeight = Math.max(
|
|
47
|
+
body.scrollHeight,
|
|
48
|
+
doc.scrollHeight,
|
|
49
|
+
body.offsetHeight,
|
|
50
|
+
doc.offsetHeight,
|
|
51
|
+
body.clientHeight,
|
|
52
|
+
doc.clientHeight
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (!docHeight) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pixelsViewed = Math.min(scrollTop + viewportHeight, docHeight);
|
|
60
|
+
const percentViewed = Math.round((pixelsViewed / docHeight) * 100);
|
|
61
|
+
|
|
62
|
+
if (percentViewed <= highestScrollPercent) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
highestScrollPercent = percentViewed;
|
|
67
|
+
|
|
68
|
+
for (const milestone of scrollMilestones) {
|
|
69
|
+
if (!firedScrollMilestones.has(milestone) && percentViewed >= milestone) {
|
|
70
|
+
firedScrollMilestones.add(milestone);
|
|
71
|
+
trackEvent('engagement_scroll_depth', buildPayload({ depth_percent: milestone }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleScroll() {
|
|
77
|
+
if (ticking) return;
|
|
78
|
+
ticking = true;
|
|
79
|
+
window.requestAnimationFrame(() => {
|
|
80
|
+
ticking = false;
|
|
81
|
+
evaluateScrollDepth();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function initScrollTracking() {
|
|
86
|
+
if (scrollListenerAttached) return;
|
|
87
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
88
|
+
window.addEventListener('resize', handleScroll, { passive: true });
|
|
89
|
+
scrollListenerAttached = true;
|
|
90
|
+
evaluateScrollDepth();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stopScrollTracking() {
|
|
94
|
+
if (!scrollListenerAttached) return;
|
|
95
|
+
window.removeEventListener('scroll', handleScroll);
|
|
96
|
+
window.removeEventListener('resize', handleScroll);
|
|
97
|
+
scrollListenerAttached = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getSectionName(element, index) {
|
|
101
|
+
return (
|
|
102
|
+
element.dataset.analyticsSection ||
|
|
103
|
+
element.id ||
|
|
104
|
+
element.getAttribute('aria-label') ||
|
|
105
|
+
`section_${index}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleVisibilityEntries(entries) {
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (!entry.isIntersecting) continue;
|
|
112
|
+
if (sectionThreshold && typeof entry.intersectionRatio === 'number' && entry.intersectionRatio < sectionThreshold) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const name = getSectionName(entry.target, 0);
|
|
116
|
+
if (seenSections.has(name)) continue;
|
|
117
|
+
seenSections.add(name);
|
|
118
|
+
trackEvent('engagement_section_visible', buildPayload({ section: name }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function observeSections() {
|
|
123
|
+
if (!isMounted) return;
|
|
124
|
+
const elements = Array.from(document.querySelectorAll(sectionSelector));
|
|
125
|
+
|
|
126
|
+
if (!elements.length) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!('IntersectionObserver' in window)) {
|
|
131
|
+
elements.forEach((el, index) => {
|
|
132
|
+
const name = getSectionName(el, index);
|
|
133
|
+
if (seenSections.has(name)) return;
|
|
134
|
+
seenSections.add(name);
|
|
135
|
+
trackEvent('engagement_section_visible', buildPayload({ section: name, observer: 'fallback' }));
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!observerReady) {
|
|
141
|
+
observer = new IntersectionObserver(handleVisibilityEntries, {
|
|
142
|
+
threshold: [sectionThreshold || 0],
|
|
143
|
+
rootMargin: '0px 0px -10% 0px',
|
|
144
|
+
});
|
|
145
|
+
observerReady = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
observer.disconnect();
|
|
149
|
+
elements.forEach((el) => observer.observe(el));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function refreshVisibilityTargets() {
|
|
153
|
+
if (!isMounted) return;
|
|
154
|
+
observeSections();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resetEngagementTracking() {
|
|
158
|
+
firedScrollMilestones = new Set();
|
|
159
|
+
highestScrollPercent = 0;
|
|
160
|
+
seenSections.clear();
|
|
161
|
+
observeSections();
|
|
162
|
+
evaluateScrollDepth();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onMounted(() => {
|
|
166
|
+
isMounted = true;
|
|
167
|
+
initScrollTracking();
|
|
168
|
+
observeSections();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
onBeforeUnmount(() => {
|
|
172
|
+
isMounted = false;
|
|
173
|
+
stopScrollTracking();
|
|
174
|
+
if (observer && observerReady) {
|
|
175
|
+
observer.disconnect();
|
|
176
|
+
observer = null;
|
|
177
|
+
observerReady = false;
|
|
178
|
+
}
|
|
179
|
+
firedScrollMilestones.clear();
|
|
180
|
+
seenSections.clear();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
refreshVisibilityTargets,
|
|
185
|
+
resetEngagementTracking,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
|
|
3
|
+
const ALLOWED_KEYS = [
|
|
4
|
+
'eyebrow',
|
|
5
|
+
'title',
|
|
6
|
+
'body',
|
|
7
|
+
'primaryLabel',
|
|
8
|
+
'secondaryLabel',
|
|
9
|
+
'secondaryHref',
|
|
10
|
+
'storageKey',
|
|
11
|
+
'closeAriaLabel',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function useIntroGate({ siteData, pageContent }) {
|
|
15
|
+
const introGateSettings = computed(() => {
|
|
16
|
+
const result = {};
|
|
17
|
+
const sources = [
|
|
18
|
+
siteData.value?.shared?.content?.introGate,
|
|
19
|
+
pageContent.value?.introGate,
|
|
20
|
+
];
|
|
21
|
+
for (const src of sources) {
|
|
22
|
+
if (!src || typeof src !== 'object') continue;
|
|
23
|
+
for (const [key, value] of Object.entries(src)) {
|
|
24
|
+
if (value !== undefined) {
|
|
25
|
+
result[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const introGateEnabled = computed(() => Boolean(introGateSettings.value.enabled));
|
|
33
|
+
|
|
34
|
+
const introGateProps = computed(() => {
|
|
35
|
+
const config = introGateSettings.value;
|
|
36
|
+
if (!config || typeof config !== 'object') return {};
|
|
37
|
+
return ALLOWED_KEYS.reduce((acc, key) => {
|
|
38
|
+
if (config[key] !== undefined) {
|
|
39
|
+
acc[key] = config[key];
|
|
40
|
+
}
|
|
41
|
+
return acc;
|
|
42
|
+
}, {});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return { introGateEnabled, introGateProps };
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight IntersectionObserver helper for deferring media loading.
|
|
5
|
+
* @param {object} [options]
|
|
6
|
+
* @param {boolean} [options.enabled=true] - Disable to render immediately.
|
|
7
|
+
* @param {string} [options.rootMargin='200px 0px'] - IntersectionObserver rootMargin.
|
|
8
|
+
* @param {number|number[]} [options.threshold=0.01] - IntersectionObserver threshold.
|
|
9
|
+
* @returns {{ isVisible: import('vue').Ref<boolean>, targetRef: import('vue').Ref<HTMLElement | null> }}
|
|
10
|
+
*/
|
|
11
|
+
export function useLazyImage(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
enabled = true,
|
|
14
|
+
rootMargin = '200px 0px',
|
|
15
|
+
threshold = 0.01,
|
|
16
|
+
} = options;
|
|
17
|
+
|
|
18
|
+
const isClient = typeof window !== 'undefined';
|
|
19
|
+
const isVisible = ref(!enabled || !isClient);
|
|
20
|
+
const targetRef = ref(null);
|
|
21
|
+
let observer = null;
|
|
22
|
+
let didResolve = !enabled || !isClient;
|
|
23
|
+
|
|
24
|
+
const resolveVisibility = () => {
|
|
25
|
+
if (didResolve) return;
|
|
26
|
+
isVisible.value = true;
|
|
27
|
+
didResolve = true;
|
|
28
|
+
if (observer) {
|
|
29
|
+
observer.disconnect();
|
|
30
|
+
observer = null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
onMounted(() => {
|
|
35
|
+
if (!enabled || !isClient) {
|
|
36
|
+
resolveVisibility();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = targetRef.value;
|
|
41
|
+
if (!target) {
|
|
42
|
+
resolveVisibility();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!('IntersectionObserver' in window)) {
|
|
47
|
+
resolveVisibility();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
observer = new IntersectionObserver(
|
|
52
|
+
(entries) => {
|
|
53
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
54
|
+
resolveVisibility();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
rootMargin,
|
|
59
|
+
threshold,
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
observer.observe(target);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
onBeforeUnmount(() => {
|
|
67
|
+
if (observer) {
|
|
68
|
+
observer.disconnect();
|
|
69
|
+
observer = null;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
isVisible,
|
|
75
|
+
targetRef,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { onServerPrefetch, ref, watch } from 'vue';
|
|
2
|
+
import { loadConfigData, mergeConfigTrees } from '../utils/loadConfig.js';
|
|
3
|
+
|
|
4
|
+
function normalizePath(value) {
|
|
5
|
+
if (!value || typeof value !== 'string') return '/';
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (!trimmed) return '/';
|
|
8
|
+
let normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
9
|
+
normalized = normalized.replace(/\/+/g, '/');
|
|
10
|
+
normalized = normalized.replace(/\/+$/, '');
|
|
11
|
+
return normalized || '/';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mergeWithSharedContent(sharedContent, pageSpecificContent) {
|
|
15
|
+
const base = sharedContent && typeof sharedContent === 'object' ? sharedContent : {};
|
|
16
|
+
const overrides = pageSpecificContent && typeof pageSpecificContent === 'object' ? pageSpecificContent : {};
|
|
17
|
+
return mergeConfigTrees(base, overrides, { cloneTarget: true, skipEmpty: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
21
|
+
const siteData = ref(null);
|
|
22
|
+
const pageContent = ref({});
|
|
23
|
+
const currentPage = ref({
|
|
24
|
+
id: '',
|
|
25
|
+
path: '/',
|
|
26
|
+
components: [],
|
|
27
|
+
content: {},
|
|
28
|
+
meta: {},
|
|
29
|
+
});
|
|
30
|
+
const componentKeys = ref([]);
|
|
31
|
+
const isLoading = ref(false);
|
|
32
|
+
const loadError = ref(null);
|
|
33
|
+
|
|
34
|
+
let cachedConfig = null;
|
|
35
|
+
let lastLocaleKey = null;
|
|
36
|
+
let activeRequest = 0;
|
|
37
|
+
|
|
38
|
+
function selectPage(config) {
|
|
39
|
+
const pages = config?.pages || {};
|
|
40
|
+
const entries = Object.entries(pages);
|
|
41
|
+
|
|
42
|
+
const resolveById = (id) => {
|
|
43
|
+
const data = pages[id];
|
|
44
|
+
if (!data) return null;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
path: data.path ?? '/',
|
|
48
|
+
components: Array.isArray(data.components) ? [...data.components] : [],
|
|
49
|
+
content: data.content || {},
|
|
50
|
+
meta: data.meta || {},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const currentPageId = typeof pageId === 'function' ? pageId() : pageId;
|
|
55
|
+
const currentPagePath = typeof pagePath === 'function' ? pagePath() : pagePath;
|
|
56
|
+
|
|
57
|
+
if (currentPageId) {
|
|
58
|
+
const direct = resolveById(currentPageId);
|
|
59
|
+
if (direct) return direct;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (currentPagePath) {
|
|
63
|
+
const requestedPath = normalizePath(currentPagePath);
|
|
64
|
+
for (const [id, data] of entries) {
|
|
65
|
+
if (normalizePath(data.path) === requestedPath) {
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
path: data.path ?? '/',
|
|
69
|
+
components: Array.isArray(data.components) ? [...data.components] : [],
|
|
70
|
+
content: data.content || {},
|
|
71
|
+
meta: data.meta || {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const homeFallback = resolveById('home');
|
|
78
|
+
if (homeFallback) return homeFallback;
|
|
79
|
+
|
|
80
|
+
if (entries.length > 0) {
|
|
81
|
+
const [firstId, firstData] = entries[0];
|
|
82
|
+
return {
|
|
83
|
+
id: firstId,
|
|
84
|
+
path: firstData.path ?? '/',
|
|
85
|
+
components: Array.isArray(firstData.components) ? [...firstData.components] : [],
|
|
86
|
+
content: firstData.content || {},
|
|
87
|
+
meta: firstData.meta || {},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function syncPage() {
|
|
95
|
+
const requestId = ++activeRequest;
|
|
96
|
+
const currentLocale = typeof locale === 'function' ? locale() : locale;
|
|
97
|
+
const localeKey = (currentLocale || '').toString().toLowerCase() || 'default';
|
|
98
|
+
const shouldReload = !cachedConfig || lastLocaleKey !== localeKey;
|
|
99
|
+
const localeForConfig = localeKey === 'default' ? undefined : localeKey;
|
|
100
|
+
|
|
101
|
+
if (requestId === activeRequest) {
|
|
102
|
+
isLoading.value = true;
|
|
103
|
+
if (shouldReload) {
|
|
104
|
+
componentKeys.value = [];
|
|
105
|
+
}
|
|
106
|
+
loadError.value = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (shouldReload) {
|
|
111
|
+
cachedConfig = await loadConfigData({ locale: localeForConfig });
|
|
112
|
+
lastLocaleKey = localeKey;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (requestId !== activeRequest) return;
|
|
116
|
+
|
|
117
|
+
siteData.value = cachedConfig;
|
|
118
|
+
const selectedPage = selectPage(cachedConfig);
|
|
119
|
+
|
|
120
|
+
if (!selectedPage) {
|
|
121
|
+
throw new Error('No pages defined in configuration.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sharedContent = cachedConfig?.shared?.content;
|
|
125
|
+
const mergedContent = mergeWithSharedContent(sharedContent, selectedPage.content);
|
|
126
|
+
currentPage.value = {
|
|
127
|
+
...selectedPage,
|
|
128
|
+
content: mergedContent,
|
|
129
|
+
};
|
|
130
|
+
pageContent.value = mergedContent;
|
|
131
|
+
|
|
132
|
+
if (typeof onPageLoaded === 'function') {
|
|
133
|
+
onPageLoaded({ config: cachedConfig, page: selectedPage, content: mergedContent });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
componentKeys.value = Array.isArray(selectedPage.components)
|
|
137
|
+
? selectedPage.components.map((entry) => (entry && typeof entry === 'object' ? { ...entry } : entry))
|
|
138
|
+
: [];
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (requestId !== activeRequest) return;
|
|
141
|
+
if (import.meta.env.DEV) {
|
|
142
|
+
console.error('[PageRenderer] Failed to load page configuration', error);
|
|
143
|
+
}
|
|
144
|
+
currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {} };
|
|
145
|
+
pageContent.value = {};
|
|
146
|
+
if (typeof onPageLoaded === 'function') {
|
|
147
|
+
onPageLoaded({ config: null, page: null, content: {} });
|
|
148
|
+
}
|
|
149
|
+
componentKeys.value = [];
|
|
150
|
+
loadError.value = error instanceof Error ? error : new Error(String(error));
|
|
151
|
+
} finally {
|
|
152
|
+
if (requestId === activeRequest) {
|
|
153
|
+
isLoading.value = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (import.meta.env.SSR) {
|
|
159
|
+
onServerPrefetch(async () => {
|
|
160
|
+
await syncPage();
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
syncPage();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const getPageId = typeof pageId === 'function' ? pageId : () => pageId;
|
|
167
|
+
const getPagePath = typeof pagePath === 'function' ? pagePath : () => pagePath;
|
|
168
|
+
const getLocale = typeof locale === 'function' ? locale : () => locale;
|
|
169
|
+
|
|
170
|
+
watch(
|
|
171
|
+
() => [getPageId(), getPagePath(), getLocale()],
|
|
172
|
+
() => { syncPage(); },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
siteData,
|
|
177
|
+
pageContent,
|
|
178
|
+
currentPage,
|
|
179
|
+
componentKeys,
|
|
180
|
+
isLoading,
|
|
181
|
+
loadError,
|
|
182
|
+
syncPage,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
import { useHead } from '@unhead/vue';
|
|
3
|
+
|
|
4
|
+
const SPECIAL_PAGE_LABELS = {
|
|
5
|
+
home: '',
|
|
6
|
+
terms: 'Terms of Service',
|
|
7
|
+
privacy: 'Privacy Policy',
|
|
8
|
+
cookies: 'Cookie Policy',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function inferPageLabel(page) {
|
|
12
|
+
if (!page) return '';
|
|
13
|
+
const id = (page.id || '').toLowerCase();
|
|
14
|
+
if (id && SPECIAL_PAGE_LABELS[id] !== undefined) {
|
|
15
|
+
return SPECIAL_PAGE_LABELS[id];
|
|
16
|
+
}
|
|
17
|
+
const path = (page.path || '').toLowerCase();
|
|
18
|
+
if (path === '/' || path === '') {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
for (const [key, label] of Object.entries(SPECIAL_PAGE_LABELS)) {
|
|
22
|
+
if (key === 'home') continue;
|
|
23
|
+
if (path === `/${key}`) {
|
|
24
|
+
return label;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const segments = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
28
|
+
if (!segments.length) return '';
|
|
29
|
+
return segments
|
|
30
|
+
.map((segment) =>
|
|
31
|
+
segment
|
|
32
|
+
.replace(/[-_]+/g, ' ')
|
|
33
|
+
.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
34
|
+
)
|
|
35
|
+
.join(' / ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function usePageMeta({ siteData, currentPage }) {
|
|
39
|
+
const siteTitle = computed(() => siteData.value?.site?.title || '');
|
|
40
|
+
const siteDescription = computed(() => siteData.value?.site?.description || '');
|
|
41
|
+
|
|
42
|
+
const pageMetaTitle = computed(() => {
|
|
43
|
+
const page = currentPage.value;
|
|
44
|
+
const site = siteTitle.value;
|
|
45
|
+
const explicitTitle = page?.meta?.title;
|
|
46
|
+
if (explicitTitle) {
|
|
47
|
+
return explicitTitle;
|
|
48
|
+
}
|
|
49
|
+
const label = inferPageLabel(page);
|
|
50
|
+
if (!label) {
|
|
51
|
+
return site;
|
|
52
|
+
}
|
|
53
|
+
return site ? `${label} — ${site}` : label;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const pageMetaDescription = computed(() => {
|
|
57
|
+
const explicitDescription = currentPage.value?.meta?.description;
|
|
58
|
+
if (explicitDescription && explicitDescription.trim()) {
|
|
59
|
+
return explicitDescription.trim();
|
|
60
|
+
}
|
|
61
|
+
return siteDescription.value || '';
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
useHead(() => {
|
|
65
|
+
const description = pageMetaDescription.value;
|
|
66
|
+
const meta = description
|
|
67
|
+
? [{ name: 'description', content: description, key: 'description' }]
|
|
68
|
+
: [];
|
|
69
|
+
return {
|
|
70
|
+
title: pageMetaTitle.value,
|
|
71
|
+
meta,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { pageMetaTitle, pageMetaDescription };
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { computed, unref } from 'vue';
|
|
2
|
+
|
|
3
|
+
function selectLargestEntry(srcset = '') {
|
|
4
|
+
if (!srcset) return '';
|
|
5
|
+
const candidates = srcset
|
|
6
|
+
.split(',')
|
|
7
|
+
.map((entry) => entry.trim())
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((entry) => {
|
|
10
|
+
const [url, descriptor] = entry.split(/\s+/);
|
|
11
|
+
let score = 0;
|
|
12
|
+
if (descriptor?.endsWith('w')) {
|
|
13
|
+
score = Number.parseInt(descriptor.replace(/\D+/g, ''), 10) || 0;
|
|
14
|
+
} else if (descriptor?.endsWith('x')) {
|
|
15
|
+
score = Number.parseFloat(descriptor.replace(/[^\d.]/g, '')) * 1000;
|
|
16
|
+
}
|
|
17
|
+
return { url, score };
|
|
18
|
+
})
|
|
19
|
+
.filter(({ url }) => Boolean(url))
|
|
20
|
+
.sort((a, b) => b.score - a.score);
|
|
21
|
+
|
|
22
|
+
return candidates.length ? candidates[0].url : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a CSS background-image declaration that respects the neon backdrop toggle
|
|
27
|
+
* and reuses the responsive image metadata returned by `useResponsiveImage`.
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {{
|
|
30
|
+
* sources: import('vue').MaybeRefOrGetter<Array<{ type: string, srcset: string }>>,
|
|
31
|
+
* fallbackSrc: import('vue').MaybeRefOrGetter<string>,
|
|
32
|
+
* fallbackFormat: import('vue').MaybeRefOrGetter<string>
|
|
33
|
+
* }} options.imageSet
|
|
34
|
+
*/
|
|
35
|
+
export function usePromoBackgroundStyles({ imageSet }) {
|
|
36
|
+
return computed(() => {
|
|
37
|
+
const fallbackSrc = unref(imageSet?.fallbackSrc) || '';
|
|
38
|
+
const fallbackFormat = (unref(imageSet?.fallbackFormat) || 'jpg').toLowerCase();
|
|
39
|
+
const sources = unref(imageSet?.sources) || [];
|
|
40
|
+
|
|
41
|
+
const formatEntries = [];
|
|
42
|
+
|
|
43
|
+
sources.forEach((source) => {
|
|
44
|
+
const largest = selectLargestEntry(source?.srcset);
|
|
45
|
+
if (largest) {
|
|
46
|
+
formatEntries.push(`url("${largest}") type("${source?.type}") 1x`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (fallbackSrc) {
|
|
51
|
+
const fallbackMime = fallbackFormat === 'jpg' ? 'jpeg' : fallbackFormat;
|
|
52
|
+
formatEntries.push(`url("${fallbackSrc}") type("image/${fallbackMime}") 1x`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cssImageSet = formatEntries.length ? `image-set(${formatEntries.join(', ')})` : '';
|
|
56
|
+
let style = '';
|
|
57
|
+
|
|
58
|
+
if (fallbackSrc) {
|
|
59
|
+
style += `background-image: url("${fallbackSrc}");`;
|
|
60
|
+
}
|
|
61
|
+
if (cssImageSet) {
|
|
62
|
+
style += `background-image: ${cssImageSet};`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return style;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Central list of locales supported by the router and SSG tooling
|
|
2
|
+
export const SUPPORTED_LOCALES = [
|
|
3
|
+
'en',
|
|
4
|
+
'fr',
|
|
5
|
+
'es',
|
|
6
|
+
'de',
|
|
7
|
+
'ja',
|
|
8
|
+
'ko',
|
|
9
|
+
'pt',
|
|
10
|
+
'ru',
|
|
11
|
+
'tr',
|
|
12
|
+
'vi',
|
|
13
|
+
'id',
|
|
14
|
+
'zh',
|
|
15
|
+
'th',
|
|
16
|
+
'hi',
|
|
17
|
+
'fil',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export default SUPPORTED_LOCALES;
|