@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.
@@ -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
+ }