@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.
- 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 +8 -162
- 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 +1 -1
- package/src/composables/useSEO.ts +21 -259
|
@@ -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
|
@@ -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 {
|