@duffcloudservices/cms 0.3.1 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,7 +27,8 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "files": [
29
29
  "dist",
30
- "src/components"
30
+ "src/components",
31
+ "src/composables"
31
32
  ],
32
33
  "scripts": {
33
34
  "build": "tsup",
@@ -0,0 +1,4 @@
1
+ export { useTextContent } from './useTextContent'
2
+ export { useSEO, createSiteSEO } from './useSEO'
3
+ export { useReleaseNotes } from './useReleaseNotes'
4
+ export { useSiteVersion } from './useSiteVersion'
@@ -0,0 +1,158 @@
1
+ /**
2
+ * useMediaCarousel Composable
3
+ *
4
+ * Extracts media carousel items from text content keys following the pattern:
5
+ * `{prefix}.{N}.url`, `{prefix}.{N}.type`, `{prefix}.{N}.alt`
6
+ *
7
+ * @example
8
+ * ```vue
9
+ * <script setup lang="ts">
10
+ * import { useTextContent, useMediaCarousel } from '@duffcloudservices/cms'
11
+ *
12
+ * const { t } = useTextContent({
13
+ * pageSlug: 'bio-mackenzie',
14
+ * defaults: {
15
+ * 'hero.media-carousel.0.url': '/images/staff/mackenzie.webp',
16
+ * 'hero.media-carousel.0.type': 'image',
17
+ * 'hero.media-carousel.0.alt': 'Mackenzie Kowalick',
18
+ * 'hero.media-carousel.1.url': '/videos/intro.mp4',
19
+ * 'hero.media-carousel.1.type': 'video',
20
+ * 'hero.media-carousel.1.alt': 'Introduction video',
21
+ * }
22
+ * })
23
+ *
24
+ * const { items } = useMediaCarousel({
25
+ * prefix: 'hero.media-carousel',
26
+ * t,
27
+ * defaults: [
28
+ * { url: '/images/staff/mackenzie.webp', type: 'image', alt: 'Mackenzie Kowalick' }
29
+ * ]
30
+ * })
31
+ * </script>
32
+ *
33
+ * <template>
34
+ * <MediaCarousel :items="items" />
35
+ * </template>
36
+ * ```
37
+ */
38
+
39
+ import { computed, type ComputedRef } from 'vue'
40
+ import { isCdnAssetUrl } from '@duffcloudservices/cms-core'
41
+
42
+ /**
43
+ * Media carousel item representing an image, video, or embed
44
+ */
45
+ export interface MediaCarouselItem {
46
+ /** URL to the image, video file, or embed URL */
47
+ url: string
48
+ /** Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram' */
49
+ type: 'image' | 'video' | 'youtube' | 'instagram'
50
+ /** Accessibility alt text */
51
+ alt?: string
52
+ /**
53
+ * Whether this image has responsive CDN variants available.
54
+ * Automatically set to `true` when the URL matches the DCS CDN asset pattern.
55
+ * Components rendering the carousel should use `<ResponsiveImage>` when this is `true`.
56
+ */
57
+ responsive?: boolean
58
+ }
59
+
60
+ /**
61
+ * Configuration for useMediaCarousel composable
62
+ */
63
+ export interface UseMediaCarouselConfig {
64
+ /** Key prefix for carousel items (e.g., 'hero.media-carousel') */
65
+ prefix: string
66
+ /** The t() function from useTextContent */
67
+ t: (key: string, fallback?: string) => string
68
+ /** Default items to use if no content keys are found */
69
+ defaults?: MediaCarouselItem[]
70
+ /** Maximum number of items to look for (default: 10) */
71
+ maxItems?: number
72
+ }
73
+
74
+ /**
75
+ * Return type for useMediaCarousel composable
76
+ */
77
+ export interface UseMediaCarouselReturn {
78
+ /** Computed array of media carousel items */
79
+ items: ComputedRef<MediaCarouselItem[]>
80
+ /** Whether any items were found from content keys */
81
+ hasItems: ComputedRef<boolean>
82
+ /** Number of items in the carousel */
83
+ count: ComputedRef<number>
84
+ }
85
+
86
+ /**
87
+ * Extract media carousel items from text content keys.
88
+ *
89
+ * Looks for keys in the format:
90
+ * - `{prefix}.{N}.url` - Required URL for the media
91
+ * - `{prefix}.{N}.type` - Type: 'image' or 'video' (defaults to 'image')
92
+ * - `{prefix}.{N}.alt` - Alt text for accessibility
93
+ *
94
+ * Items are sorted by index (0, 1, etc.) and only included if they have a valid URL.
95
+ *
96
+ * @param config - Configuration object
97
+ * @returns Media carousel helpers and state
98
+ */
99
+ export function useMediaCarousel(config: UseMediaCarouselConfig): UseMediaCarouselReturn {
100
+ const {
101
+ prefix,
102
+ t,
103
+ defaults = [],
104
+ maxItems = 10,
105
+ } = config
106
+
107
+ const items = computed<MediaCarouselItem[]>(() => {
108
+ const result: MediaCarouselItem[] = []
109
+
110
+ // Look for items from 0 to maxItems
111
+ for (let i = 0; i < maxItems; i++) {
112
+ const urlKey = `${prefix}.${i}.url`
113
+ const typeKey = `${prefix}.${i}.type`
114
+ const altKey = `${prefix}.${i}.alt`
115
+
116
+ // Use a sentinel value to detect if the key exists
117
+ const url = t(urlKey, '')
118
+
119
+ // Skip if no URL (key doesn't exist or is empty)
120
+ if (!url || url === urlKey) {
121
+ continue
122
+ }
123
+
124
+ const typeValue = t(typeKey, 'image')
125
+ // Parse type value - support image, video, youtube, instagram
126
+ let type: 'image' | 'video' | 'youtube' | 'instagram' = 'image'
127
+ if (typeValue === 'video') type = 'video'
128
+ else if (typeValue === 'youtube') type = 'youtube'
129
+ else if (typeValue === 'instagram') type = 'instagram'
130
+ const alt = t(altKey, '')
131
+
132
+ result.push({
133
+ url,
134
+ type,
135
+ alt: alt && alt !== altKey ? alt : undefined,
136
+ // Flag CDN-hosted images as responsive so carousel components
137
+ // can render them with <ResponsiveImage> automatically
138
+ responsive: type === 'image' && isCdnAssetUrl(url),
139
+ })
140
+ }
141
+
142
+ // If no items found from content keys, use defaults
143
+ if (result.length === 0) {
144
+ return defaults
145
+ }
146
+
147
+ return result
148
+ })
149
+
150
+ const hasItems = computed(() => items.value.length > 0)
151
+ const count = computed(() => items.value.length)
152
+
153
+ return {
154
+ items,
155
+ hasItems,
156
+ count,
157
+ }
158
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * useReleaseNotes Composable
3
+ *
4
+ * Fetches and displays versioned release notes from the DCS Portal API.
5
+ * Supports fetching specific versions or the latest release.
6
+ *
7
+ * @example
8
+ * ```vue
9
+ * <script setup lang="ts">
10
+ * import { useReleaseNotes } from '@duffcloudservices/cms'
11
+ * import { useRoute } from 'vue-router'
12
+ *
13
+ * const route = useRoute()
14
+ * const version = route.params.version as string || 'latest'
15
+ *
16
+ * const { releaseNote, isLoading, error } = useReleaseNotes(version)
17
+ * </script>
18
+ *
19
+ * <template>
20
+ * <div v-if="isLoading">Loading...</div>
21
+ * <div v-else-if="error">{{ error }}</div>
22
+ * <article v-else-if="releaseNote">
23
+ * <h1>{{ releaseNote.title }}</h1>
24
+ * <p>{{ releaseNote.summary }}</p>
25
+ * <div v-html="renderedMarkdown" />
26
+ * </article>
27
+ * </template>
28
+ * ```
29
+ */
30
+
31
+ import { ref, onMounted } from 'vue'
32
+ import type { ReleaseNote, ReleaseNotesReturn } from '../types/release-notes'
33
+
34
+ /**
35
+ * Get environment variable value.
36
+ */
37
+ function getEnvVar(key: string, defaultValue = ''): string {
38
+ try {
39
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
40
+ const value = (import.meta.env as Record<string, string | undefined>)[key]
41
+ if (value !== undefined) return value
42
+ }
43
+ } catch {
44
+ // import.meta not available
45
+ }
46
+ return defaultValue
47
+ }
48
+
49
+ // Simple cache for release notes
50
+ const releaseNotesCache = new Map<string, { data: ReleaseNote; expiresAt: number }>()
51
+ const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
52
+
53
+ /**
54
+ * useReleaseNotes composable for fetching release notes from the DCS API.
55
+ *
56
+ * @param version - Semantic version (e.g., "1.2.0") or "latest"
57
+ * @param options - Optional configuration
58
+ * @returns Release notes data and state
59
+ */
60
+ export function useReleaseNotes(
61
+ version: string,
62
+ options: { fetchOnMount?: boolean } = {}
63
+ ): ReleaseNotesReturn {
64
+ const { fetchOnMount = true } = options
65
+
66
+ const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', 'https://portal.duffcloudservices.com')
67
+ const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
68
+
69
+ const releaseNote = ref<ReleaseNote | null>(null)
70
+ const isLoading = ref(false)
71
+ const error = ref<string | null>(null)
72
+
73
+ async function fetchReleaseNotes(): Promise<void> {
74
+ if (!siteSlug) {
75
+ error.value = 'VITE_SITE_SLUG environment variable not set'
76
+ return
77
+ }
78
+
79
+ // Check cache
80
+ const cacheKey = `${siteSlug}:${version}`
81
+ const cached = releaseNotesCache.get(cacheKey)
82
+ if (cached && cached.expiresAt > Date.now()) {
83
+ releaseNote.value = cached.data
84
+ return
85
+ }
86
+
87
+ isLoading.value = true
88
+ error.value = null
89
+
90
+ try {
91
+ const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/release-notes/${version}`
92
+ const response = await fetch(url, {
93
+ headers: {
94
+ Accept: 'application/json',
95
+ },
96
+ })
97
+
98
+ if (!response.ok) {
99
+ if (response.status === 404) {
100
+ error.value = `Release notes for version ${version} not found`
101
+ return
102
+ }
103
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
104
+ }
105
+
106
+ const data = await response.json()
107
+
108
+ // Normalize the response
109
+ const note: ReleaseNote = {
110
+ version: data.version,
111
+ title: data.title,
112
+ summary: data.summary || '',
113
+ notesMarkdown: data.notesMarkdown || data.notes || '',
114
+ changeCount: data.changeCount || 0,
115
+ releaseDate: data.releaseDate || data.releasedAt || '',
116
+ }
117
+
118
+ // Cache the result
119
+ releaseNotesCache.set(cacheKey, {
120
+ data: note,
121
+ expiresAt: Date.now() + CACHE_TTL,
122
+ })
123
+
124
+ releaseNote.value = note
125
+ } catch (e) {
126
+ error.value = e instanceof Error ? e.message : 'Failed to load release notes'
127
+ console.error('[@duffcloudservices/cms] Failed to fetch release notes:', e)
128
+ } finally {
129
+ isLoading.value = false
130
+ }
131
+ }
132
+
133
+ async function refresh(): Promise<void> {
134
+ // Clear cache
135
+ const cacheKey = `${siteSlug}:${version}`
136
+ releaseNotesCache.delete(cacheKey)
137
+ await fetchReleaseNotes()
138
+ }
139
+
140
+ // Fetch on mount if enabled
141
+ if (fetchOnMount && typeof window !== 'undefined') {
142
+ onMounted(() => {
143
+ fetchReleaseNotes()
144
+ })
145
+ }
146
+
147
+ return {
148
+ releaseNote,
149
+ isLoading,
150
+ error,
151
+ refresh,
152
+ }
153
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Vue 3 composable that resolves responsive image variants for DCS CDN-hosted assets.
3
+ *
4
+ * Wraps the framework-agnostic `resolveResponsiveImage` from `@duffcloudservices/cms-core`
5
+ * with reactive Vue refs so it can be used directly in `<script setup>` blocks.
6
+ *
7
+ * @example
8
+ * ```vue
9
+ * <script setup lang="ts">
10
+ * import { useResponsiveImage } from '@duffcloudservices/cms'
11
+ *
12
+ * const hero = useResponsiveImage({
13
+ * src: 'https://files.duffcloudservices.com/kept/assets/hero/abc-123.jpg',
14
+ * alt: 'Hero image',
15
+ * context: 'hero',
16
+ * })
17
+ * </script>
18
+ *
19
+ * <template>
20
+ * <picture v-if="hero.hasVariants">
21
+ * <source
22
+ * v-for="source in hero.sources"
23
+ * :key="source.type"
24
+ * :srcset="source.srcset"
25
+ * :type="source.type"
26
+ * :sizes="source.sizes"
27
+ * />
28
+ * <img v-bind="hero.imgProps" class="w-full h-full object-cover" />
29
+ * </picture>
30
+ * <img v-else v-bind="hero.imgProps" class="w-full h-full object-cover" />
31
+ * </template>
32
+ * ```
33
+ */
34
+
35
+ import { computed, type MaybeRefOrGetter, toValue } from 'vue'
36
+ import {
37
+ resolveResponsiveImage,
38
+ type ImageContext,
39
+ type ResponsiveImageResult,
40
+ } from '@duffcloudservices/cms-core'
41
+
42
+ export interface UseResponsiveImageOptions {
43
+ /** Source URL — can be a reactive ref, getter, or plain string. */
44
+ src: MaybeRefOrGetter<string>
45
+ /** Alt text — can be a reactive ref, getter, or plain string. */
46
+ alt: MaybeRefOrGetter<string>
47
+ /** Sizing context — determines which variants to include. */
48
+ context?: MaybeRefOrGetter<ImageContext | undefined>
49
+ /** Optional `sizes` attribute override. */
50
+ sizes?: MaybeRefOrGetter<string | undefined>
51
+ /** Skip variant resolution and use the original URL only. */
52
+ original?: MaybeRefOrGetter<boolean | undefined>
53
+ }
54
+
55
+ /**
56
+ * Reactively resolves responsive image metadata for a DCS CDN URL.
57
+ *
58
+ * The returned object is a computed ref that recomputes whenever any
59
+ * of the input refs change. Spread `imgProps` onto an `<img>` or
60
+ * combine with `sources` inside a `<picture>` element.
61
+ */
62
+ export function useResponsiveImage(options: UseResponsiveImageOptions): ResponsiveImageResult {
63
+ const result = computed(() =>
64
+ resolveResponsiveImage({
65
+ src: toValue(options.src),
66
+ alt: toValue(options.alt),
67
+ context: toValue(options.context),
68
+ sizes: toValue(options.sizes),
69
+ original: toValue(options.original) ?? undefined,
70
+ }),
71
+ )
72
+
73
+ // Return a reactive proxy that delegates to the computed
74
+ return {
75
+ get imgProps() {
76
+ return result.value.imgProps
77
+ },
78
+ get sources() {
79
+ return result.value.sources
80
+ },
81
+ get hasVariants() {
82
+ return result.value.hasVariants
83
+ },
84
+ }
85
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * useSEO Composable
3
+ *
4
+ * Provides SEO configuration with build-time injection support from .dcs/seo.yaml.
5
+ * Generates meta tags, Open Graph, Twitter Cards, and JSON-LD structured data.
6
+ *
7
+ * @example
8
+ * ```vue
9
+ * <script setup lang="ts">
10
+ * import { useSEO } from '@duffcloudservices/cms'
11
+ *
12
+ * const { applyHead, getSchema, config } = useSEO('home')
13
+ *
14
+ * // Apply all meta tags
15
+ * applyHead()
16
+ *
17
+ * // Or customize before applying
18
+ * applyHead({
19
+ * title: 'Custom Override Title',
20
+ * schemas: [...getSchema(), customSchema]
21
+ * })
22
+ * </script>
23
+ * ```
24
+ */
25
+
26
+ import { computed, type ComputedRef } from 'vue'
27
+ import { useHead } from '@unhead/vue'
28
+ import type {
29
+ SeoConfiguration,
30
+ GlobalSeoConfig,
31
+ SeoOpenGraphConfig,
32
+ SeoTwitterConfig,
33
+ SeoSchemaConfig,
34
+ ResolvedPageSeo,
35
+ UseSeoReturn,
36
+ HeadOverrides,
37
+ } from '../types/seo'
38
+
39
+ // Declare the global injected by dcsSeoPlugin
40
+ declare const __DCS_SEO__: SeoConfiguration | undefined
41
+
42
+ /**
43
+ * Safely get build-time SEO configuration.
44
+ * Returns undefined if not available (no seo.yaml or plugin not configured).
45
+ */
46
+ function getBuildTimeSeo(): SeoConfiguration | undefined {
47
+ try {
48
+ if (typeof __DCS_SEO__ !== 'undefined' && __DCS_SEO__ !== null) {
49
+ return __DCS_SEO__
50
+ }
51
+ } catch {
52
+ // __DCS_SEO__ not defined - that's fine, use defaults
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ // =============================================================================
58
+ // Meta Tag Generation Utilities
59
+ // =============================================================================
60
+
61
+ interface HeadInput {
62
+ title?: string
63
+ titleTemplate?: string | ((title: string) => string)
64
+ meta?: Array<{ name?: string; property?: string; content: string }>
65
+ link?: Array<{ rel: string; href: string; hreflang?: string }>
66
+ script?: Array<{ type: string; children: string }>
67
+ }
68
+
69
+ /**
70
+ * Generate Open Graph meta tags from config
71
+ */
72
+ function generateOpenGraphMeta(
73
+ og: SeoOpenGraphConfig,
74
+ global: GlobalSeoConfig,
75
+ pageTitle: string,
76
+ pageDescription: string,
77
+ canonical: string
78
+ ): Array<{ property: string; content: string }> {
79
+ const tags: Array<{ property: string; content: string }> = []
80
+
81
+ tags.push({ property: 'og:title', content: og.title || pageTitle })
82
+ tags.push({ property: 'og:description', content: og.description || pageDescription })
83
+ tags.push({ property: 'og:url', content: og.url || canonical })
84
+ tags.push({ property: 'og:type', content: og.type || 'website' })
85
+
86
+ const image = og.image || global.images?.ogDefault
87
+ if (image) {
88
+ tags.push({ property: 'og:image', content: image })
89
+ if (og.imageAlt || pageTitle) {
90
+ tags.push({ property: 'og:image:alt', content: og.imageAlt || pageTitle })
91
+ }
92
+ if (og.imageWidth) {
93
+ tags.push({ property: 'og:image:width', content: String(og.imageWidth) })
94
+ }
95
+ if (og.imageHeight) {
96
+ tags.push({ property: 'og:image:height', content: String(og.imageHeight) })
97
+ }
98
+ }
99
+
100
+ if (global.siteName) {
101
+ tags.push({ property: 'og:site_name', content: global.siteName })
102
+ }
103
+
104
+ if (global.locale) {
105
+ tags.push({ property: 'og:locale', content: global.locale })
106
+ }
107
+
108
+ // Article-specific tags
109
+ if (og.type === 'article') {
110
+ if (og.publishedTime) {
111
+ tags.push({ property: 'article:published_time', content: og.publishedTime })
112
+ }
113
+ if (og.modifiedTime) {
114
+ tags.push({ property: 'article:modified_time', content: og.modifiedTime })
115
+ }
116
+ if (og.author) {
117
+ tags.push({ property: 'article:author', content: og.author })
118
+ }
119
+ if (og.section) {
120
+ tags.push({ property: 'article:section', content: og.section })
121
+ }
122
+ if (og.tags) {
123
+ og.tags.forEach((tag) => {
124
+ tags.push({ property: 'article:tag', content: tag })
125
+ })
126
+ }
127
+ }
128
+
129
+ return tags
130
+ }
131
+
132
+ /**
133
+ * Generate Twitter Card meta tags from config
134
+ */
135
+ function generateTwitterMeta(
136
+ twitter: SeoTwitterConfig,
137
+ global: GlobalSeoConfig,
138
+ pageTitle: string,
139
+ pageDescription: string
140
+ ): Array<{ name: string; content: string }> {
141
+ const tags: Array<{ name: string; content: string }> = []
142
+
143
+ tags.push({ name: 'twitter:card', content: twitter.card || 'summary_large_image' })
144
+ tags.push({ name: 'twitter:title', content: twitter.title || pageTitle })
145
+ tags.push({ name: 'twitter:description', content: twitter.description || pageDescription })
146
+
147
+ const image = twitter.image || global.images?.twitterDefault
148
+ if (image) {
149
+ tags.push({ name: 'twitter:image', content: image })
150
+ if (twitter.imageAlt || pageTitle) {
151
+ tags.push({ name: 'twitter:image:alt', content: twitter.imageAlt || pageTitle })
152
+ }
153
+ }
154
+
155
+ const site = twitter.site || global.social?.twitter
156
+ if (site) {
157
+ tags.push({ name: 'twitter:site', content: site.startsWith('@') ? site : `@${site}` })
158
+ }
159
+
160
+ if (twitter.creator) {
161
+ tags.push({
162
+ name: 'twitter:creator',
163
+ content: twitter.creator.startsWith('@') ? twitter.creator : `@${twitter.creator}`,
164
+ })
165
+ }
166
+
167
+ return tags
168
+ }
169
+
170
+ /**
171
+ * Generate JSON-LD script content from schemas
172
+ */
173
+ function generateJsonLd(schemas: SeoSchemaConfig[], global: GlobalSeoConfig): object[] {
174
+ return schemas.map((schema) => {
175
+ const base: Record<string, unknown> = {
176
+ '@context': 'https://schema.org',
177
+ '@type': schema.type,
178
+ }
179
+
180
+ // Merge properties
181
+ if (schema.properties) {
182
+ Object.assign(base, schema.properties)
183
+ }
184
+
185
+ // Auto-populate common properties from global config
186
+ if (schema.type === 'WebSite' && global.siteUrl && !base.url) {
187
+ base.url = global.siteUrl
188
+ }
189
+ if (schema.type === 'WebSite' && global.siteName && !base.name) {
190
+ base.name = global.siteName
191
+ }
192
+
193
+ return base
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Resolve page SEO by merging global defaults with page-specific config
199
+ */
200
+ function resolvePageSeo(
201
+ pageSlug: string,
202
+ pagePath: string | undefined,
203
+ seoConfig: SeoConfiguration | undefined
204
+ ): ResolvedPageSeo {
205
+ const global = seoConfig?.global ?? {}
206
+ const page = seoConfig?.pages?.[pageSlug] ?? {}
207
+
208
+ // Build canonical URL
209
+ let canonical = page.canonical || ''
210
+ if (!canonical && global.siteUrl) {
211
+ const path = pagePath ?? (pageSlug === 'home' ? '/' : `/${pageSlug}`)
212
+ canonical = `${global.siteUrl.replace(/\/$/, '')}${path}`
213
+ }
214
+
215
+ // Build title
216
+ let title = page.title || global.defaultTitle || pageSlug
217
+ if (!page.noTitleTemplate && global.titleTemplate) {
218
+ title = global.titleTemplate.replace('%s', title)
219
+ }
220
+
221
+ // Merge Open Graph
222
+ const openGraph: ResolvedPageSeo['openGraph'] = {
223
+ type: page.openGraph?.type || 'website',
224
+ title: page.openGraph?.title || page.title || global.defaultTitle || '',
225
+ description: page.openGraph?.description || page.description || global.defaultDescription || '',
226
+ ...page.openGraph,
227
+ }
228
+
229
+ // Merge Twitter
230
+ const twitter: ResolvedPageSeo['twitter'] = {
231
+ card: page.twitter?.card || 'summary_large_image',
232
+ ...page.twitter,
233
+ }
234
+
235
+ // Combine schemas (global + page)
236
+ const schemas = [...(global.schemas ?? []), ...(page.schemas ?? [])]
237
+
238
+ return {
239
+ title,
240
+ description: page.description || global.defaultDescription || '',
241
+ canonical,
242
+ robots: page.robots || global.robots || 'index, follow',
243
+ openGraph,
244
+ twitter,
245
+ schemas,
246
+ alternates: page.alternates ?? [],
247
+ }
248
+ }
249
+
250
+ /**
251
+ * useSEO composable for DCS-managed SEO configuration.
252
+ *
253
+ * @param pageSlug - Page slug matching entry in seo.yaml
254
+ * @param pagePath - Optional page path for canonical URL generation
255
+ * @returns SEO helpers and state
256
+ */
257
+ export function useSEO(pageSlug: string, pagePath?: string): UseSeoReturn {
258
+ const seoConfig = getBuildTimeSeo()
259
+ const hasBuildTimeSeo = seoConfig !== undefined
260
+
261
+ // Computed resolved config
262
+ const config: ComputedRef<ResolvedPageSeo> = computed(() =>
263
+ resolvePageSeo(pageSlug, pagePath, seoConfig)
264
+ )
265
+
266
+ /**
267
+ * Get JSON-LD schema objects for the page
268
+ */
269
+ function getSchema(): object[] {
270
+ return generateJsonLd(config.value.schemas, seoConfig?.global ?? {})
271
+ }
272
+
273
+ /**
274
+ * Get canonical URL for the page
275
+ */
276
+ function getCanonical(): string {
277
+ return config.value.canonical
278
+ }
279
+
280
+ /**
281
+ * Apply all meta tags via useHead
282
+ */
283
+ function applyHead(overrides?: HeadOverrides): void {
284
+ const resolved = config.value
285
+ const global = seoConfig?.global ?? {}
286
+
287
+ const title = overrides?.title ?? resolved.title
288
+ const description = overrides?.description ?? resolved.description
289
+
290
+ // Build meta tags
291
+ const meta: HeadInput['meta'] = []
292
+
293
+ // Basic meta
294
+ meta.push({ name: 'description', content: description })
295
+ if (resolved.robots) {
296
+ meta.push({ name: 'robots', content: resolved.robots })
297
+ }
298
+
299
+ // Verification codes
300
+ if (global.verification?.google) {
301
+ meta.push({ name: 'google-site-verification', content: global.verification.google })
302
+ }
303
+ if (global.verification?.bing) {
304
+ meta.push({ name: 'msvalidate.01', content: global.verification.bing })
305
+ }
306
+
307
+ // Open Graph
308
+ const ogMeta = generateOpenGraphMeta(
309
+ resolved.openGraph,
310
+ global,
311
+ title,
312
+ description,
313
+ resolved.canonical
314
+ )
315
+ meta.push(...ogMeta.map((t) => ({ property: t.property, content: t.content })))
316
+
317
+ // Twitter
318
+ const twitterMeta = generateTwitterMeta(resolved.twitter, global, title, description)
319
+ meta.push(...twitterMeta.map((t) => ({ name: t.name, content: t.content })))
320
+
321
+ // Additional overrides
322
+ if (overrides?.meta) {
323
+ meta.push(...overrides.meta)
324
+ }
325
+
326
+ // Build links
327
+ const link: HeadInput['link'] = []
328
+
329
+ // Canonical
330
+ if (resolved.canonical) {
331
+ link.push({ rel: 'canonical', href: resolved.canonical })
332
+ }
333
+
334
+ // Alternate languages
335
+ resolved.alternates.forEach((alt) => {
336
+ link.push({ rel: 'alternate', href: alt.href, hreflang: alt.hreflang })
337
+ })
338
+
339
+ // Build scripts (JSON-LD)
340
+ const schemas = overrides?.schemas ?? getSchema()
341
+ const script: HeadInput['script'] = schemas.map((schema) => ({
342
+ type: 'application/ld+json',
343
+ children: JSON.stringify(schema),
344
+ }))
345
+
346
+ // Apply via useHead
347
+ useHead({
348
+ title,
349
+ meta,
350
+ link,
351
+ script,
352
+ })
353
+ }
354
+
355
+ return {
356
+ config,
357
+ applyHead,
358
+ getSchema,
359
+ getCanonical,
360
+ hasBuildTimeSeo,
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Create a typed useSEO function with site-specific defaults.
366
+ * Useful for creating a site-wide wrapper.
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * // composables/useSiteSeo.ts
371
+ * import { createSiteSEO } from '@duffcloudservices/cms'
372
+ *
373
+ * export const useSiteSeo = createSiteSEO({
374
+ * siteName: 'My Site',
375
+ * siteUrl: 'https://example.com'
376
+ * })
377
+ * ```
378
+ */
379
+ export function createSiteSEO(
380
+ _siteDefaults: Partial<GlobalSeoConfig>
381
+ ): (pageSlug: string, pagePath?: string) => UseSeoReturn {
382
+ return function siteUseSEO(pageSlug: string, pagePath?: string): UseSeoReturn {
383
+ // Note: siteDefaults would be used if we needed to override at runtime
384
+ // but build-time injection handles this via dcsSeoPlugin
385
+ return useSEO(pageSlug, pagePath)
386
+ }
387
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * useSiteVersion Composable
3
+ *
4
+ * Gets the current site version for footer badges and version displays.
5
+ * Fetches the latest release version from the DCS Portal API.
6
+ *
7
+ * @example
8
+ * ```vue
9
+ * <script setup lang="ts">
10
+ * import { useSiteVersion } from '@duffcloudservices/cms'
11
+ *
12
+ * const { version, releaseNotesUrl } = useSiteVersion()
13
+ * </script>
14
+ *
15
+ * <template>
16
+ * <footer>
17
+ * <a v-if="version" :href="releaseNotesUrl" class="version-badge">
18
+ * v{{ version }}
19
+ * </a>
20
+ * </footer>
21
+ * </template>
22
+ * ```
23
+ */
24
+
25
+ import { ref, computed, onMounted } from 'vue'
26
+ import type { SiteVersionReturn } from '../types/release-notes'
27
+
28
+ /**
29
+ * Get environment variable value.
30
+ */
31
+ function getEnvVar(key: string, defaultValue = ''): string {
32
+ try {
33
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
34
+ const value = (import.meta.env as Record<string, string | undefined>)[key]
35
+ if (value !== undefined) return value
36
+ }
37
+ } catch {
38
+ // import.meta not available
39
+ }
40
+ return defaultValue
41
+ }
42
+
43
+ // Cache for site version
44
+ let versionCache: { version: string; expiresAt: number } | null = null
45
+ const CACHE_TTL = 10 * 60 * 1000 // 10 minutes
46
+
47
+ /**
48
+ * useSiteVersion composable for displaying the current site version.
49
+ *
50
+ * @param options - Optional configuration
51
+ * @returns Site version data and computed URL
52
+ */
53
+ export function useSiteVersion(options: { fetchOnMount?: boolean } = {}): SiteVersionReturn {
54
+ const { fetchOnMount = true } = options
55
+
56
+ const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', 'https://portal.duffcloudservices.com')
57
+ const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
58
+
59
+ const version = ref<string | null>(null)
60
+ const isLoading = ref(false)
61
+
62
+ const releaseNotesUrl = computed(() => {
63
+ if (!version.value) return '/releaseNotes/latest'
64
+ return `/releaseNotes/${version.value}`
65
+ })
66
+
67
+ async function fetchVersion(): Promise<void> {
68
+ if (!siteSlug) {
69
+ return
70
+ }
71
+
72
+ // Check cache
73
+ if (versionCache && versionCache.expiresAt > Date.now()) {
74
+ version.value = versionCache.version
75
+ return
76
+ }
77
+
78
+ isLoading.value = true
79
+
80
+ try {
81
+ // Fetch the latest release notes to get the version
82
+ const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/release-notes/latest`
83
+ const response = await fetch(url, {
84
+ headers: {
85
+ Accept: 'application/json',
86
+ },
87
+ })
88
+
89
+ if (!response.ok) {
90
+ // No release notes yet - that's fine
91
+ return
92
+ }
93
+
94
+ const data = await response.json()
95
+ const latestVersion = data.version
96
+
97
+ // Cache the result
98
+ versionCache = {
99
+ version: latestVersion,
100
+ expiresAt: Date.now() + CACHE_TTL,
101
+ }
102
+
103
+ version.value = latestVersion
104
+ } catch (e) {
105
+ console.error('[@duffcloudservices/cms] Failed to fetch site version:', e)
106
+ } finally {
107
+ isLoading.value = false
108
+ }
109
+ }
110
+
111
+ // Fetch on mount if enabled
112
+ if (fetchOnMount && typeof window !== 'undefined') {
113
+ onMounted(() => {
114
+ fetchVersion()
115
+ })
116
+ }
117
+
118
+ return {
119
+ version,
120
+ isLoading,
121
+ releaseNotesUrl,
122
+ }
123
+ }
@@ -0,0 +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
+ }