@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.
- package/README.md +39 -4
- package/dist/chunk-RDYVYYTC.js +311 -0
- package/dist/chunk-RDYVYYTC.js.map +1 -0
- package/dist/index.d.ts +199 -263
- package/dist/index.js +14 -175
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +93 -16
- package/dist/plugins/index.js +167 -61
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo-DsJjfI1p.d.ts +267 -0
- package/package.json +4 -1
- package/src/components/ManagedImage.vue +66 -0
- package/src/components/PreviewRibbon.vue +4 -5
- package/src/components/ResponsiveImage.vue +11 -2
- package/src/composables/useReleaseNotes.ts +6 -7
- package/src/composables/useSEO.ts +21 -259
- package/src/composables/useSiteVersion.ts +5 -6
- package/src/composables/useTextContent.test.ts +46 -0
- package/src/composables/useTextContent.ts +13 -5
|
@@ -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
|
-
|
|
232
|
-
|
|
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/
|
|
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="
|
|
61
|
+
<img v-bind="imgAttrs" :class="props.class" />
|
|
53
62
|
</picture>
|
|
54
|
-
<img v-else v-bind="
|
|
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
|
-
|
|
75
|
-
|
|
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/
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|