@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,22 @@
1
+ <template>
2
+ <svg
3
+ class="icon-x"
4
+ width="20"
5
+ height="20"
6
+ viewBox="0 0 24 24"
7
+ fill="none"
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ aria-hidden="true"
10
+ >
11
+ <path
12
+ d="M4 4h4.6l3.5 4.7L15.6 4H20l-5.7 7.1L20 20h-4.6l-3.8-5.1L7.8 20H4l6-7.4L4 4Z"
13
+ fill="currentColor"
14
+ />
15
+ </svg>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .icon-x {
20
+ display: block;
21
+ }
22
+ </style>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ class="sb-card"
5
+ :class="[attrs.class, { 'sb-card--padded': padded }]"
6
+ v-bind="forwardedAttrs"
7
+ >
8
+ <slot />
9
+ </component>
10
+ </template>
11
+
12
+ <script setup>
13
+ import { computed, useAttrs } from 'vue';
14
+
15
+ const props = defineProps({
16
+ tag: {
17
+ type: String,
18
+ default: 'div',
19
+ },
20
+ padded: {
21
+ type: Boolean,
22
+ default: true,
23
+ },
24
+ });
25
+
26
+ const attrs = useAttrs();
27
+ const forwardedAttrs = computed(() => {
28
+ const { class: _ignoredClass, ...rest } = attrs;
29
+ return rest;
30
+ });
31
+ </script>
32
+
33
+ <style scoped>
34
+ .sb-card {
35
+ background: var(--brand-surface-card-bg, #141216);
36
+ border: 1px solid var(--brand-surface-card-border, rgba(255, 255, 255, 0.08));
37
+ border-radius: var(--brand-card-radius, 24px);
38
+ box-shadow: var(--brand-surface-card-shadow, 0 8px 24px rgba(217, 22, 75, 0.18));
39
+ backdrop-filter: blur(12px);
40
+ transition: box-shadow 0.25s ease, transform 0.25s ease;
41
+ }
42
+
43
+ .sb-card--padded {
44
+ padding: 24px;
45
+ }
46
+
47
+ @media (min-width: 992px) {
48
+ .sb-card--padded {
49
+ padding: 32px;
50
+ }
51
+ }
52
+ </style>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <component
3
+ :is="rootTag"
4
+ class="skeleton-pulse"
5
+ :class="{ 'skeleton-pulse--inline': inline }"
6
+ v-bind="rootAttrs"
7
+ >
8
+ <span class="skeleton-pulse__shimmer" :style="shimmerStyle" aria-hidden="true"></span>
9
+ <span v-if="srText" class="skeleton-pulse__sr">{{ srText }}</span>
10
+ </component>
11
+ </template>
12
+
13
+ <script setup>
14
+ import { computed } from 'vue';
15
+
16
+ const props = defineProps({
17
+ width: {
18
+ type: String,
19
+ default: '100%',
20
+ },
21
+ height: {
22
+ type: String,
23
+ default: '1rem',
24
+ },
25
+ radius: {
26
+ type: String,
27
+ default: '999px',
28
+ },
29
+ inline: {
30
+ type: Boolean,
31
+ default: false,
32
+ },
33
+ label: {
34
+ type: String,
35
+ default: '',
36
+ },
37
+ });
38
+
39
+ const srText = computed(() =>
40
+ typeof props.label === 'string' ? props.label.trim() : ''
41
+ );
42
+ const hasLabel = computed(() => srText.value.length > 0);
43
+ const rootTag = computed(() => (props.inline ? 'span' : 'div'));
44
+ const shimmerStyle = computed(() => ({
45
+ width: props.width,
46
+ minWidth: props.width,
47
+ height: props.height,
48
+ borderRadius: props.radius,
49
+ }));
50
+ const rootAttrs = computed(() =>
51
+ hasLabel.value
52
+ ? { role: 'status', 'aria-live': 'polite' }
53
+ : { 'aria-hidden': 'true' }
54
+ );
55
+ </script>
56
+
57
+ <style scoped>
58
+ .skeleton-pulse {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ position: relative;
63
+ }
64
+
65
+ .skeleton-pulse--inline {
66
+ display: inline-flex;
67
+ }
68
+
69
+ .skeleton-pulse__shimmer {
70
+ display: block;
71
+ position: relative;
72
+ overflow: hidden;
73
+ background: linear-gradient(
74
+ 90deg,
75
+ rgba(32, 24, 39, 0.4) 0%,
76
+ rgba(39, 243, 255, 0.28) 45%,
77
+ rgba(255, 45, 134, 0.3) 60%,
78
+ rgba(32, 24, 39, 0.4) 100%
79
+ );
80
+ background-size: 200% 100%;
81
+ animation: skeletonPulse 1.6s ease-in-out infinite;
82
+ box-shadow:
83
+ 0 0 12px rgba(39, 243, 255, 0.18),
84
+ 0 0 24px rgba(255, 45, 134, 0.12);
85
+ }
86
+
87
+ .skeleton-pulse__sr {
88
+ position: absolute;
89
+ width: 1px;
90
+ height: 1px;
91
+ padding: 0;
92
+ margin: -1px;
93
+ overflow: hidden;
94
+ clip: rect(0, 0, 0, 0);
95
+ white-space: nowrap;
96
+ border: 0;
97
+ }
98
+
99
+ @keyframes skeletonPulse {
100
+ 0% {
101
+ background-position: 100% 0;
102
+ }
103
+ 50% {
104
+ background-position: 0 0;
105
+ }
106
+ 100% {
107
+ background-position: 100% 0;
108
+ }
109
+ }
110
+
111
+ @media (prefers-reduced-motion: reduce) {
112
+ .skeleton-pulse__shimmer {
113
+ animation: none;
114
+ background-position: 50% 0;
115
+ }
116
+ }
117
+ </style>
@@ -0,0 +1,69 @@
1
+ <template>
2
+ <span class="unit-chip">
3
+ <span v-if="icon" class="unit-chip__icon">
4
+ <img :src="icon" :alt="iconAlt" />
5
+ </span>
6
+ <span class="unit-chip__label">{{ label }}</span>
7
+ </span>
8
+ </template>
9
+
10
+ <script setup>
11
+ const props = defineProps({
12
+ label: {
13
+ type: String,
14
+ required: true,
15
+ },
16
+ icon: {
17
+ type: String,
18
+ default: '',
19
+ },
20
+ iconAlt: {
21
+ type: String,
22
+ default: 'Token icon',
23
+ },
24
+ });
25
+ </script>
26
+
27
+ <style scoped>
28
+ .unit-chip {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ gap: 6px;
33
+ padding: 0 10px;
34
+ min-height: 24px;
35
+ border-radius: 999px;
36
+ font-size: 0.7rem;
37
+ font-weight: 600;
38
+ letter-spacing: 0.14em;
39
+ text-transform: uppercase;
40
+ color: var(--brand-fg-200);
41
+ background: var(--brand-bg-800);
42
+ border: 1px solid var(--brand-surface-card-border);
43
+ line-height: 1;
44
+ vertical-align: baseline;
45
+ white-space: nowrap;
46
+ }
47
+
48
+ .unit-chip__icon {
49
+ width: 18px;
50
+ height: 18px;
51
+ border-radius: 50%;
52
+ overflow: hidden;
53
+ display: inline-flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ background: rgba(255, 255, 255, 0.08);
57
+ border: 1px solid rgba(255, 255, 255, 0.1);
58
+ }
59
+
60
+ .unit-chip__icon img {
61
+ width: 100%;
62
+ height: 100%;
63
+ object-fit: cover;
64
+ }
65
+
66
+ .unit-chip__label {
67
+ display: inline-block;
68
+ }
69
+ </style>
@@ -0,0 +1,120 @@
1
+ import { computed } from 'vue';
2
+
3
+ const DEFAULT_MODAL = {
4
+ title: 'Coming Soon',
5
+ message: "We're putting the finishing touches on this experience. Check back soon.",
6
+ };
7
+
8
+ const COMING_SOON_PREFIX = 'coming-soon';
9
+
10
+ function normalizeModal(source, fallback = null) {
11
+ if (!source || typeof source !== 'object') {
12
+ return fallback;
13
+ }
14
+
15
+ const modal = source.modal && typeof source.modal === 'object' ? source.modal : null;
16
+ const titleCandidate = [
17
+ source.title,
18
+ modal?.title,
19
+ fallback?.title,
20
+ DEFAULT_MODAL.title,
21
+ ].find((value) => typeof value === 'string' && value.trim());
22
+ const messageCandidate = [
23
+ source.message,
24
+ modal?.message,
25
+ fallback?.message,
26
+ DEFAULT_MODAL.message,
27
+ ].find((value) => typeof value === 'string' && value.trim());
28
+
29
+ if (!titleCandidate || !messageCandidate) {
30
+ return fallback;
31
+ }
32
+
33
+ return {
34
+ title: titleCandidate.trim(),
35
+ message: messageCandidate.trim(),
36
+ };
37
+ }
38
+
39
+ function parseComingSoonAction(rawValue) {
40
+ if (rawValue === undefined || rawValue === null) {
41
+ return null;
42
+ }
43
+
44
+ const value = String(rawValue).trim();
45
+ if (!value) {
46
+ return null;
47
+ }
48
+
49
+ const lowerValue = value.toLowerCase();
50
+ const prefix = COMING_SOON_PREFIX;
51
+
52
+ if (lowerValue === prefix) {
53
+ return { variant: null };
54
+ }
55
+
56
+ if (lowerValue.startsWith(`${prefix}:`)) {
57
+ const variant = value.slice(prefix.length + 1).trim();
58
+ return { variant: variant || null };
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ function resolveVariant(config, variant) {
65
+ const baseModal = normalizeModal(config, DEFAULT_MODAL);
66
+ if (!baseModal) {
67
+ return null;
68
+ }
69
+
70
+ if (!variant) {
71
+ return baseModal;
72
+ }
73
+
74
+ const variants = config?.variants;
75
+ if (!variants || typeof variants !== 'object') {
76
+ return baseModal;
77
+ }
78
+
79
+ const variantConfig = variants[variant] || variants[variant.toLowerCase()];
80
+ if (!variantConfig) {
81
+ return baseModal;
82
+ }
83
+
84
+ return normalizeModal(variantConfig, baseModal) || baseModal;
85
+ }
86
+
87
+ /**
88
+ * Build a resolver that checks action href values for the "coming-soon"
89
+ * directive and returns the modal payload to show when present.
90
+ * @param {Ref<Record<string, any>>} pageContentRef
91
+ * @returns {{ resolve(options: { href?: string | null }): { title: string, message: string, variant: string | null } | null, isComingSoonAction(href: string): boolean }}
92
+ */
93
+ export function useComingSoonResolver(pageContentRef) {
94
+ const comingSoonConfig = computed(() => pageContentRef?.value?.comingSoon || null);
95
+
96
+ return {
97
+ resolve(options = {}) {
98
+ const parsed = parseComingSoonAction(options?.href);
99
+ if (!parsed) {
100
+ return null;
101
+ }
102
+
103
+ const config = comingSoonConfig.value || {};
104
+ const modal = resolveVariant(config, parsed.variant);
105
+ if (!modal) {
106
+ return null;
107
+ }
108
+
109
+ return {
110
+ ...modal,
111
+ variant: parsed.variant ? parsed.variant.toLowerCase() : null,
112
+ };
113
+ },
114
+ isComingSoonAction(href) {
115
+ return Boolean(parseComingSoonAction(href));
116
+ },
117
+ };
118
+ }
119
+
120
+ export { COMING_SOON_PREFIX };
@@ -0,0 +1,27 @@
1
+ import { ref } from 'vue';
2
+
3
+ const isVisible = ref(false);
4
+ const modalTitle = ref('Coming Soon');
5
+ const modalMessage = ref("We're putting the finishing touches on this experience. Check back soon!");
6
+
7
+ export function useComingSoonInterstitial() {
8
+ const openComingSoon = ({ title, message } = {}) => {
9
+ modalTitle.value = title || 'Coming Soon';
10
+ modalMessage.value =
11
+ message ||
12
+ "We're putting the finishing touches on this experience. Check back soon!";
13
+ isVisible.value = true;
14
+ };
15
+
16
+ const closeComingSoon = () => {
17
+ isVisible.value = false;
18
+ };
19
+
20
+ return {
21
+ isComingSoonVisible: isVisible,
22
+ comingSoonTitle: modalTitle,
23
+ comingSoonMessage: modalMessage,
24
+ openComingSoon,
25
+ closeComingSoon,
26
+ };
27
+ }
@@ -0,0 +1,196 @@
1
+ import { computed } from 'vue';
2
+ import { extensionComponentDefinitionsBySource, extensionComponentSources, getExtensionContentDefaults } from '../extensions/extensionLoader.js';
3
+ import { APP_VERSION } from '../utils/appInfo.js';
4
+ import { satisfiesMinVersion } from '../utils/semver.js';
5
+ import { validateRequiredContentPaths } from '../utils/contentRequirements.js';
6
+ import { mergeConfigTrees } from '../utils/loadConfig.js';
7
+
8
+ const extensionDefinitionsBySource = extensionComponentDefinitionsBySource || {};
9
+ const extensionSourcesByName = extensionComponentSources || {};
10
+
11
+ const normalizePageKey = (value) => (value || '').toString().trim().toLowerCase();
12
+ const normalizeSourceKey = (value) => (value || '').toString().trim().toLowerCase();
13
+
14
+ const reportExtensionIssue = (message, context = {}) => {
15
+ if (!import.meta.env.DEV) return;
16
+ const contextEntries = Object.entries(context)
17
+ .filter(([, value]) => value !== undefined && value !== null);
18
+ if (contextEntries.length) {
19
+ console.warn('[extensions]', message, Object.fromEntries(contextEntries));
20
+ } else {
21
+ console.warn('[extensions]', message);
22
+ }
23
+ };
24
+
25
+ const resolveExtensionDefinition = (name, sourceSlug) => {
26
+ if (!name) return null;
27
+ const normalizedSource = normalizeSourceKey(sourceSlug);
28
+ if (!normalizedSource) return null;
29
+ const sourceKey = `${normalizedSource}:${name}`;
30
+ return extensionDefinitionsBySource[sourceKey] || null;
31
+ };
32
+
33
+ export function useComponentResolver({ componentKeys, pageContent, currentPage, registry }) {
34
+ const loadedComponents = computed(() => {
35
+ const pageKey = normalizePageKey(currentPage.value.id);
36
+ return componentKeys.value
37
+ .map((entry, index) => {
38
+ let name = '';
39
+ let enabled = true;
40
+ let configKey;
41
+ let source;
42
+
43
+ if (entry && typeof entry === 'object') {
44
+ name = typeof entry.name === 'string' ? entry.name : '';
45
+ enabled = entry.enabled !== false;
46
+ configKey = typeof entry.configKey === 'string' ? entry.configKey : undefined;
47
+ source = typeof entry.source === 'string' ? entry.source : undefined;
48
+ } else if (typeof entry === 'string') {
49
+ name = entry;
50
+ }
51
+
52
+ if (!enabled) return null;
53
+
54
+ const availableSources = Array.isArray(extensionSourcesByName[name])
55
+ ? extensionSourcesByName[name]
56
+ : null;
57
+ let normalizedSource = normalizeSourceKey(source) || null;
58
+
59
+ if (availableSources && availableSources.length) {
60
+ if (!normalizedSource) {
61
+ if (availableSources.length === 1 && !registry[name]) {
62
+ // Single unambiguous extension source and no base component — auto-resolve.
63
+ normalizedSource = normalizeSourceKey(availableSources[0]);
64
+ } else {
65
+ reportExtensionIssue(
66
+ `Component "${name}" comes from an extension. Specify the source slug (available: ${availableSources.join(', ')}) in the component entry.`,
67
+ { component: name, availableSources },
68
+ );
69
+ return null;
70
+ }
71
+ }
72
+ if (!availableSources.includes(normalizedSource)) {
73
+ reportExtensionIssue(
74
+ `Component "${name}" is not provided by the "${source}" extension.`,
75
+ { component: name, requestedSource: normalizedSource, availableSources },
76
+ );
77
+ return null;
78
+ }
79
+ } else if (normalizedSource) {
80
+ reportExtensionIssue(
81
+ `Component "${name}" does not belong to an extension, so the "source" field should be omitted.`,
82
+ { component: name, requestedSource: normalizedSource },
83
+ );
84
+ return null;
85
+ }
86
+
87
+ const definition = resolveExtensionDefinition(name, normalizedSource);
88
+ if (availableSources && availableSources.length && !definition) {
89
+ reportExtensionIssue(
90
+ `Component "${name}" could not resolve extension definition for source "${normalizedSource}".`,
91
+ { component: name, requestedSource: normalizedSource },
92
+ );
93
+ return null;
94
+ }
95
+ const isExtension = Boolean(definition);
96
+ let component = isExtension ? definition.component : registry[name];
97
+
98
+ if (!component && name && name.startsWith('Spacer')) {
99
+ component = registry.Spacer;
100
+ }
101
+
102
+ if (!component) {
103
+ if (import.meta.env.DEV) {
104
+ console.warn(
105
+ `[PageRenderer] Component "${name}"${
106
+ source ? ` from source "${source}"` : ''
107
+ } is not registered in the component registry.`,
108
+ );
109
+ }
110
+ return null;
111
+ }
112
+
113
+ const resolvedSource = definition?.slug || normalizedSource || null;
114
+ const keyBase = definition ? `${resolvedSource || 'extension'}-${name}` : (name || `component-${index}`);
115
+
116
+ const defaultConfigKey = isExtension ? definition.configKey : undefined;
117
+ const resolvedConfigKey = configKey || defaultConfigKey || null;
118
+ const rawContent =
119
+ resolvedConfigKey && pageContent.value && typeof pageContent.value === 'object'
120
+ ? pageContent.value[resolvedConfigKey]
121
+ : undefined;
122
+ const contentDefaults = isExtension
123
+ ? getExtensionContentDefaults(definition.normalizedSlug, resolvedConfigKey)
124
+ : undefined;
125
+ const resolvedContent = contentDefaults
126
+ ? mergeConfigTrees(contentDefaults, rawContent || {}, { skipEmpty: true })
127
+ : rawContent;
128
+
129
+ if (isExtension) {
130
+ if (Array.isArray(definition.allowedPages) && definition.allowedPages.length) {
131
+ const normalizedAllowed = definition.allowedPages
132
+ .map((slug) => normalizePageKey(slug))
133
+ .filter(Boolean);
134
+ if (normalizedAllowed.length && !normalizedAllowed.includes(pageKey)) {
135
+ reportExtensionIssue(`Component "${name}" is not allowed on page "${currentPage.value.id}".`, {
136
+ component: name,
137
+ source: resolvedSource || undefined,
138
+ page: currentPage.value.id,
139
+ allowedPages: normalizedAllowed,
140
+ });
141
+ return null;
142
+ }
143
+ }
144
+
145
+ if (definition.minAppVersion && !satisfiesMinVersion(APP_VERSION, definition.minAppVersion)) {
146
+ reportExtensionIssue(
147
+ `Component "${name}" requires app version ${definition.minAppVersion} or higher.`,
148
+ {
149
+ component: name,
150
+ source: resolvedSource || undefined,
151
+ required: definition.minAppVersion,
152
+ current: APP_VERSION,
153
+ },
154
+ );
155
+ return null;
156
+ }
157
+
158
+ if (Array.isArray(definition.requiredContent) && definition.requiredContent.length) {
159
+ const { isValid, missing } = validateRequiredContentPaths(
160
+ resolvedContent,
161
+ definition.requiredContent,
162
+ );
163
+ if (!isValid) {
164
+ reportExtensionIssue(
165
+ `Component "${name}" is missing required content.`,
166
+ {
167
+ component: name,
168
+ source: resolvedSource || undefined,
169
+ page: currentPage.value.id,
170
+ missing,
171
+ configKey: resolvedConfigKey,
172
+ },
173
+ );
174
+ return null;
175
+ }
176
+ }
177
+ }
178
+
179
+ const props = {};
180
+ if (isExtension) {
181
+ props.content = resolvedContent !== undefined ? resolvedContent : null;
182
+ props.configKey = resolvedConfigKey;
183
+ }
184
+
185
+ return {
186
+ key: `${keyBase}-${index}`,
187
+ component,
188
+ configKey: resolvedConfigKey,
189
+ props,
190
+ };
191
+ })
192
+ .filter(Boolean);
193
+ });
194
+
195
+ return { loadedComponents };
196
+ }