/** * 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 * * * * ``` */ 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, } }