@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.
- package/LICENSE +21 -0
- package/README.md +212 -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 +88 -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 +412 -0
- package/scripts/migrate-to-content-dirs.mjs +223 -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 +161 -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 +235 -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 +187 -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/buildCssVarMap.js +246 -0
- package/src/themes/themeLoader.js +156 -0
- package/src/themes/themeManager.js +34 -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 +146 -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 +319 -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 +726 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import baseManifest from '../../themes/base/theme.config.js';
|
|
2
|
+
const cssModules = import.meta.glob('../../themes/**/theme.css', { eager: true });
|
|
3
|
+
import { validateThemeManifest } from './themeValidator.js';
|
|
4
|
+
|
|
5
|
+
const manifestModules = import.meta.glob(
|
|
6
|
+
[
|
|
7
|
+
'../../themes/**/theme.config.js',
|
|
8
|
+
'../../themes/**/theme.config.mjs',
|
|
9
|
+
'../../themes/**/theme.config.json',
|
|
10
|
+
],
|
|
11
|
+
{ eager: true }
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const catalog = {};
|
|
15
|
+
|
|
16
|
+
const normalizeSlug = (slug) =>
|
|
17
|
+
typeof slug === 'string' && slug.trim().length > 0 ? slug.trim().toLowerCase() : null;
|
|
18
|
+
|
|
19
|
+
const cssMap = Object.keys(cssModules).reduce((acc, key) => {
|
|
20
|
+
const slugMatch = key.match(/\/themes\/([^/]+)\/theme\.css$/);
|
|
21
|
+
if (slugMatch) {
|
|
22
|
+
acc[slugMatch[1]] = key;
|
|
23
|
+
}
|
|
24
|
+
return acc;
|
|
25
|
+
}, {});
|
|
26
|
+
|
|
27
|
+
const addToCatalog = (manifest, sourceId, cssPath) => {
|
|
28
|
+
if (!manifest || typeof manifest !== 'object') return;
|
|
29
|
+
const normalized = normalizeSlug(manifest.slug);
|
|
30
|
+
try {
|
|
31
|
+
validateThemeManifest(manifest);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error?.message || 'Unknown theme validation error';
|
|
34
|
+
throw new Error(`[theme-loader] ${message} (source: ${sourceId})`);
|
|
35
|
+
}
|
|
36
|
+
if (!normalized) {
|
|
37
|
+
throw new Error(`[theme-loader] Theme manifest missing slug (source: ${sourceId})`);
|
|
38
|
+
}
|
|
39
|
+
if (catalog[normalized]) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`[theme-loader] Duplicate theme slug "${normalized}" detected. Source ${sourceId} skipped.`
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
catalog[normalized] = {
|
|
47
|
+
...manifest,
|
|
48
|
+
slug: normalized,
|
|
49
|
+
assets: {
|
|
50
|
+
...manifest.assets,
|
|
51
|
+
css: manifest.assets?.css || cssPath || undefined,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Auto-discover bundled themes (i.e. base)
|
|
57
|
+
Object.entries(manifestModules).forEach(([path, module]) => {
|
|
58
|
+
const manifest = module?.default ?? module;
|
|
59
|
+
const slug = normalizeSlug(manifest?.slug);
|
|
60
|
+
const bundledCssPath = slug ? cssMap[slug] : undefined;
|
|
61
|
+
addToCatalog(manifest, path, bundledCssPath ? bundledCssPath.replace('../../', '') : undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!catalog.base) {
|
|
65
|
+
addToCatalog(baseManifest, 'themes/base/theme.config.js', undefined);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register an external theme package.
|
|
70
|
+
*
|
|
71
|
+
* Called by the Vite plugin for each theme passed via the `themes` option.
|
|
72
|
+
* Must be called before the first render (during app initialisation).
|
|
73
|
+
*
|
|
74
|
+
* Two patterns for shipping theme CSS:
|
|
75
|
+
*
|
|
76
|
+
* 1. **Side-effect import (preferred).** The theme's `index.js` imports
|
|
77
|
+
* its `theme.css` for side effects:
|
|
78
|
+
*
|
|
79
|
+
* import manifest from './theme.config.js';
|
|
80
|
+
* import './theme.css';
|
|
81
|
+
* export default { manifest };
|
|
82
|
+
*
|
|
83
|
+
* Vite bundles `theme.css` into the main entry chunk and emits a
|
|
84
|
+
* synchronous `<link rel="stylesheet">` in `<head>`, so theme rules
|
|
85
|
+
* apply during the initial paint. Combined with the SSR-rendered
|
|
86
|
+
* `data-site-theme` attribute (set in `applySiteTheme`), this
|
|
87
|
+
* eliminates the FOUC that JS-injected theme CSS used to cause.
|
|
88
|
+
*
|
|
89
|
+
* 2. **Inline string (legacy).** The theme's `index.js` imports the CSS
|
|
90
|
+
* with `?inline` and exports it as a `css` string:
|
|
91
|
+
*
|
|
92
|
+
* import manifest from './theme.config.js';
|
|
93
|
+
* import css from './theme.css?inline';
|
|
94
|
+
* export default { manifest, css };
|
|
95
|
+
*
|
|
96
|
+
* The CSS is injected via a runtime `<style>` tag (handled below).
|
|
97
|
+
* This works but applies after Vue hydrates, so dark themes will
|
|
98
|
+
* flash a light first paint unless the site provides critical CSS.
|
|
99
|
+
*
|
|
100
|
+
* Existing themes (cms-theme-neon, -frog, -aurora, -southpark, -swamp)
|
|
101
|
+
* still use pattern 2 and are supported indefinitely; new themes should
|
|
102
|
+
* prefer pattern 1.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} themeModule - Default export from a @koehler8/cms-theme-* package
|
|
105
|
+
* Expected shape: { manifest, css? } or just the manifest object directly.
|
|
106
|
+
*/
|
|
107
|
+
export function registerTheme(themeModule) {
|
|
108
|
+
if (!themeModule || typeof themeModule !== 'object') {
|
|
109
|
+
throw new Error('[theme-loader] registerTheme() requires a theme module object');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const manifest = themeModule.manifest || themeModule;
|
|
113
|
+
const cssContent = themeModule.css;
|
|
114
|
+
const sourceId = `external:${manifest?.slug || 'unknown'}`;
|
|
115
|
+
|
|
116
|
+
addToCatalog(manifest, sourceId, undefined);
|
|
117
|
+
|
|
118
|
+
// Legacy pattern 2: theme shipped inline CSS, inject it into the document head.
|
|
119
|
+
// For pattern 1 themes, `cssContent` is undefined and this block is skipped —
|
|
120
|
+
// the CSS is already loaded via Vite's bundling.
|
|
121
|
+
if (cssContent && typeof cssContent === 'string' && typeof document !== 'undefined') {
|
|
122
|
+
const slug = normalizeSlug(manifest.slug);
|
|
123
|
+
const existingStyle = document.getElementById(`theme-css-${slug}`);
|
|
124
|
+
if (!existingStyle) {
|
|
125
|
+
const style = document.createElement('style');
|
|
126
|
+
style.id = `theme-css-${slug}`;
|
|
127
|
+
style.textContent = cssContent;
|
|
128
|
+
document.head.appendChild(style);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getThemeCatalog() {
|
|
134
|
+
return catalog;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getThemeManifest(themeKey) {
|
|
138
|
+
if (typeof themeKey !== 'string') return null;
|
|
139
|
+
return catalog[themeKey.trim().toLowerCase()] || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveThemeManifest(themeKey) {
|
|
143
|
+
return (
|
|
144
|
+
getThemeManifest(themeKey) ||
|
|
145
|
+
catalog.base ||
|
|
146
|
+
Object.values(catalog)[0] ||
|
|
147
|
+
null
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function listThemeMetadata() {
|
|
152
|
+
return Object.values(catalog).map((manifest) => ({
|
|
153
|
+
slug: manifest.slug,
|
|
154
|
+
meta: manifest.meta,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveThemeManifest } from './themeLoader.js';
|
|
2
|
+
import { buildCssVarMap } from './buildCssVarMap.js';
|
|
3
|
+
|
|
4
|
+
// buildCssVarMap was extracted to its own pure module so the build-time
|
|
5
|
+
// vite plugin can call it (to emit `virtual:cms-theme-vars.css`) without
|
|
6
|
+
// pulling in this file's runtime-only imports.
|
|
7
|
+
|
|
8
|
+
export function applyThemeVariables(themeKey) {
|
|
9
|
+
const manifest = resolveThemeManifest(themeKey);
|
|
10
|
+
if (typeof document === 'undefined') {
|
|
11
|
+
return manifest;
|
|
12
|
+
}
|
|
13
|
+
const vars = buildCssVarMap(manifest);
|
|
14
|
+
const root = document.documentElement;
|
|
15
|
+
|
|
16
|
+
// Remove inline assignments from previous calls so normal cascade can apply.
|
|
17
|
+
Object.keys(vars).forEach((name) => root.style.removeProperty(name));
|
|
18
|
+
|
|
19
|
+
let styleTag = document.getElementById('theme-vars');
|
|
20
|
+
if (!styleTag) {
|
|
21
|
+
styleTag = document.createElement('style');
|
|
22
|
+
styleTag.id = 'theme-vars';
|
|
23
|
+
// Insert early so site/theme overrides loaded later can win via cascade.
|
|
24
|
+
const head = document.head || root;
|
|
25
|
+
head.insertBefore(styleTag, head.firstChild);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cssBody = Object.entries(vars)
|
|
29
|
+
.map(([name, value]) => `${name}: ${value};`)
|
|
30
|
+
.join('\n ');
|
|
31
|
+
styleTag.textContent = `:root {\n ${cssBody}\n}`;
|
|
32
|
+
|
|
33
|
+
return manifest;
|
|
34
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
const COLOR_PATTERN =
|
|
2
|
+
/^(#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\b|rgba?\(|hsla?\(|var\(--|linear-gradient\(|radial-gradient\(|conic-gradient\(|color-mix\()/i;
|
|
3
|
+
const CSS_UNIT_PATTERN = /^-?\d*\.?\d+(px|rem|em|vh|vw|%)$/i;
|
|
4
|
+
|
|
5
|
+
const CTA_KEYS = ['bg', 'text', 'border', 'hoverBg', 'hoverBorder', 'shadow'];
|
|
6
|
+
const CHIP_KEYS = ['bg', 'text', 'border'];
|
|
7
|
+
|
|
8
|
+
const REQUIRED_BLOCKS = {
|
|
9
|
+
palette: [
|
|
10
|
+
'primary',
|
|
11
|
+
'primaryAccent',
|
|
12
|
+
'secondary',
|
|
13
|
+
'accent',
|
|
14
|
+
'accentSoft',
|
|
15
|
+
'neutral',
|
|
16
|
+
'neutralStrong',
|
|
17
|
+
'inverse',
|
|
18
|
+
'success',
|
|
19
|
+
'info',
|
|
20
|
+
'warning',
|
|
21
|
+
'critical',
|
|
22
|
+
],
|
|
23
|
+
text: ['primary', 'muted', 'mutedStrong', 'inverse', 'accent', 'onAccent'],
|
|
24
|
+
surfaces: [
|
|
25
|
+
'base',
|
|
26
|
+
'baseAlt',
|
|
27
|
+
'raised',
|
|
28
|
+
'sunken',
|
|
29
|
+
'callout',
|
|
30
|
+
'card',
|
|
31
|
+
'cardAlt',
|
|
32
|
+
'overlay',
|
|
33
|
+
'backdrop',
|
|
34
|
+
'border',
|
|
35
|
+
'divider',
|
|
36
|
+
'input',
|
|
37
|
+
'chip',
|
|
38
|
+
'chipAccent',
|
|
39
|
+
],
|
|
40
|
+
typography: [
|
|
41
|
+
'bodyFamily',
|
|
42
|
+
'headingFamily',
|
|
43
|
+
'monoFamily',
|
|
44
|
+
'baseSize',
|
|
45
|
+
'scale',
|
|
46
|
+
'weightRegular',
|
|
47
|
+
'weightMedium',
|
|
48
|
+
'weightBold',
|
|
49
|
+
'letterSpacingTight',
|
|
50
|
+
'letterSpacingWide',
|
|
51
|
+
],
|
|
52
|
+
ctas: ['primary', 'secondary', 'ghost', 'link'],
|
|
53
|
+
chips: ['neutral', 'accent', 'outline'],
|
|
54
|
+
focus: ['ring', 'ringMuted', 'outline', 'shadowInset'],
|
|
55
|
+
radii: ['sm', 'md', 'lg', 'pill', 'full'],
|
|
56
|
+
elevation: ['flat', 'raised', 'overlay'],
|
|
57
|
+
utility: [
|
|
58
|
+
'divider',
|
|
59
|
+
'inputBorder',
|
|
60
|
+
'inputText',
|
|
61
|
+
'inputPlaceholder',
|
|
62
|
+
'selectionBg',
|
|
63
|
+
'selectionText',
|
|
64
|
+
'gradientHero',
|
|
65
|
+
'gradientPromo',
|
|
66
|
+
'bodyBackground',
|
|
67
|
+
'modalSurface',
|
|
68
|
+
'modalBorder',
|
|
69
|
+
'modalShadow',
|
|
70
|
+
'modalRadius',
|
|
71
|
+
'chartTrack',
|
|
72
|
+
'chartCenterText',
|
|
73
|
+
'statusHeadline',
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const NESTED_SURFACES = {
|
|
78
|
+
helper: ['background', 'hover', 'text', 'border', 'heading', 'body'],
|
|
79
|
+
tabs: [
|
|
80
|
+
'background',
|
|
81
|
+
'border',
|
|
82
|
+
'shadow',
|
|
83
|
+
'tabColor',
|
|
84
|
+
'activeBackground',
|
|
85
|
+
'activeColor',
|
|
86
|
+
'activeShadow',
|
|
87
|
+
'stepBackground',
|
|
88
|
+
'stepBorder',
|
|
89
|
+
'stepColor',
|
|
90
|
+
'activeStepBackground',
|
|
91
|
+
'activeStepBorder',
|
|
92
|
+
'activeStepColor',
|
|
93
|
+
],
|
|
94
|
+
field: [
|
|
95
|
+
'background',
|
|
96
|
+
'border',
|
|
97
|
+
'shadow',
|
|
98
|
+
'addonBackground',
|
|
99
|
+
'addonBorder',
|
|
100
|
+
'addonColor',
|
|
101
|
+
'inputColor',
|
|
102
|
+
'inputPlaceholder',
|
|
103
|
+
],
|
|
104
|
+
strip: ['background', 'border', 'text'],
|
|
105
|
+
chrome: ['background', 'text', 'shadow', 'compactShadow'],
|
|
106
|
+
backdropPrimary: ['background', 'before', 'after'],
|
|
107
|
+
backdropSecondary: ['background', 'before', 'after'],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const CTA_OVERRIDES = {
|
|
111
|
+
primary: { border: ensureString, hoverBorder: ensureString, shadow: ensureString },
|
|
112
|
+
secondary: { border: ensureString, hoverBorder: ensureString, shadow: ensureString },
|
|
113
|
+
ghost: { border: ensureString, hoverBorder: ensureString, shadow: ensureString },
|
|
114
|
+
link: { underline: ensureColor },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const CHIP_OVERRIDES = {
|
|
118
|
+
outline: { border: ensureString },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const SURFACE_NESTED_OVERRIDES = {
|
|
122
|
+
tabs: {
|
|
123
|
+
border: ensureString,
|
|
124
|
+
shadow: ensureString,
|
|
125
|
+
activeShadow: ensureString,
|
|
126
|
+
stepBorder: ensureString,
|
|
127
|
+
activeStepBorder: ensureString,
|
|
128
|
+
},
|
|
129
|
+
field: {
|
|
130
|
+
shadow: ensureString,
|
|
131
|
+
addonBorder: ensureString,
|
|
132
|
+
},
|
|
133
|
+
chrome: {
|
|
134
|
+
shadow: ensureString,
|
|
135
|
+
compactShadow: ensureString,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const UTILITY_VALIDATORS = {
|
|
140
|
+
divider: ensureColor,
|
|
141
|
+
inputBorder: ensureColor,
|
|
142
|
+
inputText: ensureColor,
|
|
143
|
+
inputPlaceholder: ensureColor,
|
|
144
|
+
selectionBg: ensureColor,
|
|
145
|
+
selectionText: ensureColor,
|
|
146
|
+
gradientHero: ensureColor,
|
|
147
|
+
gradientPromo: ensureColor,
|
|
148
|
+
bodyBackground: ensureColor,
|
|
149
|
+
modalSurface: ensureColor,
|
|
150
|
+
modalBorder: ensureColor,
|
|
151
|
+
modalShadow: ensureString,
|
|
152
|
+
modalRadius: (value, path, errors) => {
|
|
153
|
+
if (!isCssSize(value)) errors.push(`${path} must be a CSS size`);
|
|
154
|
+
},
|
|
155
|
+
chartTrack: ensureColor,
|
|
156
|
+
chartCenterText: ensureColor,
|
|
157
|
+
statusHeadline: ensureSurfaceOrColor,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function isString(value) {
|
|
161
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isColor(value) {
|
|
165
|
+
if (!isString(value)) return false;
|
|
166
|
+
const normalized = value.trim().toLowerCase();
|
|
167
|
+
if (normalized === 'transparent' || normalized === 'currentcolor' || normalized === 'none') {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return COLOR_PATTERN.test(normalized);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isCssSize(value) {
|
|
174
|
+
if (typeof value === 'number') return true;
|
|
175
|
+
if (!isString(value)) return false;
|
|
176
|
+
const normalized = value.trim();
|
|
177
|
+
if (normalized === '0') return true;
|
|
178
|
+
return CSS_UNIT_PATTERN.test(normalized);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensureColor(value, path, errors) {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
value.forEach((entry, index) => ensureColor(entry, `${path}[${index}]`, errors));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!isColor(value)) {
|
|
187
|
+
errors.push(`${path} must be a CSS color/gradient value`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureSurfaceOrColor(value, path, errors) {
|
|
192
|
+
if (value && typeof value === 'object') {
|
|
193
|
+
if (!value.background) {
|
|
194
|
+
errors.push(`${path}.background is required`);
|
|
195
|
+
} else {
|
|
196
|
+
ensureColor(value.background, `${path}.background`, errors);
|
|
197
|
+
}
|
|
198
|
+
if (value.color) ensureColor(value.color, `${path}.color`, errors);
|
|
199
|
+
if (value.shadow) ensureString(value.shadow, `${path}.shadow`, errors);
|
|
200
|
+
if (value.border) ensureColor(value.border, `${path}.border`, errors);
|
|
201
|
+
if (value.valueColor) ensureColor(value.valueColor, `${path}.valueColor`, errors);
|
|
202
|
+
if (value.valueShadow) ensureString(value.valueShadow, `${path}.valueShadow`, errors);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
ensureColor(value, path, errors);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ensureString(value, path, errors) {
|
|
209
|
+
if (!isString(value)) {
|
|
210
|
+
errors.push(`${path} must be a non-empty string`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureObject(value, path, errors) {
|
|
215
|
+
if (!value || typeof value !== 'object') {
|
|
216
|
+
errors.push(`${path} must be an object`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ensureKeys(obj, keys, path, errors, validator = ensureColor, overrides = {}) {
|
|
223
|
+
keys.forEach((key) => {
|
|
224
|
+
if (!(key in obj)) {
|
|
225
|
+
errors.push(`${path}.${key} is required`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const fieldValidator = overrides[key] || validator;
|
|
229
|
+
fieldValidator(obj[key], `${path}.${key}`, errors);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validatePaletteBlock(tokens, errors) {
|
|
234
|
+
if (!ensureObject(tokens.palette, 'tokens.palette', errors)) return;
|
|
235
|
+
ensureKeys(tokens.palette, REQUIRED_BLOCKS.palette, 'tokens.palette', errors, ensureColor);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateTextBlock(tokens, errors) {
|
|
239
|
+
if (!ensureObject(tokens.text, 'tokens.text', errors)) return;
|
|
240
|
+
ensureKeys(tokens.text, REQUIRED_BLOCKS.text, 'tokens.text', errors, ensureColor);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function validateSurfaceBlock(tokens, errors) {
|
|
244
|
+
if (!ensureObject(tokens.surfaces, 'tokens.surfaces', errors)) return;
|
|
245
|
+
ensureKeys(tokens.surfaces, REQUIRED_BLOCKS.surfaces, 'tokens.surfaces', errors, ensureColor);
|
|
246
|
+
Object.entries(NESTED_SURFACES).forEach(([key, nestedKeys]) => {
|
|
247
|
+
const node = tokens.surfaces[key];
|
|
248
|
+
if (!ensureObject(node, `tokens.surfaces.${key}`, errors)) return;
|
|
249
|
+
ensureKeys(
|
|
250
|
+
node,
|
|
251
|
+
nestedKeys,
|
|
252
|
+
`tokens.surfaces.${key}`,
|
|
253
|
+
errors,
|
|
254
|
+
ensureColor,
|
|
255
|
+
SURFACE_NESTED_OVERRIDES[key] || {}
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateTypography(tokens, errors) {
|
|
261
|
+
if (!ensureObject(tokens.typography, 'tokens.typography', errors)) return;
|
|
262
|
+
ensureKeys(tokens.typography, REQUIRED_BLOCKS.typography, 'tokens.typography', errors, (value, path) => {
|
|
263
|
+
if (typeof value === 'number') return;
|
|
264
|
+
if (!isString(value)) {
|
|
265
|
+
errors.push(`${path} must be a string or number`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validateCtas(tokens, errors) {
|
|
271
|
+
if (!ensureObject(tokens.ctas, 'tokens.ctas', errors)) return;
|
|
272
|
+
REQUIRED_BLOCKS.ctas.forEach((ctaKey) => {
|
|
273
|
+
const block = tokens.ctas[ctaKey];
|
|
274
|
+
if (!ensureObject(block, `tokens.ctas.${ctaKey}`, errors)) return;
|
|
275
|
+
const keys = ctaKey === 'link' ? ['text', 'hoverText', 'underline'] : CTA_KEYS;
|
|
276
|
+
ensureKeys(
|
|
277
|
+
block,
|
|
278
|
+
keys,
|
|
279
|
+
`tokens.ctas.${ctaKey}`,
|
|
280
|
+
errors,
|
|
281
|
+
ensureColor,
|
|
282
|
+
CTA_OVERRIDES[ctaKey] || {}
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validateChips(tokens, errors) {
|
|
288
|
+
if (!ensureObject(tokens.chips, 'tokens.chips', errors)) return;
|
|
289
|
+
REQUIRED_BLOCKS.chips.forEach((chipKey) => {
|
|
290
|
+
const block = tokens.chips[chipKey];
|
|
291
|
+
if (!ensureObject(block, `tokens.chips.${chipKey}`, errors)) return;
|
|
292
|
+
ensureKeys(
|
|
293
|
+
block,
|
|
294
|
+
CHIP_KEYS,
|
|
295
|
+
`tokens.chips.${chipKey}`,
|
|
296
|
+
errors,
|
|
297
|
+
ensureColor,
|
|
298
|
+
CHIP_OVERRIDES[chipKey] || {}
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validateFocus(tokens, errors) {
|
|
304
|
+
if (!ensureObject(tokens.focus, 'tokens.focus', errors)) return;
|
|
305
|
+
ensureKeys(tokens.focus, REQUIRED_BLOCKS.focus, 'tokens.focus', errors, ensureString);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function validateRadii(tokens, errors) {
|
|
309
|
+
if (!ensureObject(tokens.radii, 'tokens.radii', errors)) return;
|
|
310
|
+
ensureKeys(tokens.radii, REQUIRED_BLOCKS.radii, 'tokens.radii', errors, (value, path) => {
|
|
311
|
+
if (!isCssSize(value)) {
|
|
312
|
+
errors.push(`${path} must be a CSS size (px, rem, etc.)`);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function validateElevation(tokens, errors) {
|
|
318
|
+
if (!ensureObject(tokens.elevation, 'tokens.elevation', errors)) return;
|
|
319
|
+
ensureKeys(tokens.elevation, REQUIRED_BLOCKS.elevation, 'tokens.elevation', errors, ensureString);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function validateUtility(tokens, errors) {
|
|
323
|
+
if (!ensureObject(tokens.utility, 'tokens.utility', errors)) return;
|
|
324
|
+
REQUIRED_BLOCKS.utility.forEach((key) => {
|
|
325
|
+
const value = tokens.utility[key];
|
|
326
|
+
if (value === undefined || value === null || value === '') {
|
|
327
|
+
errors.push(`tokens.utility.${key} is required`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const validator = UTILITY_VALIDATORS[key] || ensureColor;
|
|
331
|
+
if (Array.isArray(value)) {
|
|
332
|
+
value.forEach((entry, index) => validator(entry, `tokens.utility.${key}[${index}]`, errors));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
validator(value, `tokens.utility.${key}`, errors);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function validateThemeManifest(manifest, options = {}) {
|
|
340
|
+
const { throwOnError = true } = options;
|
|
341
|
+
const errors = [];
|
|
342
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
343
|
+
errors.push('Theme manifest must be an object');
|
|
344
|
+
} else {
|
|
345
|
+
if (!isString(manifest.slug)) {
|
|
346
|
+
errors.push('manifest.slug must be a non-empty string');
|
|
347
|
+
}
|
|
348
|
+
if (!ensureObject(manifest.meta, 'manifest.meta', errors)) {
|
|
349
|
+
// no-op
|
|
350
|
+
} else {
|
|
351
|
+
if (!isString(manifest.meta.name)) errors.push('manifest.meta.name is required');
|
|
352
|
+
if (!isString(manifest.meta.version)) errors.push('manifest.meta.version is required');
|
|
353
|
+
}
|
|
354
|
+
if (!ensureObject(manifest.tokens, 'manifest.tokens', errors)) {
|
|
355
|
+
// no-op
|
|
356
|
+
} else {
|
|
357
|
+
validatePaletteBlock(manifest.tokens, errors);
|
|
358
|
+
validateTextBlock(manifest.tokens, errors);
|
|
359
|
+
validateSurfaceBlock(manifest.tokens, errors);
|
|
360
|
+
validateTypography(manifest.tokens, errors);
|
|
361
|
+
validateCtas(manifest.tokens, errors);
|
|
362
|
+
validateChips(manifest.tokens, errors);
|
|
363
|
+
validateFocus(manifest.tokens, errors);
|
|
364
|
+
validateRadii(manifest.tokens, errors);
|
|
365
|
+
validateElevation(manifest.tokens, errors);
|
|
366
|
+
validateUtility(manifest.tokens, errors);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (errors.length && throwOnError) {
|
|
371
|
+
const slug = manifest?.slug || 'unknown';
|
|
372
|
+
const error = new Error(
|
|
373
|
+
`Theme manifest "${slug}" is invalid:\n- ${errors.join('\n- ')}`
|
|
374
|
+
);
|
|
375
|
+
error.errors = errors;
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { valid: errors.length === 0, errors };
|
|
380
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Google Analytics helper that respects consent settings.
|
|
3
|
+
*/
|
|
4
|
+
import { shouldEnableAnalytics } from './cookieConsent.js';
|
|
5
|
+
import { getAnalyticsContext } from './trackingContext.js';
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEYS = new Set(['wallet_address', 'account_address', 'private_key', 'seed_phrase']);
|
|
8
|
+
|
|
9
|
+
function sanitizeParams(params = {}) {
|
|
10
|
+
if (!params || typeof params !== 'object') return {};
|
|
11
|
+
return Object.entries(params).reduce((acc, [key, value]) => {
|
|
12
|
+
const lowered = key.toLowerCase();
|
|
13
|
+
if (SENSITIVE_KEYS.has(lowered)) {
|
|
14
|
+
return acc;
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
17
|
+
acc[key] = sanitizeParams(value);
|
|
18
|
+
return acc;
|
|
19
|
+
}
|
|
20
|
+
acc[key] = value;
|
|
21
|
+
return acc;
|
|
22
|
+
}, {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canTrack() {
|
|
26
|
+
if (typeof window === 'undefined') return false;
|
|
27
|
+
if (!shouldEnableAnalytics()) return false;
|
|
28
|
+
if (typeof window.gtag !== 'function') return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Send a GA4 event if analytics is ready.
|
|
34
|
+
* @param {string} name - GA4 event name.
|
|
35
|
+
* @param {Record<string, any>} [params] - Additional event parameters.
|
|
36
|
+
* @returns {boolean} True when the call was sent to gtag.
|
|
37
|
+
*/
|
|
38
|
+
export function trackEvent(name, params = {}) {
|
|
39
|
+
if (!name || typeof name !== 'string') {
|
|
40
|
+
if (import.meta.env.DEV) {
|
|
41
|
+
console.warn('[analytics] trackEvent requires a string name.');
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!canTrack()) return false;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const payload = sanitizeParams(params);
|
|
50
|
+
window.gtag('event', name, payload);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (import.meta.env.DEV) {
|
|
54
|
+
console.warn('[analytics] Failed to send event', name, error);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set GA4 user properties.
|
|
62
|
+
* @param {Record<string, any>} properties
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function setUserProperties(properties = {}) {
|
|
66
|
+
if (!properties || typeof properties !== 'object') return false;
|
|
67
|
+
if (!canTrack()) return false;
|
|
68
|
+
try {
|
|
69
|
+
window.gtag('set', 'user_properties', properties);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (import.meta.env.DEV) {
|
|
73
|
+
console.warn('[analytics] Failed to set user_properties', error);
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Public helper to surface readiness in components if needed.
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isAnalyticsReady() {
|
|
84
|
+
return canTrack();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Alias kept for ergonomic imports in components.
|
|
89
|
+
*/
|
|
90
|
+
export function trackEventIfReady(name, params) {
|
|
91
|
+
return trackEvent(name, params);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function trackFunnelEvent(name, params = {}) {
|
|
95
|
+
const payload = {
|
|
96
|
+
...getAnalyticsContext(),
|
|
97
|
+
...sanitizeParams(params),
|
|
98
|
+
};
|
|
99
|
+
return trackEvent(name, payload);
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import packageJsonRaw from '../../package.json?raw';
|
|
2
|
+
|
|
3
|
+
const packageJson = JSON.parse(packageJsonRaw);
|
|
4
|
+
|
|
5
|
+
const envVersion = import.meta?.env?.VITE_APP_VERSION;
|
|
6
|
+
|
|
7
|
+
export const APP_VERSION = (typeof envVersion === 'string' && envVersion.trim())
|
|
8
|
+
? envVersion.trim()
|
|
9
|
+
: packageJson?.version || '0.0.0';
|