@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.
@@ -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
+ }