@duffcloudservices/cms 0.3.12 → 0.3.14
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/README.md +332 -309
- package/dist/editor/editorBridge.js +127 -50
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/index.js +59 -13
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/package.json +90 -90
- package/src/components/DcsReviewShowcase.vue +321 -326
- package/src/components/PreviewRibbon.vue +612 -612
- package/src/components/ResponsiveImage.vue +55 -55
- package/src/composables/index.ts +10 -10
- package/src/composables/useMediaCarousel.ts +158 -158
- package/src/composables/useReleaseNotes.ts +153 -153
- package/src/composables/useResponsiveImage.ts +85 -85
- package/src/composables/useReviewContent.ts +150 -92
- package/src/composables/useSEO.ts +387 -387
- package/src/composables/useSiteVersion.ts +123 -123
- package/src/composables/useTextContent.ts +297 -297
|
@@ -1,297 +1,297 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useTextContent Composable
|
|
3
|
-
*
|
|
4
|
-
* Provides text content management with build-time injection support and optional
|
|
5
|
-
* runtime API overrides for DCS-managed customer sites.
|
|
6
|
-
*
|
|
7
|
-
* Content resolution order:
|
|
8
|
-
* 1. Runtime API overrides (premium tier only, if mode is 'runtime')
|
|
9
|
-
* 2. Build-time content from .dcs/content.yaml (injected via dcsContentPlugin)
|
|
10
|
-
* 3. Hardcoded defaults passed to the composable
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```vue
|
|
14
|
-
* <script setup lang="ts">
|
|
15
|
-
* import { useTextContent } from '@duffcloudservices/cms'
|
|
16
|
-
*
|
|
17
|
-
* const { t, isLoading, error } = useTextContent({
|
|
18
|
-
* pageSlug: 'home',
|
|
19
|
-
* defaults: {
|
|
20
|
-
* 'hero.title': 'Welcome to Our Site',
|
|
21
|
-
* 'hero.subtitle': 'Build something amazing',
|
|
22
|
-
* 'cta.primary': 'Get Started'
|
|
23
|
-
* }
|
|
24
|
-
* })
|
|
25
|
-
* </script>
|
|
26
|
-
*
|
|
27
|
-
* <template>
|
|
28
|
-
* <h1>{{ t('hero.title') }}</h1>
|
|
29
|
-
* <p>{{ t('hero.subtitle') }}</p>
|
|
30
|
-
* <button>{{ t('cta.primary') }}</button>
|
|
31
|
-
* </template>
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { ref, computed, readonly, onMounted, type Ref } from 'vue'
|
|
36
|
-
import type { DcsContentFile, TextContentConfig, TextContentReturn } from '../types/content'
|
|
37
|
-
|
|
38
|
-
// Declare the global injected by dcsContentPlugin
|
|
39
|
-
declare const __DCS_CONTENT__: DcsContentFile | undefined
|
|
40
|
-
|
|
41
|
-
// Simple in-memory cache for runtime fetches
|
|
42
|
-
const fetchCache = new Map<string, { data: Record<string, string>; expiresAt: number }>()
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Safely get build-time content configuration.
|
|
46
|
-
* Returns undefined if not available (no content.yaml or plugin not configured).
|
|
47
|
-
*/
|
|
48
|
-
function getBuildTimeContent(): DcsContentFile | undefined {
|
|
49
|
-
try {
|
|
50
|
-
if (typeof __DCS_CONTENT__ !== 'undefined' && __DCS_CONTENT__ !== null) {
|
|
51
|
-
return __DCS_CONTENT__
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
// __DCS_CONTENT__ not defined - that's fine, use defaults
|
|
55
|
-
}
|
|
56
|
-
return undefined
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Get build-time content for a specific page, merging global and page-specific content.
|
|
61
|
-
*/
|
|
62
|
-
function getBuildTimePageContent(pageSlug: string): Record<string, string> {
|
|
63
|
-
const content = getBuildTimeContent()
|
|
64
|
-
if (!content) return {}
|
|
65
|
-
|
|
66
|
-
const global = content.global ?? {}
|
|
67
|
-
const page = content.pages?.[pageSlug] ?? {}
|
|
68
|
-
|
|
69
|
-
return { ...global, ...page }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Get environment variable value, handling both Vite and process.env patterns.
|
|
74
|
-
*/
|
|
75
|
-
function getEnvVar(key: string, defaultValue = ''): string {
|
|
76
|
-
try {
|
|
77
|
-
// Vite pattern
|
|
78
|
-
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
|
79
|
-
const value = (import.meta.env as Record<string, string | undefined>)[key]
|
|
80
|
-
if (value !== undefined) return value
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
// import.meta not available
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
// Node.js pattern
|
|
88
|
-
if (typeof process !== 'undefined' && process.env) {
|
|
89
|
-
const value = process.env[key]
|
|
90
|
-
if (value !== undefined) return value
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// process not available
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return defaultValue
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* useTextContent composable for DCS-managed text content.
|
|
101
|
-
*
|
|
102
|
-
* @param config - Configuration object
|
|
103
|
-
* @returns Text content helpers and state
|
|
104
|
-
*/
|
|
105
|
-
export function useTextContent(config: TextContentConfig): TextContentReturn {
|
|
106
|
-
const {
|
|
107
|
-
pageSlug,
|
|
108
|
-
defaults,
|
|
109
|
-
fetchOnMount = true,
|
|
110
|
-
cacheKey,
|
|
111
|
-
cacheTtl = 60000,
|
|
112
|
-
} = config
|
|
113
|
-
|
|
114
|
-
// Get configuration from environment
|
|
115
|
-
const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', '')
|
|
116
|
-
const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
|
|
117
|
-
const textOverrideMode = getEnvVar('VITE_TEXT_OVERRIDE_MODE', 'commit')
|
|
118
|
-
const mode: 'commit' | 'runtime' = textOverrideMode === 'runtime' ? 'runtime' : 'commit'
|
|
119
|
-
|
|
120
|
-
// Get build-time content immediately (synchronous)
|
|
121
|
-
const buildTimeContent = getBuildTimePageContent(pageSlug)
|
|
122
|
-
const hasBuildTimeContent = Object.keys(buildTimeContent).length > 0
|
|
123
|
-
|
|
124
|
-
// State - initialize with build-time content
|
|
125
|
-
const overrides = ref<Record<string, string>>({ ...buildTimeContent })
|
|
126
|
-
const isLoading = ref(false)
|
|
127
|
-
const error = ref<string | null>(null)
|
|
128
|
-
|
|
129
|
-
// Computed merged texts (defaults + overrides)
|
|
130
|
-
const texts = computed(() => ({ ...defaults, ...overrides.value }))
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Get text by key with optional fallback.
|
|
134
|
-
* Resolution order: overrides → defaults → fallback → key
|
|
135
|
-
*/
|
|
136
|
-
function t(key: string, fallback?: string): string {
|
|
137
|
-
return overrides.value[key] ?? defaults[key] ?? fallback ?? key
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Check if a key has an override (from build-time or runtime).
|
|
142
|
-
*/
|
|
143
|
-
function hasOverride(key: string): boolean {
|
|
144
|
-
return key in overrides.value
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Get an array of objects from indexed content keys.
|
|
149
|
-
* Useful for lists where keys follow the pattern: arrayKey.index.property
|
|
150
|
-
* Example: features.1.title, features.1.description, features.2.title, etc.
|
|
151
|
-
*
|
|
152
|
-
* @param arrayKey - The base key prefix (e.g., 'features', 'items')
|
|
153
|
-
* @returns Array of objects with properties extracted from matching keys, sorted by index
|
|
154
|
-
*
|
|
155
|
-
* @example
|
|
156
|
-
* ```ts
|
|
157
|
-
* // Given content keys:
|
|
158
|
-
* // positions.1.title = "Software Engineer"
|
|
159
|
-
* // positions.1.description = "Build cool stuff"
|
|
160
|
-
* // positions.2.title = "Designer"
|
|
161
|
-
* // positions.2.description = "Design cool stuff"
|
|
162
|
-
*
|
|
163
|
-
* const positions = getArray('positions')
|
|
164
|
-
* // Returns:
|
|
165
|
-
* // [
|
|
166
|
-
* // { _index: 1, title: "Software Engineer", description: "Build cool stuff" },
|
|
167
|
-
* // { _index: 2, title: "Designer", description: "Design cool stuff" }
|
|
168
|
-
* // ]
|
|
169
|
-
* ```
|
|
170
|
-
*/
|
|
171
|
-
function getArray(arrayKey: string): Array<Record<string, unknown> & { _index: number }> {
|
|
172
|
-
const items: Record<number, Record<string, unknown> & { _index: number }> = {}
|
|
173
|
-
const source = { ...defaults, ...overrides.value }
|
|
174
|
-
|
|
175
|
-
Object.keys(source).forEach((key) => {
|
|
176
|
-
if (key.startsWith(`${arrayKey}.`)) {
|
|
177
|
-
const parts = key.split('.')
|
|
178
|
-
// Format: arrayKey.index.property (or arrayKey.index.nested.property)
|
|
179
|
-
// Example: features.1.title -> index=1, prop=title
|
|
180
|
-
if (parts.length >= 3) {
|
|
181
|
-
const index = Number.parseInt(parts[1], 10)
|
|
182
|
-
const prop = parts.slice(2).join('.')
|
|
183
|
-
|
|
184
|
-
if (!Number.isNaN(index)) {
|
|
185
|
-
if (!items[index]) items[index] = { _index: index }
|
|
186
|
-
items[index][prop] = source[key]
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
return Object.values(items).sort((a, b) => a._index - b._index)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Fetch runtime overrides from the API.
|
|
197
|
-
* Only runs if mode is 'runtime' and API is configured.
|
|
198
|
-
*/
|
|
199
|
-
async function fetchOverrides(): Promise<void> {
|
|
200
|
-
// Skip API fetch in commit mode - use build-time content only
|
|
201
|
-
if (mode !== 'runtime') {
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Skip if API not configured
|
|
206
|
-
if (!apiBaseUrl || !siteSlug) {
|
|
207
|
-
console.warn(
|
|
208
|
-
'[@duffcloudservices/cms] Runtime mode enabled but VITE_API_BASE_URL or VITE_SITE_SLUG not set'
|
|
209
|
-
)
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Check cache first
|
|
214
|
-
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
|
|
215
|
-
const cached = fetchCache.get(effectiveCacheKey)
|
|
216
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
217
|
-
overrides.value = { ...buildTimeContent, ...cached.data }
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
isLoading.value = true
|
|
222
|
-
error.value = null
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/pages/${pageSlug}/text`
|
|
226
|
-
const response = await fetch(url, {
|
|
227
|
-
headers: {
|
|
228
|
-
Accept: 'application/json',
|
|
229
|
-
},
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
if (!response.ok) {
|
|
233
|
-
if (response.status === 404) {
|
|
234
|
-
// No overrides for this page - that's fine
|
|
235
|
-
return
|
|
236
|
-
}
|
|
237
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const data = await response.json()
|
|
241
|
-
const apiOverrides = data.overrides ?? data.texts ?? {}
|
|
242
|
-
|
|
243
|
-
// Cache the result
|
|
244
|
-
fetchCache.set(effectiveCacheKey, {
|
|
245
|
-
data: apiOverrides,
|
|
246
|
-
expiresAt: Date.now() + cacheTtl,
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
// Merge: build-time content is baseline, API overrides on top
|
|
250
|
-
overrides.value = { ...buildTimeContent, ...apiOverrides }
|
|
251
|
-
} catch (e) {
|
|
252
|
-
error.value = e instanceof Error ? e.message : 'Failed to load text overrides'
|
|
253
|
-
console.error('[@duffcloudservices/cms] Failed to fetch text overrides:', e)
|
|
254
|
-
} finally {
|
|
255
|
-
isLoading.value = false
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Manually refresh overrides.
|
|
261
|
-
* In commit mode, resets to build-time content.
|
|
262
|
-
* In runtime mode, fetches fresh data from API.
|
|
263
|
-
*/
|
|
264
|
-
async function refresh(): Promise<void> {
|
|
265
|
-
if (mode !== 'runtime') {
|
|
266
|
-
// In commit mode, just reset to build-time content
|
|
267
|
-
overrides.value = { ...buildTimeContent }
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Clear cache for this key
|
|
272
|
-
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
|
|
273
|
-
fetchCache.delete(effectiveCacheKey)
|
|
274
|
-
|
|
275
|
-
await fetchOverrides()
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Fetch on mount if enabled and in runtime mode (browser only)
|
|
279
|
-
if (fetchOnMount && mode === 'runtime' && globalThis.window !== undefined) {
|
|
280
|
-
onMounted(() => {
|
|
281
|
-
fetchOverrides()
|
|
282
|
-
})
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
t,
|
|
287
|
-
getArray,
|
|
288
|
-
texts,
|
|
289
|
-
overrides,
|
|
290
|
-
isLoading: readonly(isLoading) as Ref<boolean>,
|
|
291
|
-
error: readonly(error) as Ref<string | null>,
|
|
292
|
-
refresh,
|
|
293
|
-
hasOverride,
|
|
294
|
-
hasBuildTimeContent,
|
|
295
|
-
mode,
|
|
296
|
-
}
|
|
297
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* useTextContent Composable
|
|
3
|
+
*
|
|
4
|
+
* Provides text content management with build-time injection support and optional
|
|
5
|
+
* runtime API overrides for DCS-managed customer sites.
|
|
6
|
+
*
|
|
7
|
+
* Content resolution order:
|
|
8
|
+
* 1. Runtime API overrides (premium tier only, if mode is 'runtime')
|
|
9
|
+
* 2. Build-time content from .dcs/content.yaml (injected via dcsContentPlugin)
|
|
10
|
+
* 3. Hardcoded defaults passed to the composable
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```vue
|
|
14
|
+
* <script setup lang="ts">
|
|
15
|
+
* import { useTextContent } from '@duffcloudservices/cms'
|
|
16
|
+
*
|
|
17
|
+
* const { t, isLoading, error } = useTextContent({
|
|
18
|
+
* pageSlug: 'home',
|
|
19
|
+
* defaults: {
|
|
20
|
+
* 'hero.title': 'Welcome to Our Site',
|
|
21
|
+
* 'hero.subtitle': 'Build something amazing',
|
|
22
|
+
* 'cta.primary': 'Get Started'
|
|
23
|
+
* }
|
|
24
|
+
* })
|
|
25
|
+
* </script>
|
|
26
|
+
*
|
|
27
|
+
* <template>
|
|
28
|
+
* <h1>{{ t('hero.title') }}</h1>
|
|
29
|
+
* <p>{{ t('hero.subtitle') }}</p>
|
|
30
|
+
* <button>{{ t('cta.primary') }}</button>
|
|
31
|
+
* </template>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ref, computed, readonly, onMounted, type Ref } from 'vue'
|
|
36
|
+
import type { DcsContentFile, TextContentConfig, TextContentReturn } from '../types/content'
|
|
37
|
+
|
|
38
|
+
// Declare the global injected by dcsContentPlugin
|
|
39
|
+
declare const __DCS_CONTENT__: DcsContentFile | undefined
|
|
40
|
+
|
|
41
|
+
// Simple in-memory cache for runtime fetches
|
|
42
|
+
const fetchCache = new Map<string, { data: Record<string, string>; expiresAt: number }>()
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Safely get build-time content configuration.
|
|
46
|
+
* Returns undefined if not available (no content.yaml or plugin not configured).
|
|
47
|
+
*/
|
|
48
|
+
function getBuildTimeContent(): DcsContentFile | undefined {
|
|
49
|
+
try {
|
|
50
|
+
if (typeof __DCS_CONTENT__ !== 'undefined' && __DCS_CONTENT__ !== null) {
|
|
51
|
+
return __DCS_CONTENT__
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// __DCS_CONTENT__ not defined - that's fine, use defaults
|
|
55
|
+
}
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get build-time content for a specific page, merging global and page-specific content.
|
|
61
|
+
*/
|
|
62
|
+
function getBuildTimePageContent(pageSlug: string): Record<string, string> {
|
|
63
|
+
const content = getBuildTimeContent()
|
|
64
|
+
if (!content) return {}
|
|
65
|
+
|
|
66
|
+
const global = content.global ?? {}
|
|
67
|
+
const page = content.pages?.[pageSlug] ?? {}
|
|
68
|
+
|
|
69
|
+
return { ...global, ...page }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get environment variable value, handling both Vite and process.env patterns.
|
|
74
|
+
*/
|
|
75
|
+
function getEnvVar(key: string, defaultValue = ''): string {
|
|
76
|
+
try {
|
|
77
|
+
// Vite pattern
|
|
78
|
+
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
|
79
|
+
const value = (import.meta.env as Record<string, string | undefined>)[key]
|
|
80
|
+
if (value !== undefined) return value
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// import.meta not available
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// Node.js pattern
|
|
88
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
89
|
+
const value = process.env[key]
|
|
90
|
+
if (value !== undefined) return value
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// process not available
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return defaultValue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* useTextContent composable for DCS-managed text content.
|
|
101
|
+
*
|
|
102
|
+
* @param config - Configuration object
|
|
103
|
+
* @returns Text content helpers and state
|
|
104
|
+
*/
|
|
105
|
+
export function useTextContent(config: TextContentConfig): TextContentReturn {
|
|
106
|
+
const {
|
|
107
|
+
pageSlug,
|
|
108
|
+
defaults,
|
|
109
|
+
fetchOnMount = true,
|
|
110
|
+
cacheKey,
|
|
111
|
+
cacheTtl = 60000,
|
|
112
|
+
} = config
|
|
113
|
+
|
|
114
|
+
// Get configuration from environment
|
|
115
|
+
const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', '')
|
|
116
|
+
const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
|
|
117
|
+
const textOverrideMode = getEnvVar('VITE_TEXT_OVERRIDE_MODE', 'commit')
|
|
118
|
+
const mode: 'commit' | 'runtime' = textOverrideMode === 'runtime' ? 'runtime' : 'commit'
|
|
119
|
+
|
|
120
|
+
// Get build-time content immediately (synchronous)
|
|
121
|
+
const buildTimeContent = getBuildTimePageContent(pageSlug)
|
|
122
|
+
const hasBuildTimeContent = Object.keys(buildTimeContent).length > 0
|
|
123
|
+
|
|
124
|
+
// State - initialize with build-time content
|
|
125
|
+
const overrides = ref<Record<string, string>>({ ...buildTimeContent })
|
|
126
|
+
const isLoading = ref(false)
|
|
127
|
+
const error = ref<string | null>(null)
|
|
128
|
+
|
|
129
|
+
// Computed merged texts (defaults + overrides)
|
|
130
|
+
const texts = computed(() => ({ ...defaults, ...overrides.value }))
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get text by key with optional fallback.
|
|
134
|
+
* Resolution order: overrides → defaults → fallback → key
|
|
135
|
+
*/
|
|
136
|
+
function t(key: string, fallback?: string): string {
|
|
137
|
+
return overrides.value[key] ?? defaults[key] ?? fallback ?? key
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if a key has an override (from build-time or runtime).
|
|
142
|
+
*/
|
|
143
|
+
function hasOverride(key: string): boolean {
|
|
144
|
+
return key in overrides.value
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get an array of objects from indexed content keys.
|
|
149
|
+
* Useful for lists where keys follow the pattern: arrayKey.index.property
|
|
150
|
+
* Example: features.1.title, features.1.description, features.2.title, etc.
|
|
151
|
+
*
|
|
152
|
+
* @param arrayKey - The base key prefix (e.g., 'features', 'items')
|
|
153
|
+
* @returns Array of objects with properties extracted from matching keys, sorted by index
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* // Given content keys:
|
|
158
|
+
* // positions.1.title = "Software Engineer"
|
|
159
|
+
* // positions.1.description = "Build cool stuff"
|
|
160
|
+
* // positions.2.title = "Designer"
|
|
161
|
+
* // positions.2.description = "Design cool stuff"
|
|
162
|
+
*
|
|
163
|
+
* const positions = getArray('positions')
|
|
164
|
+
* // Returns:
|
|
165
|
+
* // [
|
|
166
|
+
* // { _index: 1, title: "Software Engineer", description: "Build cool stuff" },
|
|
167
|
+
* // { _index: 2, title: "Designer", description: "Design cool stuff" }
|
|
168
|
+
* // ]
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
function getArray(arrayKey: string): Array<Record<string, unknown> & { _index: number }> {
|
|
172
|
+
const items: Record<number, Record<string, unknown> & { _index: number }> = {}
|
|
173
|
+
const source = { ...defaults, ...overrides.value }
|
|
174
|
+
|
|
175
|
+
Object.keys(source).forEach((key) => {
|
|
176
|
+
if (key.startsWith(`${arrayKey}.`)) {
|
|
177
|
+
const parts = key.split('.')
|
|
178
|
+
// Format: arrayKey.index.property (or arrayKey.index.nested.property)
|
|
179
|
+
// Example: features.1.title -> index=1, prop=title
|
|
180
|
+
if (parts.length >= 3) {
|
|
181
|
+
const index = Number.parseInt(parts[1], 10)
|
|
182
|
+
const prop = parts.slice(2).join('.')
|
|
183
|
+
|
|
184
|
+
if (!Number.isNaN(index)) {
|
|
185
|
+
if (!items[index]) items[index] = { _index: index }
|
|
186
|
+
items[index][prop] = source[key]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return Object.values(items).sort((a, b) => a._index - b._index)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetch runtime overrides from the API.
|
|
197
|
+
* Only runs if mode is 'runtime' and API is configured.
|
|
198
|
+
*/
|
|
199
|
+
async function fetchOverrides(): Promise<void> {
|
|
200
|
+
// Skip API fetch in commit mode - use build-time content only
|
|
201
|
+
if (mode !== 'runtime') {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Skip if API not configured
|
|
206
|
+
if (!apiBaseUrl || !siteSlug) {
|
|
207
|
+
console.warn(
|
|
208
|
+
'[@duffcloudservices/cms] Runtime mode enabled but VITE_API_BASE_URL or VITE_SITE_SLUG not set'
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check cache first
|
|
214
|
+
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
|
|
215
|
+
const cached = fetchCache.get(effectiveCacheKey)
|
|
216
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
217
|
+
overrides.value = { ...buildTimeContent, ...cached.data }
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
isLoading.value = true
|
|
222
|
+
error.value = null
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/pages/${pageSlug}/text`
|
|
226
|
+
const response = await fetch(url, {
|
|
227
|
+
headers: {
|
|
228
|
+
Accept: 'application/json',
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
if (response.status === 404) {
|
|
234
|
+
// No overrides for this page - that's fine
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const data = await response.json()
|
|
241
|
+
const apiOverrides = data.overrides ?? data.texts ?? {}
|
|
242
|
+
|
|
243
|
+
// Cache the result
|
|
244
|
+
fetchCache.set(effectiveCacheKey, {
|
|
245
|
+
data: apiOverrides,
|
|
246
|
+
expiresAt: Date.now() + cacheTtl,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Merge: build-time content is baseline, API overrides on top
|
|
250
|
+
overrides.value = { ...buildTimeContent, ...apiOverrides }
|
|
251
|
+
} catch (e) {
|
|
252
|
+
error.value = e instanceof Error ? e.message : 'Failed to load text overrides'
|
|
253
|
+
console.error('[@duffcloudservices/cms] Failed to fetch text overrides:', e)
|
|
254
|
+
} finally {
|
|
255
|
+
isLoading.value = false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Manually refresh overrides.
|
|
261
|
+
* In commit mode, resets to build-time content.
|
|
262
|
+
* In runtime mode, fetches fresh data from API.
|
|
263
|
+
*/
|
|
264
|
+
async function refresh(): Promise<void> {
|
|
265
|
+
if (mode !== 'runtime') {
|
|
266
|
+
// In commit mode, just reset to build-time content
|
|
267
|
+
overrides.value = { ...buildTimeContent }
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Clear cache for this key
|
|
272
|
+
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
|
|
273
|
+
fetchCache.delete(effectiveCacheKey)
|
|
274
|
+
|
|
275
|
+
await fetchOverrides()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Fetch on mount if enabled and in runtime mode (browser only)
|
|
279
|
+
if (fetchOnMount && mode === 'runtime' && globalThis.window !== undefined) {
|
|
280
|
+
onMounted(() => {
|
|
281
|
+
fetchOverrides()
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
t,
|
|
287
|
+
getArray,
|
|
288
|
+
texts,
|
|
289
|
+
overrides,
|
|
290
|
+
isLoading: readonly(isLoading) as Ref<boolean>,
|
|
291
|
+
error: readonly(error) as Ref<string | null>,
|
|
292
|
+
refresh,
|
|
293
|
+
hasOverride,
|
|
294
|
+
hasBuildTimeContent,
|
|
295
|
+
mode,
|
|
296
|
+
}
|
|
297
|
+
}
|