/**
* useTextContent Composable
*
* Provides text content management with build-time injection support and optional
* runtime API overrides for DCS-managed customer sites.
*
* Content resolution order:
* 1. Runtime API overrides (premium tier only, if mode is 'runtime')
* 2. Build-time content from .dcs/content.yaml (injected via dcsContentPlugin)
* 3. Hardcoded defaults passed to the composable
*
* @example
* ```vue
*
*
*
*
{{ t('hero.title') }}
*
{{ t('hero.subtitle') }}
*
*
* ```
*/
import { platformFetch } from '@duffcloudservices/cms-core'
import { ref, computed, readonly, onMounted, type Ref } from 'vue'
import type { DcsContentFile, TextContentConfig, TextContentReturn } from '../types/content'
// Declare the global injected by dcsContentPlugin
declare const __DCS_CONTENT__: DcsContentFile | undefined
// Simple in-memory cache for runtime fetches
const fetchCache = new Map; expiresAt: number }>()
/**
* Safely get build-time content configuration.
* Returns undefined if not available (no content.yaml or plugin not configured).
*/
function getBuildTimeContent(): DcsContentFile | undefined {
try {
if (typeof __DCS_CONTENT__ !== 'undefined' && __DCS_CONTENT__ !== null) {
return __DCS_CONTENT__
}
} catch {
// __DCS_CONTENT__ not defined - that's fine, use defaults
}
return undefined
}
/**
* Get build-time content for a specific page, merging global and page-specific content.
*/
function getBuildTimePageContent(pageSlug: string): Record {
const content = getBuildTimeContent()
if (!content) return {}
const global = content.global ?? {}
const page = content.pages?.[pageSlug] ?? {}
return { ...global, ...page }
}
/**
* Get environment variable value, handling both Vite and process.env patterns.
*/
function getEnvVar(key: string, defaultValue = ''): string {
try {
// Vite pattern
if (typeof import.meta !== 'undefined' && import.meta.env) {
const value = (import.meta.env as Record)[key]
if (value !== undefined) return value
}
} catch {
// import.meta not available
}
try {
// Node.js pattern
if (typeof process !== 'undefined' && process.env) {
const value = process.env[key]
if (value !== undefined) return value
}
} catch {
// process not available
}
return defaultValue
}
/**
* useTextContent composable for DCS-managed text content.
*
* @param config - Configuration object
* @returns Text content helpers and state
*/
export function useTextContent(config: TextContentConfig): TextContentReturn {
const {
pageSlug,
defaults,
fetchOnMount = true,
cacheKey,
cacheTtl = 60000,
} = config
// Get configuration from environment.
// @deprecated VITE_SITE_SLUG: the site is now resolved server-side from the
// request Host or the dedicated Container App's DCS_SITE_SLUG. It is still read
// here for cache-key continuity and source compatibility, but is no longer used
// to build the request URL.
const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', '')
const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
const textOverrideMode = getEnvVar('VITE_TEXT_OVERRIDE_MODE', 'commit')
const mode: 'commit' | 'runtime' = textOverrideMode === 'runtime' ? 'runtime' : 'commit'
// Get build-time content immediately (synchronous)
const buildTimeContent = getBuildTimePageContent(pageSlug)
const hasBuildTimeContent = Object.keys(buildTimeContent).length > 0
// State - initialize with build-time content
const overrides = ref>({ ...buildTimeContent })
const isLoading = ref(false)
const error = ref(null)
// Computed merged texts (defaults + overrides)
const texts = computed(() => ({ ...defaults, ...overrides.value }))
/**
* Get text by key with optional fallback.
* Resolution order: overrides → defaults → fallback → key
*/
function t(key: string, fallback?: string): string {
return overrides.value[key] ?? defaults[key] ?? fallback ?? key
}
/**
* Check if a key has an override (from build-time or runtime).
*/
function hasOverride(key: string): boolean {
return key in overrides.value
}
/**
* Get an array of objects from indexed content keys.
* Useful for lists where keys follow the pattern: arrayKey.index.property
* Example: features.1.title, features.1.description, features.2.title, etc.
*
* @param arrayKey - The base key prefix (e.g., 'features', 'items')
* @returns Array of objects with properties extracted from matching keys, sorted by index
*
* @example
* ```ts
* // Given content keys:
* // positions.1.title = "Software Engineer"
* // positions.1.description = "Build cool stuff"
* // positions.2.title = "Designer"
* // positions.2.description = "Design cool stuff"
*
* const positions = getArray('positions')
* // Returns:
* // [
* // { _index: 1, title: "Software Engineer", description: "Build cool stuff" },
* // { _index: 2, title: "Designer", description: "Design cool stuff" }
* // ]
* ```
*/
function getArray(arrayKey: string): Array & { _index: number }> {
const items: Record & { _index: number }> = {}
const source = { ...defaults, ...overrides.value }
Object.keys(source).forEach((key) => {
if (key.startsWith(`${arrayKey}.`)) {
const parts = key.split('.')
// Format: arrayKey.index.property (or arrayKey.index.nested.property)
// Example: features.1.title -> index=1, prop=title
if (parts.length >= 3) {
const index = Number.parseInt(parts[1], 10)
const prop = parts.slice(2).join('.')
if (!Number.isNaN(index)) {
if (!items[index]) items[index] = { _index: index }
items[index][prop] = source[key]
}
}
}
})
return Object.values(items).sort((a, b) => a._index - b._index)
}
/**
* Fetch runtime overrides from the API.
* Only runs if mode is 'runtime' and API is configured.
*/
async function fetchOverrides(): Promise {
// Skip API fetch in commit mode - use build-time content only
if (mode !== 'runtime') {
return
}
// Skip if API not configured. The site is resolved server-side (Host /
// DCS_SITE_SLUG), so VITE_SITE_SLUG is no longer required for routing —
// only the API base URL is needed.
if (!apiBaseUrl) {
console.warn(
'[@duffcloudservices/cms] Runtime mode enabled but VITE_API_BASE_URL not set'
)
return
}
// Check cache first
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
const cached = fetchCache.get(effectiveCacheKey)
if (cached && cached.expiresAt > Date.now()) {
overrides.value = { ...buildTimeContent, ...cached.data }
return
}
isLoading.value = true
error.value = null
try {
// Site is resolved server-side from the request Host or the dedicated
// Container App's DCS_SITE_SLUG; the slug is no longer encoded in the path.
const url = `${apiBaseUrl}/api/v1/pages/${pageSlug}/text`
// C-298 layer 2. `VITE_API_BASE_URL` defaults to '' here, so an unset base in
// `runtime` mode produces a RELATIVE call — the exact shape of C-261. The sweep also
// proved the configured base (api.duffcloudservices.com) is tenant-unresolvable for
// this host-resolved route (400 "Unable to resolve site from request"), which arrives
// as JSON and is therefore left to the `!response.ok` branch below, unchanged. What
// platformFetch adds is that an HTML shell can no longer be mistaken for "no
// overrides for this page".
const response = await platformFetch(url, {
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) {
// No overrides for this page - that's fine
return
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const apiOverrides = data.overrides ?? data.texts ?? {}
// Cache the result
fetchCache.set(effectiveCacheKey, {
data: apiOverrides,
expiresAt: Date.now() + cacheTtl,
})
// Merge: build-time content is baseline, API overrides on top
overrides.value = { ...buildTimeContent, ...apiOverrides }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load text overrides'
console.error('[@duffcloudservices/cms] Failed to fetch text overrides:', e)
} finally {
isLoading.value = false
}
}
/**
* Manually refresh overrides.
* In commit mode, resets to build-time content.
* In runtime mode, fetches fresh data from API.
*/
async function refresh(): Promise {
if (mode !== 'runtime') {
// In commit mode, just reset to build-time content
overrides.value = { ...buildTimeContent }
return
}
// Clear cache for this key
const effectiveCacheKey = cacheKey ?? `${siteSlug}:${pageSlug}`
fetchCache.delete(effectiveCacheKey)
await fetchOverrides()
}
// Fetch on mount if enabled and in runtime mode (browser only)
if (fetchOnMount && mode === 'runtime' && globalThis.window !== undefined) {
onMounted(() => {
fetchOverrides()
})
}
return {
t,
getArray,
texts,
overrides,
isLoading: readonly(isLoading) as Ref,
error: readonly(error) as Ref,
refresh,
hasOverride,
hasBuildTimeContent,
mode,
}
}