@duffcloudservices/cms 0.3.17 → 0.5.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.
@@ -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 {