@duffcloudservices/cms 0.3.12 → 0.3.14
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 +332 -309
- package/dist/editor/editorBridge.js +127 -50
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/index.js +59 -13
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/package.json +90 -90
- package/src/components/DcsReviewShowcase.vue +321 -326
- package/src/components/PreviewRibbon.vue +612 -612
- package/src/components/ResponsiveImage.vue +55 -55
- package/src/composables/index.ts +10 -10
- package/src/composables/useMediaCarousel.ts +158 -158
- package/src/composables/useReleaseNotes.ts +153 -153
- package/src/composables/useResponsiveImage.ts +85 -85
- package/src/composables/useReviewContent.ts +150 -92
- package/src/composables/useSEO.ts +387 -387
- package/src/composables/useSiteVersion.ts +123 -123
- package/src/composables/useTextContent.ts +297 -297
|
@@ -1,387 +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
|
-
}
|
|
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
|
+
}
|