@duffcloudservices/cms 0.3.16 → 0.4.0

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.
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed, useAttrs } from 'vue'
3
+ import { useResponsiveImage } from '../composables/useResponsiveImage'
4
+ import { useTextContent } from '../composables/useTextContent'
5
+ import type { ImageContext } from '@duffcloudservices/cms-core'
6
+
7
+ defineOptions({ inheritAttrs: false })
8
+
9
+ const props = withDefaults(defineProps<{
10
+ pageSlug: string
11
+ imageKey: string
12
+ fallbackSrc: string
13
+ altKey?: string
14
+ fallbackAlt?: string
15
+ context?: ImageContext
16
+ sizes?: string
17
+ original?: boolean
18
+ }>(), {
19
+ altKey: '',
20
+ fallbackAlt: '',
21
+ context: 'content',
22
+ sizes: undefined,
23
+ original: false,
24
+ })
25
+
26
+ const attrs = useAttrs()
27
+ const { t } = useTextContent({
28
+ pageSlug: props.pageSlug,
29
+ defaults: {
30
+ [props.imageKey]: props.fallbackSrc,
31
+ ...(props.altKey ? { [props.altKey]: props.fallbackAlt } : {}),
32
+ },
33
+ })
34
+
35
+ const imageSrc = computed(() => t(props.imageKey, props.fallbackSrc))
36
+ const imageAlt = computed(() => (props.altKey ? t(props.altKey, props.fallbackAlt) : props.fallbackAlt))
37
+ const image = useResponsiveImage({
38
+ src: imageSrc,
39
+ alt: imageAlt,
40
+ context: () => props.context,
41
+ sizes: () => props.sizes,
42
+ original: () => props.original,
43
+ })
44
+
45
+ const imgAttrs = computed(() => ({
46
+ ...image.imgProps,
47
+ ...attrs,
48
+ 'data-dcs-image-key': props.imageKey,
49
+ 'data-dcs-image-url': imageSrc.value,
50
+ 'data-dcs-image-alt': imageAlt.value,
51
+ }))
52
+ </script>
53
+
54
+ <template>
55
+ <picture v-if="image.hasVariants && !original">
56
+ <source
57
+ v-for="source in image.sources"
58
+ :key="source.type"
59
+ :srcset="source.srcset"
60
+ :type="source.type"
61
+ :sizes="source.sizes"
62
+ />
63
+ <img v-bind="imgAttrs" />
64
+ </picture>
65
+ <img v-else v-bind="imgAttrs" />
66
+ </template>
@@ -228,15 +228,14 @@ onMounted(async () => {
228
228
  }
229
229
 
230
230
  try {
231
- const slug =
232
- (import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
233
- ''
234
- if (!slug) return
231
+ // The site is resolved server-side (request Host / DCS_SITE_SLUG); the slug
232
+ // is no longer encoded in the path. VITE_SITE_SLUG is retained for source
233
+ // compatibility but is not required here.
235
234
  const base =
236
235
  (import.meta.env as Record<string, string | undefined>)
237
236
  .VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
238
237
  const res = await fetch(
239
- `${base}/api/v1/sites/${slug}/release-notes/latest`,
238
+ `${base}/api/v1/release-notes/latest`,
240
239
  { headers: { Accept: 'application/json' } },
241
240
  )
242
241
  if (res.ok) {
@@ -13,9 +13,12 @@
13
13
  * />
14
14
  * ```
15
15
  */
16
+ import { computed, useAttrs } from 'vue'
16
17
  import { useResponsiveImage } from '../composables/useResponsiveImage'
17
18
  import type { ImageContext } from '@duffcloudservices/cms-core'
18
19
 
20
+ defineOptions({ inheritAttrs: false })
21
+
19
22
  const props = defineProps<{
20
23
  /** Image source URL (original CDN URL or local path). */
21
24
  src: string
@@ -38,6 +41,12 @@ const image = useResponsiveImage({
38
41
  sizes: () => props.sizes,
39
42
  original: () => props.original,
40
43
  })
44
+
45
+ const attrs = useAttrs()
46
+ const imgAttrs = computed(() => ({
47
+ ...image.imgProps,
48
+ ...attrs,
49
+ }))
41
50
  </script>
42
51
 
43
52
  <template>
@@ -49,7 +58,7 @@ const image = useResponsiveImage({
49
58
  :type="source.type"
50
59
  :sizes="source.sizes"
51
60
  />
52
- <img v-bind="image.imgProps" :class="props.class" />
61
+ <img v-bind="imgAttrs" :class="props.class" />
53
62
  </picture>
54
- <img v-else v-bind="image.imgProps" :class="props.class" />
63
+ <img v-else v-bind="imgAttrs" :class="props.class" />
55
64
  </template>
@@ -64,6 +64,9 @@ export function useReleaseNotes(
64
64
  const { fetchOnMount = true } = options
65
65
 
66
66
  const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', 'https://portal.duffcloudservices.com')
67
+ // @deprecated VITE_SITE_SLUG: the site is now resolved server-side from the
68
+ // request Host or the dedicated Container App's DCS_SITE_SLUG. It is retained
69
+ // only for cache-key continuity and source compatibility, not for routing.
67
70
  const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
68
71
 
69
72
  const releaseNote = ref<ReleaseNote | null>(null)
@@ -71,12 +74,8 @@ export function useReleaseNotes(
71
74
  const error = ref<string | null>(null)
72
75
 
73
76
  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
77
+ // Check cache. The slug is no longer required for routing (server resolves
78
+ // the site from Host / DCS_SITE_SLUG); it is only part of the cache key.
80
79
  const cacheKey = `${siteSlug}:${version}`
81
80
  const cached = releaseNotesCache.get(cacheKey)
82
81
  if (cached && cached.expiresAt > Date.now()) {
@@ -88,7 +87,7 @@ export function useReleaseNotes(
88
87
  error.value = null
89
88
 
90
89
  try {
91
- const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/release-notes/${version}`
90
+ const url = `${apiBaseUrl}/api/v1/release-notes/${version}`
92
91
  const response = await fetch(url, {
93
92
  headers: {
94
93
  Accept: 'application/json',
@@ -4,6 +4,10 @@
4
4
  * Provides SEO configuration with build-time injection support from .dcs/seo.yaml.
5
5
  * Generates meta tags, Open Graph, Twitter Cards, and JSON-LD structured data.
6
6
  *
7
+ * The actual tag resolution lives in the framework-agnostic `../seo/headTags`
8
+ * module so that the build-time static-HTML emitter (`dcsSeoPlugin`) produces
9
+ * byte-identical output. This composable is a thin Vue/unhead wrapper over it.
10
+ *
7
11
  * @example
8
12
  * ```vue
9
13
  * <script setup lang="ts">
@@ -28,13 +32,11 @@ import { useHead } from '@unhead/vue'
28
32
  import type {
29
33
  SeoConfiguration,
30
34
  GlobalSeoConfig,
31
- SeoOpenGraphConfig,
32
- SeoTwitterConfig,
33
- SeoSchemaConfig,
34
35
  ResolvedPageSeo,
35
36
  UseSeoReturn,
36
37
  HeadOverrides,
37
38
  } from '../types/seo'
39
+ import { buildHeadTags, resolvePageSeo, generateJsonLd } from '../seo/headTags'
38
40
 
39
41
  // Declare the global injected by dcsSeoPlugin
40
42
  declare const __DCS_SEO__: SeoConfiguration | undefined
@@ -54,199 +56,6 @@ function getBuildTimeSeo(): SeoConfiguration | undefined {
54
56
  return undefined
55
57
  }
56
58
 
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
59
  /**
251
60
  * useSEO composable for DCS-managed SEO configuration.
252
61
  *
@@ -278,78 +87,31 @@ export function useSEO(pageSlug: string, pagePath?: string): UseSeoReturn {
278
87
  }
279
88
 
280
89
  /**
281
- * Apply all meta tags via useHead
90
+ * Apply all meta tags via useHead.
91
+ *
92
+ * Delegates to the shared `buildHeadTags` resolver so the emitted tags match
93
+ * the build-time static-HTML emitter exactly. Keywords are intentionally not
94
+ * emitted at runtime (historical behaviour), so `includeKeywords` is omitted.
282
95
  */
283
96
  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 })
97
+ const { title, meta, link, script } = buildHeadTags(pageSlug, pagePath, seoConfig, {
98
+ title: overrides?.title,
99
+ description: overrides?.description,
100
+ keywords: overrides?.keywords,
101
+ schemas: overrides?.schemas,
102
+ meta: overrides?.meta,
337
103
  })
338
104
 
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
105
+ // Apply via useHead. The shared resolver returns framework-agnostic tag
106
+ // shapes (HeadMetaTag/HeadLinkTag/HeadScriptTag); unhead's input types are
107
+ // structurally compatible but add an open-ended `data-*` index signature,
108
+ // so we hand them over via useHead's Head input type. Runtime is identical.
347
109
  useHead({
348
110
  title,
349
111
  meta,
350
112
  link,
351
113
  script,
352
- })
114
+ } as unknown as Parameters<typeof useHead>[0])
353
115
  }
354
116
 
355
117
  return {
@@ -54,7 +54,10 @@ export function useSiteVersion(options: { fetchOnMount?: boolean } = {}): SiteVe
54
54
  const { fetchOnMount = true } = options
55
55
 
56
56
  const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', 'https://portal.duffcloudservices.com')
57
- const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
57
+ // @deprecated VITE_SITE_SLUG: the site is now resolved server-side from the
58
+ // request Host or the dedicated Container App's DCS_SITE_SLUG. It is no longer
59
+ // used for routing; retained only for source compatibility.
60
+ void getEnvVar('VITE_SITE_SLUG', '')
58
61
 
59
62
  const version = ref<string | null>(null)
60
63
  const isLoading = ref(false)
@@ -65,10 +68,6 @@ export function useSiteVersion(options: { fetchOnMount?: boolean } = {}): SiteVe
65
68
  })
66
69
 
67
70
  async function fetchVersion(): Promise<void> {
68
- if (!siteSlug) {
69
- return
70
- }
71
-
72
71
  // Check cache
73
72
  if (versionCache && versionCache.expiresAt > Date.now()) {
74
73
  version.value = versionCache.version
@@ -79,7 +78,7 @@ export function useSiteVersion(options: { fetchOnMount?: boolean } = {}): SiteVe
79
78
 
80
79
  try {
81
80
  // Fetch the latest release notes to get the version
82
- const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/release-notes/latest`
81
+ const url = `${apiBaseUrl}/api/v1/release-notes/latest`
83
82
  const response = await fetch(url, {
84
83
  headers: {
85
84
  Accept: 'application/json',
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
2
+ import { useTextContent } from './useTextContent'
3
+
4
+ /**
5
+ * Pins the slug-free URL shape for runtime text-override fetches.
6
+ *
7
+ * The site is resolved server-side (request Host or the dedicated Container
8
+ * App's DCS_SITE_SLUG), so VITE_SITE_SLUG must NOT appear in the request path.
9
+ */
10
+ describe('useTextContent runtime URL (slug-free)', () => {
11
+ beforeEach(() => {
12
+ vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com')
13
+ vi.stubEnv('VITE_TEXT_OVERRIDE_MODE', 'runtime')
14
+ // Still set for source compatibility — must be ignored for routing.
15
+ vi.stubEnv('VITE_SITE_SLUG', 'kept')
16
+ })
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllEnvs()
20
+ vi.restoreAllMocks()
21
+ })
22
+
23
+ it('fetches /api/v1/pages/{pageSlug}/text without the site slug in the path', async () => {
24
+ const fetchMock = vi.fn().mockResolvedValue({
25
+ ok: true,
26
+ status: 200,
27
+ json: async () => ({ overrides: { 'hero.title': 'Hi' } }),
28
+ })
29
+ vi.stubGlobal('fetch', fetchMock)
30
+
31
+ // fetchOnMount: false avoids registering onMounted outside a component
32
+ // setup context; refresh() drives the same fetch path directly.
33
+ const { refresh } = useTextContent({
34
+ pageSlug: 'home',
35
+ defaults: {},
36
+ fetchOnMount: false,
37
+ })
38
+ await refresh()
39
+
40
+ expect(fetchMock).toHaveBeenCalledTimes(1)
41
+ const [url] = fetchMock.mock.calls[0]
42
+ expect(url).toBe('https://api.example.com/api/v1/pages/home/text')
43
+ expect(url).not.toContain('/sites/')
44
+ expect(url).not.toContain('kept')
45
+ })
46
+ })
@@ -111,7 +111,11 @@ export function useTextContent(config: TextContentConfig): TextContentReturn {
111
111
  cacheTtl = 60000,
112
112
  } = config
113
113
 
114
- // Get configuration from environment
114
+ // Get configuration from environment.
115
+ // @deprecated VITE_SITE_SLUG: the site is now resolved server-side from the
116
+ // request Host or the dedicated Container App's DCS_SITE_SLUG. It is still read
117
+ // here for cache-key continuity and source compatibility, but is no longer used
118
+ // to build the request URL.
115
119
  const apiBaseUrl = getEnvVar('VITE_API_BASE_URL', '')
116
120
  const siteSlug = getEnvVar('VITE_SITE_SLUG', '')
117
121
  const textOverrideMode = getEnvVar('VITE_TEXT_OVERRIDE_MODE', 'commit')
@@ -202,10 +206,12 @@ export function useTextContent(config: TextContentConfig): TextContentReturn {
202
206
  return
203
207
  }
204
208
 
205
- // Skip if API not configured
206
- if (!apiBaseUrl || !siteSlug) {
209
+ // Skip if API not configured. The site is resolved server-side (Host /
210
+ // DCS_SITE_SLUG), so VITE_SITE_SLUG is no longer required for routing —
211
+ // only the API base URL is needed.
212
+ if (!apiBaseUrl) {
207
213
  console.warn(
208
- '[@duffcloudservices/cms] Runtime mode enabled but VITE_API_BASE_URL or VITE_SITE_SLUG not set'
214
+ '[@duffcloudservices/cms] Runtime mode enabled but VITE_API_BASE_URL not set'
209
215
  )
210
216
  return
211
217
  }
@@ -222,7 +228,9 @@ export function useTextContent(config: TextContentConfig): TextContentReturn {
222
228
  error.value = null
223
229
 
224
230
  try {
225
- const url = `${apiBaseUrl}/api/v1/sites/${siteSlug}/pages/${pageSlug}/text`
231
+ // Site is resolved server-side from the request Host or the dedicated
232
+ // Container App's DCS_SITE_SLUG; the slug is no longer encoded in the path.
233
+ const url = `${apiBaseUrl}/api/v1/pages/${pageSlug}/text`
226
234
  const response = await fetch(url, {
227
235
  headers: {
228
236
  Accept: 'application/json',