@duffcloudservices/cms 0.3.17 → 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,267 @@
1
+ import * as vue from 'vue';
2
+
3
+ /**
4
+ * Types for .dcs/seo.yaml structure
5
+ * Matches contracts/generated/schemas/seo.json
6
+ */
7
+ /**
8
+ * Root structure of .dcs/seo.yaml
9
+ */
10
+ interface SeoConfiguration {
11
+ /** Schema version */
12
+ version: number;
13
+ /** ISO timestamp of last update */
14
+ lastUpdated?: string;
15
+ /** Email or identifier of who made the update */
16
+ updatedBy?: string;
17
+ /** Global/site-wide SEO defaults */
18
+ global?: GlobalSeoConfig;
19
+ /** Page-specific SEO configurations keyed by page slug */
20
+ pages?: Record<string, PageSeoConfig>;
21
+ }
22
+ /**
23
+ * Global/site-wide SEO configuration
24
+ */
25
+ interface GlobalSeoConfig {
26
+ /** Site name used in titles and structured data */
27
+ siteName?: string;
28
+ /** Base URL of the site (e.g., https://example.com) */
29
+ siteUrl?: string;
30
+ /** Locale for Open Graph (e.g., en_US) */
31
+ locale?: string;
32
+ /** Default page title */
33
+ defaultTitle?: string;
34
+ /** Default meta description */
35
+ defaultDescription?: string;
36
+ /** Title template with %s placeholder (e.g., "%s | Site Name") */
37
+ titleTemplate?: string;
38
+ /** Author information for structured data */
39
+ author?: SeoAuthorConfig;
40
+ /** Social media handles */
41
+ social?: SeoSocialConfig;
42
+ /** Default images for social sharing */
43
+ images?: SeoImagesConfig;
44
+ /** Default robots directive (e.g., "index, follow") */
45
+ robots?: string;
46
+ /** Global JSON-LD schemas (Organization, WebSite, etc.) */
47
+ schemas?: SeoSchemaConfig[];
48
+ /** Search engine verification codes */
49
+ verification?: SeoVerificationConfig;
50
+ }
51
+ /**
52
+ * Page-specific SEO configuration
53
+ */
54
+ interface PageSeoConfig {
55
+ /** Page title */
56
+ title?: string;
57
+ /** Meta description */
58
+ description?: string;
59
+ /** Meta keywords (comma-separated) */
60
+ keywords?: string;
61
+ /** Canonical URL */
62
+ canonical?: string;
63
+ /** Page-specific robots directive */
64
+ robots?: string;
65
+ /** Open Graph configuration */
66
+ openGraph?: SeoOpenGraphConfig;
67
+ /** Twitter Card configuration */
68
+ twitter?: SeoTwitterConfig;
69
+ /** Page-specific JSON-LD schemas */
70
+ schemas?: SeoSchemaConfig[];
71
+ /** Alternate language links */
72
+ alternates?: SeoAlternateConfig[];
73
+ /** If true, don't apply titleTemplate to this page */
74
+ noTitleTemplate?: boolean;
75
+ }
76
+ /**
77
+ * Author information for structured data
78
+ */
79
+ interface SeoAuthorConfig {
80
+ /** Author name */
81
+ name?: string;
82
+ /** Author email */
83
+ email?: string;
84
+ /** Author image URL */
85
+ image?: string;
86
+ /** Job title */
87
+ jobTitle?: string;
88
+ /** Social profile URLs */
89
+ sameAs?: string[];
90
+ }
91
+ /**
92
+ * Social media handles
93
+ */
94
+ interface SeoSocialConfig {
95
+ /** Twitter handle (without @) */
96
+ twitter?: string;
97
+ /** LinkedIn company or profile slug */
98
+ linkedin?: string;
99
+ /** GitHub username */
100
+ github?: string;
101
+ /** Facebook page name */
102
+ facebook?: string;
103
+ /** Instagram username */
104
+ instagram?: string;
105
+ /** YouTube channel */
106
+ youtube?: string;
107
+ }
108
+ /**
109
+ * Default images for social sharing
110
+ */
111
+ interface SeoImagesConfig {
112
+ /** Logo image URL */
113
+ logo?: string;
114
+ /** Default Open Graph image */
115
+ ogDefault?: string;
116
+ /** Default Twitter Card image */
117
+ twitterDefault?: string;
118
+ /** Favicon URL */
119
+ favicon?: string;
120
+ }
121
+ /**
122
+ * Open Graph meta configuration
123
+ */
124
+ interface SeoOpenGraphConfig {
125
+ /** OG title (defaults to page title) */
126
+ title?: string;
127
+ /** OG description (defaults to page description) */
128
+ description?: string;
129
+ /** OG image URL */
130
+ image?: string;
131
+ /** Alt text for OG image */
132
+ imageAlt?: string;
133
+ /** OG image width in pixels */
134
+ imageWidth?: number;
135
+ /** OG image height in pixels */
136
+ imageHeight?: number;
137
+ /** OG type */
138
+ type?: 'website' | 'article' | 'profile' | 'book' | 'music.song' | 'music.album' | 'video.movie' | 'video.episode' | 'video.tv_show' | 'video.other';
139
+ /** OG URL (defaults to canonical) */
140
+ url?: string;
141
+ /** Article published time (ISO 8601) */
142
+ publishedTime?: string;
143
+ /** Article modified time (ISO 8601) */
144
+ modifiedTime?: string;
145
+ /** Article author */
146
+ author?: string;
147
+ /** Article section/category */
148
+ section?: string;
149
+ /** Article tags */
150
+ tags?: string[];
151
+ }
152
+ /**
153
+ * Twitter Card configuration
154
+ */
155
+ interface SeoTwitterConfig {
156
+ /** Card type */
157
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player';
158
+ /** Twitter title */
159
+ title?: string;
160
+ /** Twitter description */
161
+ description?: string;
162
+ /** Twitter image URL */
163
+ image?: string;
164
+ /** Alt text for Twitter image */
165
+ imageAlt?: string;
166
+ /** Site's Twitter handle (without @) */
167
+ site?: string;
168
+ /** Content creator's Twitter handle (without @) */
169
+ creator?: string;
170
+ }
171
+ /**
172
+ * JSON-LD schema configuration
173
+ */
174
+ interface SeoSchemaConfig {
175
+ /** Schema.org type (e.g., "WebSite", "Organization", "Article") */
176
+ type: string;
177
+ /** Schema properties */
178
+ properties?: Record<string, unknown>;
179
+ }
180
+ /**
181
+ * Alternate language link
182
+ */
183
+ interface SeoAlternateConfig {
184
+ /** Language code (e.g., "en", "es", "x-default") */
185
+ hreflang: string;
186
+ /** URL of alternate version */
187
+ href: string;
188
+ }
189
+ /**
190
+ * Search engine verification codes
191
+ */
192
+ interface SeoVerificationConfig {
193
+ /** Google Search Console verification code */
194
+ google?: string;
195
+ /** Bing Webmaster Tools verification code */
196
+ bing?: string;
197
+ /** DuckDuckGo verification (reserved for future use) */
198
+ duckduckgo?: string;
199
+ }
200
+ /**
201
+ * Resolved page SEO configuration (after merging global + page)
202
+ */
203
+ interface ResolvedPageSeo {
204
+ /** Final page title */
205
+ title: string;
206
+ /** Final meta description */
207
+ description: string;
208
+ /** Page keywords (comma-separated), if configured */
209
+ keywords?: string;
210
+ /** Final canonical URL */
211
+ canonical: string;
212
+ /** Final robots directive */
213
+ robots: string;
214
+ /** Merged Open Graph configuration */
215
+ openGraph: Required<Pick<SeoOpenGraphConfig, 'title' | 'description' | 'type'>> & SeoOpenGraphConfig;
216
+ /** Merged Twitter configuration */
217
+ twitter: Required<Pick<SeoTwitterConfig, 'card'>> & SeoTwitterConfig;
218
+ /** All schemas (global + page) */
219
+ schemas: SeoSchemaConfig[];
220
+ /** Alternate links */
221
+ alternates: SeoAlternateConfig[];
222
+ }
223
+ /**
224
+ * Configuration for useSEO composable
225
+ */
226
+ interface UseSeoConfig {
227
+ /** Page slug matching entry in seo.yaml */
228
+ pageSlug: string;
229
+ /** Optional page path for canonical URL generation */
230
+ pagePath?: string;
231
+ }
232
+ /**
233
+ * Return type of useSEO composable
234
+ */
235
+ interface UseSeoReturn {
236
+ /** Computed page SEO configuration */
237
+ config: vue.ComputedRef<ResolvedPageSeo>;
238
+ /** Apply all meta tags via useHead */
239
+ applyHead: (overrides?: HeadOverrides) => void;
240
+ /** Get JSON-LD schema objects for the page */
241
+ getSchema: () => object[];
242
+ /** Get canonical URL for the page */
243
+ getCanonical: () => string;
244
+ /** Whether SEO config was loaded from build-time */
245
+ hasBuildTimeSeo: boolean;
246
+ }
247
+ /**
248
+ * Overrides that can be passed to applyHead
249
+ */
250
+ interface HeadOverrides {
251
+ /** Override title */
252
+ title?: string;
253
+ /** Override description */
254
+ description?: string;
255
+ /** Override keywords meta tag */
256
+ keywords?: string;
257
+ /** Additional or replacement schemas */
258
+ schemas?: object[];
259
+ /** Additional meta tags */
260
+ meta?: Array<{
261
+ name?: string;
262
+ property?: string;
263
+ content: string;
264
+ }>;
265
+ }
266
+
267
+ export type { GlobalSeoConfig as G, HeadOverrides as H, PageSeoConfig as P, ResolvedPageSeo as R, SeoConfiguration as S, UseSeoReturn as U, SeoSchemaConfig as a, SeoOpenGraphConfig as b, SeoTwitterConfig as c, SeoAuthorConfig as d, SeoSocialConfig as e, SeoImagesConfig as f, SeoAlternateConfig as g, SeoVerificationConfig as h, UseSeoConfig as i };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.3.17",
3
+ "version": "0.4.0",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 {