@duffcloudservices/cms 0.5.1 → 0.7.1
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-FUNIALH6.js +1003 -0
- package/dist/chunk-FUNIALH6.js.map +1 -0
- package/dist/chunk-KCWMS7P4.js +3 -0
- package/dist/chunk-KCWMS7P4.js.map +1 -0
- package/dist/index.d.ts +4 -195
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +72 -28
- package/dist/plugins/index.js +221 -12
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +248 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/vitepressTransform-y8Ru9elj.d.ts +971 -0
- package/package.json +8 -3
- package/src/components/ManagedImage.test.ts +75 -0
- package/src/components/ManagedImage.vue +19 -3
- package/src/components/ResponsiveImage.test.ts +47 -0
- package/src/components/ResponsiveImage.vue +15 -3
- package/dist/chunk-JJK7OGC2.js +0 -459
- package/dist/chunk-JJK7OGC2.js.map +0 -1
- package/dist/vitepressTransform-D9P9D7So.d.ts +0 -479
|
@@ -0,0 +1,971 @@
|
|
|
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
|
+
/**
|
|
268
|
+
* Framework-free schema.org JSON-LD builders shared by BOTH SEO emit paths.
|
|
269
|
+
*
|
|
270
|
+
* This is the single home for the structured-data "completeness" logic the DCS
|
|
271
|
+
* SEO factory needs: a cross-linked `@graph` knowledge spine (Organization /
|
|
272
|
+
* WebSite / LocalBusiness), `BreadcrumbList`, `BlogPosting`, `FAQPage`, and the
|
|
273
|
+
* HONEST `Review` / `aggregateRating` injection. Both call sites consume it:
|
|
274
|
+
*
|
|
275
|
+
* - the Vue-SPA per-route emitter (`buildHeadTags` in `./headTags.ts`), and
|
|
276
|
+
* - the VitePress `transformPageData` factory (`./vitepressTransform.ts`).
|
|
277
|
+
*
|
|
278
|
+
* Every export here is a PURE function of plain inputs (no Vue, no Vite, no
|
|
279
|
+
* filesystem) returning plain `schema.org` objects, so the two paths stay
|
|
280
|
+
* byte-identical and the builder logic is never duplicated.
|
|
281
|
+
*
|
|
282
|
+
* ── HONESTY (non-negotiable) ────────────────────────────────────────────────
|
|
283
|
+
* `Review`, `aggregateRating`, and `FAQPage` are emitted ONLY from REAL data:
|
|
284
|
+
* • Review/aggregateRating come from `.dcs/content.yaml` review items that
|
|
285
|
+
* carry a numeric `rating`, a non-empty `text`, and an `authorName`.
|
|
286
|
+
* • FAQPage comes from a structured Q&A source (frontmatter `faq: [{q,a}]` or
|
|
287
|
+
* `.dcs/faq.yaml`) where each entry has a non-empty question AND answer.
|
|
288
|
+
* When the real source is missing or empty the builder short-circuits to an
|
|
289
|
+
* empty result — it NEVER synthesises a placeholder rating, review, or Q&A. A
|
|
290
|
+
* fabricated rating is a legal + trust risk; this module makes fabrication
|
|
291
|
+
* impossible by construction (no defaults, no invented counts).
|
|
292
|
+
*/
|
|
293
|
+
|
|
294
|
+
/** A plain schema.org object (already shaped for JSON-LD serialisation). */
|
|
295
|
+
type SchemaObject = Record<string, unknown>;
|
|
296
|
+
/** Return true when a schema `@type` is a LocalBusiness (sub)type. */
|
|
297
|
+
declare function isLocalBusinessType(type: string | undefined): boolean;
|
|
298
|
+
/**
|
|
299
|
+
* A real review item, mirroring the shape stored in `.dcs/content.yaml` under a
|
|
300
|
+
* `reviews.<key>.items` array (see `useReviewContent`). Only the fields the
|
|
301
|
+
* honest Review/aggregateRating builder needs are typed here; extra fields are
|
|
302
|
+
* ignored.
|
|
303
|
+
*/
|
|
304
|
+
interface ReviewSource {
|
|
305
|
+
rating?: unknown;
|
|
306
|
+
text?: unknown;
|
|
307
|
+
authorName?: unknown;
|
|
308
|
+
date?: unknown;
|
|
309
|
+
locationName?: unknown;
|
|
310
|
+
}
|
|
311
|
+
/** A structured FAQ entry: `{ q, a }` (frontmatter) — honesty-gated. */
|
|
312
|
+
interface FaqSource {
|
|
313
|
+
q?: unknown;
|
|
314
|
+
a?: unknown;
|
|
315
|
+
/** Alternate keys some sources use (`question`/`answer`). */
|
|
316
|
+
question?: unknown;
|
|
317
|
+
answer?: unknown;
|
|
318
|
+
}
|
|
319
|
+
/** A single breadcrumb hop along the route to the current page. */
|
|
320
|
+
interface BreadcrumbCrumb {
|
|
321
|
+
/** Human-readable name (e.g. `Home`, `Services`, the post title). */
|
|
322
|
+
name: string;
|
|
323
|
+
/** Absolute URL for this hop. */
|
|
324
|
+
item: string;
|
|
325
|
+
}
|
|
326
|
+
/** Blog-post metadata used to build a `BlogPosting`, from the SPA or VitePress. */
|
|
327
|
+
interface BlogMeta {
|
|
328
|
+
/** The headline / post title. */
|
|
329
|
+
headline?: string;
|
|
330
|
+
/** ISO-ish publish date as authored (granularity preserved, e.g. `2026-01`). */
|
|
331
|
+
datePublished?: string;
|
|
332
|
+
/** ISO-ish modified date, if distinct. */
|
|
333
|
+
dateModified?: string;
|
|
334
|
+
/** Absolute canonical URL of the post (becomes `mainEntityOfPage`). */
|
|
335
|
+
url?: string;
|
|
336
|
+
/** Header/social image URL for the post. */
|
|
337
|
+
image?: string;
|
|
338
|
+
/** Short description / excerpt. */
|
|
339
|
+
description?: string;
|
|
340
|
+
}
|
|
341
|
+
/** Stable `@id` anchors derived from the site URL (no new YAML required). */
|
|
342
|
+
declare function graphIds(siteUrl: string): {
|
|
343
|
+
organization: string;
|
|
344
|
+
website: string;
|
|
345
|
+
localBusiness: string;
|
|
346
|
+
};
|
|
347
|
+
/**
|
|
348
|
+
* Derive an `sameAs` array of absolute profile URLs from the global `social`
|
|
349
|
+
* block. Returns `[]` when nothing is configured (so the key is omitted, never
|
|
350
|
+
* emitted empty).
|
|
351
|
+
*/
|
|
352
|
+
declare function deriveSameAs(global: GlobalSeoConfig): string[];
|
|
353
|
+
/**
|
|
354
|
+
* Find the LocalBusiness-subtype entry in `global.schemas`, if any. This is the
|
|
355
|
+
* NAP-complete node a site already hand-authors; the graph builder absorbs it
|
|
356
|
+
* (rather than letting `generateJsonLd` emit a second, unlinked copy).
|
|
357
|
+
*
|
|
358
|
+
* @returns the matching `SeoSchemaConfig` and its index, or `null`.
|
|
359
|
+
*/
|
|
360
|
+
declare function findLocalBusinessSchema(global: GlobalSeoConfig): {
|
|
361
|
+
schema: SeoSchemaConfig;
|
|
362
|
+
index: number;
|
|
363
|
+
} | null;
|
|
364
|
+
/**
|
|
365
|
+
* True when `buildGlobalGraph` provides this schema canonically, so it must NOT
|
|
366
|
+
* also be emitted standalone: the LocalBusiness subtype it folds in, or a global
|
|
367
|
+
* `Organization` / `WebSite` (the graph emits cross-linked versions of both).
|
|
368
|
+
*
|
|
369
|
+
* Shared by both emit paths so the de-dup rule is identical.
|
|
370
|
+
*/
|
|
371
|
+
declare function graphAbsorbs(schema: SeoSchemaConfig, global: GlobalSeoConfig): boolean;
|
|
372
|
+
/** One normalised, REAL review (passed the honesty gate). */
|
|
373
|
+
interface NormalisedReview {
|
|
374
|
+
rating: number;
|
|
375
|
+
text: string;
|
|
376
|
+
authorName: string;
|
|
377
|
+
date?: string;
|
|
378
|
+
locationName?: string;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Keep ONLY real review items: a numeric `rating`, a non-empty `text`, and a
|
|
382
|
+
* non-empty `authorName`. Anything missing any of the three is dropped (never
|
|
383
|
+
* back-filled). Returns `[]` when nothing qualifies.
|
|
384
|
+
*/
|
|
385
|
+
declare function filterRealReviews(items: ReviewSource[] | undefined): NormalisedReview[];
|
|
386
|
+
/** A `Review` schema.org node array + an `aggregateRating`, both honesty-gated. */
|
|
387
|
+
interface ReviewSchemaParts {
|
|
388
|
+
/** `Review` nodes (one per real item). Empty when no real reviews. */
|
|
389
|
+
review: SchemaObject[];
|
|
390
|
+
/** `AggregateRating` node, or `undefined` when no real reviews. */
|
|
391
|
+
aggregateRating?: SchemaObject;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Build `Review[]` + `aggregateRating` from REAL review items only.
|
|
395
|
+
*
|
|
396
|
+
* Each `ratingValue` is CLAMPED to `[worstRating, bestRating]` (F3): a source
|
|
397
|
+
* rating of 7 (or 0/-1) would otherwise emit an out-of-range, schema-invalid
|
|
398
|
+
* value (and skew the aggregate). The aggregate mean is computed from the SAME
|
|
399
|
+
* clamped values so the headline rating matches the displayed reviews.
|
|
400
|
+
*
|
|
401
|
+
* `aggregateRating.ratingValue` is the mean (rounded to one decimal),
|
|
402
|
+
* `reviewCount`/`ratingCount` equal the real item count. When there are zero
|
|
403
|
+
* real items, BOTH are omitted — never an invented rating or count.
|
|
404
|
+
*/
|
|
405
|
+
declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): ReviewSchemaParts;
|
|
406
|
+
/**
|
|
407
|
+
* Build the cross-linked global knowledge graph as ONE JSON-LD object carrying
|
|
408
|
+
* a `@graph` array: `Organization`, `WebSite` (publisher → org), and the site's
|
|
409
|
+
* `LocalBusiness` node (parentOrganization → org), auto-derived from
|
|
410
|
+
* `global.siteName` / `global.siteUrl` / `global.images.logo` / `global.social`.
|
|
411
|
+
* Requires NO new YAML.
|
|
412
|
+
*
|
|
413
|
+
* The LocalBusiness node ABSORBS the existing `global.schemas[*]` LocalBusiness
|
|
414
|
+
* subtype (its NAP/geo/hours/offers are preserved verbatim) and is promoted with
|
|
415
|
+
* an `@id`; honest `review` + `aggregateRating` are merged in when supplied.
|
|
416
|
+
*
|
|
417
|
+
* Returns `[]` when there is no `siteUrl` (no stable `@id` anchor possible), so
|
|
418
|
+
* the existing per-schema emission is left untouched for un-configured sites.
|
|
419
|
+
*
|
|
420
|
+
* @param opts.reviews REAL review items (from content.yaml) for the business
|
|
421
|
+
* node. Optional; when omitted/empty no Review/aggregateRating is added.
|
|
422
|
+
*/
|
|
423
|
+
declare function buildGlobalGraph(global: GlobalSeoConfig, opts?: {
|
|
424
|
+
reviews?: ReviewSource[];
|
|
425
|
+
}): SchemaObject[];
|
|
426
|
+
/**
|
|
427
|
+
* Build a `BreadcrumbList` from an ordered trail of crumbs (Home → … → current).
|
|
428
|
+
*
|
|
429
|
+
* Honesty/cleanliness rules:
|
|
430
|
+
* • The home page (a trail of length ≤ 1, i.e. just "Home") emits NOTHING —
|
|
431
|
+
* a single-item breadcrumb is noise.
|
|
432
|
+
* • Positions are 1-based and contiguous.
|
|
433
|
+
*
|
|
434
|
+
* @returns a single-element array `[BreadcrumbList]`, or `[]` for the home page.
|
|
435
|
+
*/
|
|
436
|
+
declare function buildBreadcrumbList(trail: BreadcrumbCrumb[]): SchemaObject[];
|
|
437
|
+
/**
|
|
438
|
+
* Derive a Home → … → current breadcrumb trail from a route path.
|
|
439
|
+
*
|
|
440
|
+
* Each path segment becomes a crumb; the segment label comes from
|
|
441
|
+
* `titles[segmentPath]` (an absolute-route → title map) when present, else a
|
|
442
|
+
* slug-derived Title Case of the segment (never blank).
|
|
443
|
+
*
|
|
444
|
+
* @param route the page route, e.g. `/`, `/services`, `/blog/my-post`.
|
|
445
|
+
* @param siteUrl normalised site URL (no trailing slash).
|
|
446
|
+
* @param titles optional map of route → human title for intermediate hops
|
|
447
|
+
* AND the leaf (e.g. `{ '/': 'Home', '/blog': 'Blog',
|
|
448
|
+
* '/blog/my-post': 'My Post' }`). Missing entries fall back to
|
|
449
|
+
* a slug-derived title.
|
|
450
|
+
* @param homeName label for the root crumb (default `Home`).
|
|
451
|
+
*/
|
|
452
|
+
declare function breadcrumbTrailFromRoute(route: string, siteUrl: string, titles?: Record<string, string>, homeName?: string): BreadcrumbCrumb[];
|
|
453
|
+
/** Title-case a slug segment (`my-post` → `My Post`); never blank. */
|
|
454
|
+
declare function slugToTitle(slug: string): string;
|
|
455
|
+
/**
|
|
456
|
+
* Build a single `BlogPosting`.
|
|
457
|
+
*
|
|
458
|
+
* `author`/`publisher` are emitted as SELF-CONTAINED Organization nodes — each
|
|
459
|
+
* carrying its `@id`, plus a concrete `name` and `url`. This is the F2 fix: a
|
|
460
|
+
* bare `{ '@id': … }` ref DANGLES when `emitBlogPosting` runs WITHOUT the global
|
|
461
|
+
* `@graph` (the VitePress path with `emitGraph: false`), because nothing then
|
|
462
|
+
* defines the Organization node that `@id` points at. An inline node is valid
|
|
463
|
+
* standalone AND still carries the `@id`, so when the `@graph` IS present a
|
|
464
|
+
* consumer merges the two by `@id` (no duplication, no dangle) either way.
|
|
465
|
+
*
|
|
466
|
+
* `datePublished` granularity is preserved exactly as authored (e.g. a
|
|
467
|
+
* year-month `2026-01` is NOT padded to a fabricated day).
|
|
468
|
+
*
|
|
469
|
+
* @param global Optional global config — supplies the publisher/author `name`.
|
|
470
|
+
* When omitted (or no `siteName`), the inline node still carries `@id` + `url`
|
|
471
|
+
* so the reference never dangles.
|
|
472
|
+
* @returns `[BlogPosting]`, or `[]` when there is no headline (nothing to emit).
|
|
473
|
+
*/
|
|
474
|
+
declare function buildBlogPosting(meta: BlogMeta, siteUrl: string, global?: GlobalSeoConfig): SchemaObject[];
|
|
475
|
+
/** One normalised, REAL FAQ pair (passed the honesty gate). */
|
|
476
|
+
interface NormalisedFaq {
|
|
477
|
+
question: string;
|
|
478
|
+
answer: string;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Keep ONLY real FAQ entries: a non-empty question AND a non-empty answer.
|
|
482
|
+
* Tolerant of both `{ q, a }` and `{ question, answer }` shapes. Returns `[]`
|
|
483
|
+
* when nothing qualifies (so no FAQPage is emitted).
|
|
484
|
+
*/
|
|
485
|
+
declare function filterRealFaq(entries: FaqSource[] | undefined): NormalisedFaq[];
|
|
486
|
+
/**
|
|
487
|
+
* Build a `FAQPage` from a STRUCTURED Q&A source ONLY (frontmatter `faq:` or a
|
|
488
|
+
* `.dcs/faq.yaml`). A `<FaqPage />` Vue component is NOT a valid source — there
|
|
489
|
+
* is no machine-readable Q&A to read, so this correctly emits nothing.
|
|
490
|
+
*
|
|
491
|
+
* @returns `[FAQPage]` with one `Question` per real entry, or `[]` when there is
|
|
492
|
+
* no valid structured Q&A.
|
|
493
|
+
*/
|
|
494
|
+
declare function buildFaqPage(entries: FaqSource[] | undefined): SchemaObject[];
|
|
495
|
+
/**
|
|
496
|
+
* The relevant slice of `.dcs/content.yaml`: flat dotted keys live under
|
|
497
|
+
* `global` and per-page maps. Reviews are stored under `reviews.<key>.items`
|
|
498
|
+
* (e.g. iron-oak `reviews.reviews.items`, kept `reviews.testimonials.items`),
|
|
499
|
+
* so the key differs per site — `findReviewItemsForPage` scans tolerantly.
|
|
500
|
+
*/
|
|
501
|
+
interface ContentConfig {
|
|
502
|
+
global?: Record<string, unknown>;
|
|
503
|
+
pages?: Record<string, Record<string, unknown>>;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Find the REAL review items for a page from `.dcs/content.yaml`, tolerant of
|
|
507
|
+
* the per-site key naming (`reviews.reviews.items` vs `reviews.testimonials.
|
|
508
|
+
* items`). Prefers a page-scoped block, then falls back to global; within a
|
|
509
|
+
* block it picks the FIRST `reviews.*.items` array (sites carry one canonical
|
|
510
|
+
* source). Returns `[]` when none is present — no synthesis.
|
|
511
|
+
*
|
|
512
|
+
* The honesty filter still runs downstream (`buildReviewSchemaParts`), so a
|
|
513
|
+
* non-empty return here is NOT yet a guarantee of emission; items without a
|
|
514
|
+
* rating/text/authorName are dropped there.
|
|
515
|
+
*/
|
|
516
|
+
declare function findReviewItemsForPage(content: ContentConfig | undefined, pageSlug: string): ReviewSource[];
|
|
517
|
+
/**
|
|
518
|
+
* Guarantee an absolute URL. When `value` is already absolute (`http(s)://`) it
|
|
519
|
+
* is returned unchanged; when it is a site-relative path it is joined onto the
|
|
520
|
+
* (trailing-slash-trimmed) `siteUrl`; when it is empty the `fallback` (typically
|
|
521
|
+
* the already-absolute canonical) is returned.
|
|
522
|
+
*/
|
|
523
|
+
declare function absolutizeUrl(value: string | undefined, siteUrl: string | undefined, fallback?: string): string;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Loader for the `.dcs/pages.yaml` route manifest.
|
|
527
|
+
*
|
|
528
|
+
* `pages.yaml` is the canonical page registry maintained by the DCS portal and
|
|
529
|
+
* by Copilot when scaffolding pages. For the build-time SEO emitter we only
|
|
530
|
+
* need each route's `slug` and `path` (e.g. `{ slug: 'home', path: '/' }`).
|
|
531
|
+
*
|
|
532
|
+
* The parser is intentionally defensive: any missing/unparseable file or
|
|
533
|
+
* malformed entry yields `null` (caller logs + no-ops) so a bad manifest can
|
|
534
|
+
* never break a production build.
|
|
535
|
+
*/
|
|
536
|
+
/** A single route extracted from `.dcs/pages.yaml`. */
|
|
537
|
+
interface PageRouteEntry {
|
|
538
|
+
/** Page slug, matching an entry in `seo.yaml` `pages.<slug>` (may be absent). */
|
|
539
|
+
slug: string;
|
|
540
|
+
/** Route path, e.g. `/`, `/services`, `/blog/my-post`. */
|
|
541
|
+
path: string;
|
|
542
|
+
/**
|
|
543
|
+
* Human title from the manifest (e.g. "Kitchen Cabinet Refresh"). Used as the
|
|
544
|
+
* per-route title fallback when `seo.yaml` has no entry for this page, so
|
|
545
|
+
* un-configured routes (e.g. blog posts) get unique titles. Optional.
|
|
546
|
+
*/
|
|
547
|
+
title?: string;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Resolve and parse `.dcs/pages.yaml` from one of the usual locations.
|
|
551
|
+
*
|
|
552
|
+
* Mirrors the `.dcs` path resolution used elsewhere in the package (project
|
|
553
|
+
* root, parent dir for VitePress-style nesting, and cwd).
|
|
554
|
+
*
|
|
555
|
+
* @returns the list of `{ slug, path }` routes, or `null` if the file is
|
|
556
|
+
* absent, unreadable, unparseable, or contains no usable page entries.
|
|
557
|
+
*/
|
|
558
|
+
declare function loadPagesManifest(projectRoot: string, relativePagesPath: string, debug?: boolean): PageRouteEntry[] | null;
|
|
559
|
+
/**
|
|
560
|
+
* Extract `{ slug, path }` routes from an already-parsed manifest object.
|
|
561
|
+
* Exposed separately so tests can exercise it without touching the filesystem.
|
|
562
|
+
*
|
|
563
|
+
* Entries missing a string `path` are skipped; a missing slug falls back to the
|
|
564
|
+
* empty string (so the route still gets baked, using global SEO defaults).
|
|
565
|
+
*/
|
|
566
|
+
declare function parsePagesManifest(raw: unknown): PageRouteEntry[] | null;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Shared, PURE (fs-free) cores for the DCS site-file emitters:
|
|
570
|
+
* `sitemap.xml`, `robots.txt`, and `llms.txt`.
|
|
571
|
+
*
|
|
572
|
+
* This is the SINGLE SOURCE OF TRUTH for the three site-wide static files. Both
|
|
573
|
+
* cms's own `dcsSeoPlugin` (the "factory" emit path) and — as a follow-up on a
|
|
574
|
+
* separate branch — kit-vite's `dcsSitemapPlugin` import these cores so their
|
|
575
|
+
* outputs are byte-identical. The file name/shape deliberately mirror the
|
|
576
|
+
* proven kit-vite emitter (`packages/kit-vite/src/sitemap.ts`) so the eventual
|
|
577
|
+
* DRY port is a one-line re-export swap.
|
|
578
|
+
*
|
|
579
|
+
* These cores reuse cms's single SEO source of truth — `resolvePageSeo` for
|
|
580
|
+
* each route's canonical + robots — so the `<loc>` written into `sitemap.xml`
|
|
581
|
+
* (and the link written into `llms.txt`) is byte-identical to the
|
|
582
|
+
* `<link rel="canonical">` the SEO emitter bakes into each page's `<head>`. No
|
|
583
|
+
* head-tag or manifest logic is forked here.
|
|
584
|
+
*
|
|
585
|
+
* Everything in this module is pure string-in / string-out and never throws.
|
|
586
|
+
* The filesystem work lives in the plugin layer (`dcsSeoPlugin`), exactly like
|
|
587
|
+
* the existing `headTags` / `pagesManifest` split — so these cores stay
|
|
588
|
+
* Vue/Vite-free and unit-testable under jsdom.
|
|
589
|
+
*/
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* AI-crawler user agents emitted as explicit allow/deny groups in robots.txt so
|
|
593
|
+
* AI discovery is opt-in-friendly (and explicitly gated off in preview). A site
|
|
594
|
+
* can opt the whole tier out via `robots.aiBots: false`.
|
|
595
|
+
*/
|
|
596
|
+
declare const AI_BOTS: readonly ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
|
|
597
|
+
/**
|
|
598
|
+
* The SINGLE indexability predicate shared by `buildSitemapXml` and
|
|
599
|
+
* `buildLlmsTxt` so the two outputs always agree on which routes appear.
|
|
600
|
+
*
|
|
601
|
+
* A route is NOT indexable when: its path/slug is in `exclude`, OR its
|
|
602
|
+
* path/slug is in `noindex`, OR its path matches an `excludedGlobs` entry, OR
|
|
603
|
+
* its resolved robots (from `seo.yaml`) matches `/noindex/i`.
|
|
604
|
+
*/
|
|
605
|
+
declare function isRouteIndexable(route: PageRouteEntry, seoConfig: SeoConfiguration | undefined, opts?: {
|
|
606
|
+
exclude?: Set<string>;
|
|
607
|
+
noindex?: Set<string>;
|
|
608
|
+
excludedGlobs?: string[];
|
|
609
|
+
}): boolean;
|
|
610
|
+
interface BuildSitemapParams {
|
|
611
|
+
/** Route list (from `loadPagesManifest`). */
|
|
612
|
+
routes: PageRouteEntry[];
|
|
613
|
+
/** Canonical production origin (preferred base when no per-page canonical). */
|
|
614
|
+
siteUrl?: string;
|
|
615
|
+
/** cms seo config (passed straight through to `resolvePageSeo`). */
|
|
616
|
+
seoConfig?: SeoConfiguration;
|
|
617
|
+
/** Routes to skip entirely (path or slug). */
|
|
618
|
+
exclude?: string[];
|
|
619
|
+
/** Routes forced to noindex / omitted (path or slug). */
|
|
620
|
+
noindex?: string[];
|
|
621
|
+
/** `pages.yaml` top-level `excluded:` globs (coron8 parity). */
|
|
622
|
+
excludedGlobs?: string[];
|
|
623
|
+
/** Optional single site-wide `<lastmod>` (ISO date) — no fabricated per-page dates. */
|
|
624
|
+
lastmod?: string;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Build the sitemap XML string from a route list. Pure: routes + siteUrl +
|
|
628
|
+
* seoConfig in, XML out.
|
|
629
|
+
*
|
|
630
|
+
* A route is omitted when it is not indexable (see {@link isRouteIndexable}) OR
|
|
631
|
+
* no absolute `<loc>` is derivable (missing siteUrl + no canonical).
|
|
632
|
+
*
|
|
633
|
+
* Returns the empty string when no route yields an absolute `<loc>` (the caller
|
|
634
|
+
* treats this as a no-op signal — a sitemap with no absolute base is worse than
|
|
635
|
+
* none).
|
|
636
|
+
*/
|
|
637
|
+
declare function buildSitemapXml(params: BuildSitemapParams): string;
|
|
638
|
+
/** robots.txt override hooks. */
|
|
639
|
+
interface DcsRobotsOptions {
|
|
640
|
+
/** Emit a robots.txt at all (default `true`). */
|
|
641
|
+
enabled?: boolean;
|
|
642
|
+
/** `Disallow:` lines to emit (default `[]`). */
|
|
643
|
+
disallow?: string[];
|
|
644
|
+
/** `Allow:` lines to emit (default `['/']` in production). */
|
|
645
|
+
allow?: string[];
|
|
646
|
+
/** Raw lines appended verbatim after the generated directives. */
|
|
647
|
+
extra?: string[];
|
|
648
|
+
/**
|
|
649
|
+
* Emit explicit AI-crawler allow/deny groups (GPTBot, ClaudeBot,
|
|
650
|
+
* PerplexityBot, Google-Extended). Default `true` — each `Allow: /` in
|
|
651
|
+
* production, each `Disallow: /` in preview. Set `false` to drop the tier.
|
|
652
|
+
*/
|
|
653
|
+
aiBots?: boolean;
|
|
654
|
+
/**
|
|
655
|
+
* Overwrite an existing `dist/robots.txt` (e.g. a hand-authored
|
|
656
|
+
* `public/robots.txt` Vite already copied). Default `false` — do not clobber.
|
|
657
|
+
*/
|
|
658
|
+
force?: boolean;
|
|
659
|
+
}
|
|
660
|
+
interface BuildRobotsParams {
|
|
661
|
+
/** Canonical production origin (required for the absolute `Sitemap:` line). */
|
|
662
|
+
siteUrl?: string;
|
|
663
|
+
/** Preview / staging gate: `Disallow: /`, no `Sitemap:` line. */
|
|
664
|
+
preview?: boolean;
|
|
665
|
+
/** robots.txt override hooks. */
|
|
666
|
+
robots?: DcsRobotsOptions;
|
|
667
|
+
/** Whether a sitemap is being emitted (drives the `Sitemap:` line). */
|
|
668
|
+
hasSitemap?: boolean;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Build the robots.txt string. Pure: siteUrl + options in, text out.
|
|
672
|
+
*
|
|
673
|
+
* - **Preview mode** (`preview: true`): emits the privacy gate `User-agent: *`
|
|
674
|
+
* + `Disallow: /`, the same `Disallow: /` for each AI-bot tier (when
|
|
675
|
+
* enabled), and OMITS the `Sitemap:` line.
|
|
676
|
+
* - **Production**: `User-agent: *`, any `disallow`/`allow` lines (default
|
|
677
|
+
* `Allow: /`), explicit AI-bot allow groups (when enabled), a blank line,
|
|
678
|
+
* then an absolute `Sitemap: {siteUrl}/sitemap.xml` (trailing slash trimmed).
|
|
679
|
+
* The `Sitemap:` line is omitted when no sitemap is emitted or no `siteUrl`
|
|
680
|
+
* is known.
|
|
681
|
+
* - `extra` lines are appended verbatim.
|
|
682
|
+
*/
|
|
683
|
+
declare function buildRobotsTxt(params: BuildRobotsParams): string;
|
|
684
|
+
interface BuildLlmsParams {
|
|
685
|
+
/** Route list (from `loadPagesManifest`). */
|
|
686
|
+
routes: PageRouteEntry[];
|
|
687
|
+
/** Canonical production origin (preferred base when no per-page canonical). */
|
|
688
|
+
siteUrl?: string;
|
|
689
|
+
/** cms seo config (passed straight through to `resolvePageSeo`). */
|
|
690
|
+
seoConfig?: SeoConfiguration;
|
|
691
|
+
/** Routes to skip entirely (path or slug). */
|
|
692
|
+
exclude?: string[];
|
|
693
|
+
/** Routes forced to noindex / omitted (path or slug). */
|
|
694
|
+
noindex?: string[];
|
|
695
|
+
/** `pages.yaml` top-level `excluded:` globs (coron8 parity). */
|
|
696
|
+
excludedGlobs?: string[];
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Build the `llms.txt` plain-text body following the emerging llms.txt
|
|
700
|
+
* convention (Markdown-ish): an H1 `# {siteName}`, a one-line `> {summary}`
|
|
701
|
+
* blockquote, then a `## Pages` section listing each INDEXABLE route as a
|
|
702
|
+
* Markdown link `- [{title}]({canonical}): {description}`.
|
|
703
|
+
*
|
|
704
|
+
* Sourced from the SAME inputs as the sitemap (so there is zero new resolution
|
|
705
|
+
* path): `siteName`/`defaultDescription` from `seoConfig.global`, the URL list
|
|
706
|
+
* filtered through the IDENTICAL {@link isRouteIndexable} predicate, and per-page
|
|
707
|
+
* title/description/canonical from `resolvePageSeo`.
|
|
708
|
+
*
|
|
709
|
+
* Returns the empty string (no-op) when there is no `siteName` AND no indexable
|
|
710
|
+
* route with a derivable link.
|
|
711
|
+
*/
|
|
712
|
+
declare function buildLlmsTxt(params: BuildLlmsParams): string;
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Build-time SEO for VitePress static-site generation.
|
|
716
|
+
*
|
|
717
|
+
* VitePress 1.6 does **not** use `unhead`, so the runtime `useSEO`/`applyHead`
|
|
718
|
+
* composable is a no-op against the SSG HTML. The SSG-correct sink is the
|
|
719
|
+
* `transformPageData(pageData)` build hook: writing `<meta>`/`<link>`/JSON-LD
|
|
720
|
+
* into `pageData.frontmatter.head` (VitePress bakes those into the rendered
|
|
721
|
+
* `<head>`) and overwriting `pageData.title` / `pageData.description` (VitePress
|
|
722
|
+
* renders the `<title>` — via `titleTemplate` — and the `description` meta from
|
|
723
|
+
* those two fields).
|
|
724
|
+
*
|
|
725
|
+
* This factory generalises the bespoke `buildSeoHead`/`transformPageData` that
|
|
726
|
+
* shipped inline in a site's `.vitepress/config.ts`. It reuses the shared,
|
|
727
|
+
* framework-agnostic resolver (`resolvePageSeo`, `generateOpenGraphMeta`,
|
|
728
|
+
* `generateTwitterMeta`, `generateJsonLd`) for global + page meta / OG / Twitter
|
|
729
|
+
* / canonical and the global JSON-LD knowledge graph, and delegates **page-type
|
|
730
|
+
* JSON-LD** (e.g. Article / Place / CollectionPage / Service / FAQPage +
|
|
731
|
+
* BreadcrumbList) to a *pluggable* rule set the site supplies. None of the
|
|
732
|
+
* real-estate (or any other vertical's) schema logic lives in this package — it
|
|
733
|
+
* is all site CONFIG.
|
|
734
|
+
*
|
|
735
|
+
* It is the VitePress counterpart to the Vue-SPA per-route emitter in
|
|
736
|
+
* `dcsSeoPlugin({ emitStaticHtml: true })`; both produce identical global
|
|
737
|
+
* meta/OG/Twitter/JSON-LD from the same `seo.yaml` via the shared resolver.
|
|
738
|
+
*
|
|
739
|
+
* @example
|
|
740
|
+
* ```ts
|
|
741
|
+
* // docs/.vitepress/config.ts
|
|
742
|
+
* import { createSeoTransformPageData } from '@duffcloudservices/cms/plugins'
|
|
743
|
+
* import seoConfig from '../../.dcs/seo.yaml'
|
|
744
|
+
*
|
|
745
|
+
* export default defineConfig({
|
|
746
|
+
* transformPageData: createSeoTransformPageData({
|
|
747
|
+
* seoConfig,
|
|
748
|
+
* pageTypeRules: [
|
|
749
|
+
* { match: (ctx) => ctx.route.startsWith('/blogs/'), build: (ctx) => [ ... ] },
|
|
750
|
+
* // ...Place / CollectionPage / Service / FAQPage rules
|
|
751
|
+
* ],
|
|
752
|
+
* }),
|
|
753
|
+
* })
|
|
754
|
+
* ```
|
|
755
|
+
*/
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* A VitePress `head` entry. Mirrors VitePress's `HeadConfig` without taking a
|
|
759
|
+
* dependency on the `vitepress` package (which is not a dependency of this
|
|
760
|
+
* library). The tuple forms are:
|
|
761
|
+
* ['meta', { name|property, content }]
|
|
762
|
+
* ['link', { rel, href, ... }]
|
|
763
|
+
* ['script', { type: 'application/ld+json' }, '<serialised json>']
|
|
764
|
+
*/
|
|
765
|
+
type VitePressHeadConfig = [string, Record<string, string>] | [string, Record<string, string>, string];
|
|
766
|
+
/**
|
|
767
|
+
* The minimal slice of VitePress's `PageData` this factory reads and mutates.
|
|
768
|
+
* Typed structurally so callers can pass VitePress's real `PageData` without a
|
|
769
|
+
* cast and without this package importing `vitepress`.
|
|
770
|
+
*/
|
|
771
|
+
interface VitePressPageData {
|
|
772
|
+
/** Source-relative path, e.g. `index.md`, `blogs/my-post.md`. */
|
|
773
|
+
relativePath: string;
|
|
774
|
+
/** Dynamic-route params (e.g. `{ topic: 'home-buying' }`). */
|
|
775
|
+
params?: Record<string, unknown>;
|
|
776
|
+
/** Page frontmatter; `head` is appended to here. */
|
|
777
|
+
frontmatter: Record<string, any>;
|
|
778
|
+
/** VitePress page title (drives `<title>` via `titleTemplate`). */
|
|
779
|
+
title?: string;
|
|
780
|
+
/** VitePress page description (drives the `description` meta). */
|
|
781
|
+
description?: string;
|
|
782
|
+
[key: string]: unknown;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Context handed to the site's page-type rules and resolver hooks. Everything a
|
|
786
|
+
* site needs to derive its title/description/og/schemas for one page, computed
|
|
787
|
+
* once per page by the factory.
|
|
788
|
+
*/
|
|
789
|
+
interface SeoPageContext {
|
|
790
|
+
/** Route path, e.g. `/`, `/blogs/my-post`, `/locations/birmingham`. */
|
|
791
|
+
route: string;
|
|
792
|
+
/** Slug: `'home'` for `/`, otherwise the route without its leading slash. */
|
|
793
|
+
slug: string;
|
|
794
|
+
/** Absolute canonical URL for this route. */
|
|
795
|
+
canonical: string;
|
|
796
|
+
/** Normalised site base URL (no trailing slash), e.g. `https://example.com`. */
|
|
797
|
+
siteUrl: string;
|
|
798
|
+
/** The page's frontmatter (read-only convenience; same object as pageData). */
|
|
799
|
+
frontmatter: Record<string, any>;
|
|
800
|
+
/** The resolved global SEO config block. */
|
|
801
|
+
global: GlobalSeoConfig;
|
|
802
|
+
/** The full VitePress page data (for rules that need more than the above). */
|
|
803
|
+
pageData: VitePressPageData;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* The resolved per-page title / description / OG type a site may override.
|
|
807
|
+
* Returned by the optional `resolvePage` hook so a site can apply its own
|
|
808
|
+
* per-page-type title precedence (e.g. "{City} Luxury Real Estate") and decide
|
|
809
|
+
* whether that title should win over VitePress's `titleTemplate`.
|
|
810
|
+
*/
|
|
811
|
+
interface ResolvedPageOverrides {
|
|
812
|
+
/**
|
|
813
|
+
* The page title. When `setPageTitle` is true this is written to
|
|
814
|
+
* `pageData.title` (VitePress then applies `titleTemplate`).
|
|
815
|
+
*/
|
|
816
|
+
title?: string;
|
|
817
|
+
/**
|
|
818
|
+
* When true, `title` is written back to `pageData.title`. Leave false for
|
|
819
|
+
* pages whose frontmatter title should remain authoritative (e.g. blog posts
|
|
820
|
+
* that already carry a good `<h1>`/title).
|
|
821
|
+
*/
|
|
822
|
+
setPageTitle?: boolean;
|
|
823
|
+
/** The meta description. Written to `pageData.description` when truthy. */
|
|
824
|
+
description?: string;
|
|
825
|
+
/** Open Graph type override (e.g. `'article'`, `'profile'`). */
|
|
826
|
+
ogType?: SeoOpenGraphConfig['type'];
|
|
827
|
+
/** Open Graph image URL override (e.g. a post's header image). */
|
|
828
|
+
ogImage?: string;
|
|
829
|
+
/**
|
|
830
|
+
* Extra Open Graph fields to merge into the OG config (e.g. `publishedTime`,
|
|
831
|
+
* `modifiedTime`, `section`, `tags`). The shared OG generator emits the
|
|
832
|
+
* matching `article:*` tags when `ogType === 'article'`. This is how a site
|
|
833
|
+
* supplies article metadata (date/category) without that logic living in the
|
|
834
|
+
* package. Lower precedence than `ogType`/`ogImage`/`ogTitle`/`ogDescription`.
|
|
835
|
+
*/
|
|
836
|
+
og?: Partial<SeoOpenGraphConfig>;
|
|
837
|
+
/** Keywords override (comma-separated) for the `keywords` meta. */
|
|
838
|
+
keywords?: string;
|
|
839
|
+
/**
|
|
840
|
+
* Open Graph title override. Defaults to `title` so og:title tracks <title>.
|
|
841
|
+
*/
|
|
842
|
+
ogTitle?: string;
|
|
843
|
+
/**
|
|
844
|
+
* Open Graph description override. Defaults to `description`.
|
|
845
|
+
*/
|
|
846
|
+
ogDescription?: string;
|
|
847
|
+
}
|
|
848
|
+
/** A single pluggable page-type rule: when `match` is true, emit `build`. */
|
|
849
|
+
interface SeoPageTypeRule {
|
|
850
|
+
/** Return true when this rule applies to the page (by route/slug/etc.). */
|
|
851
|
+
match: (ctx: SeoPageContext) => boolean;
|
|
852
|
+
/** Build the page-type JSON-LD objects to emit (already plain objects). */
|
|
853
|
+
build: (ctx: SeoPageContext) => Array<Record<string, unknown>>;
|
|
854
|
+
}
|
|
855
|
+
interface CreateSeoTransformPageDataOptions {
|
|
856
|
+
/** The parsed `.dcs/seo.yaml` (global graph + per-page meta). */
|
|
857
|
+
seoConfig: SeoConfiguration | undefined;
|
|
858
|
+
/**
|
|
859
|
+
* Pluggable page-type rules. Evaluated in order; **every** matching rule's
|
|
860
|
+
* `build` output is emitted (so a route can contribute both a primary schema
|
|
861
|
+
* and a BreadcrumbList from one rule, or be matched by several). The
|
|
862
|
+
* real-estate BlogPosting / Place / CollectionPage / Service / FAQPage logic
|
|
863
|
+
* is supplied here by the site — never hardcoded in this package.
|
|
864
|
+
*/
|
|
865
|
+
pageTypeRules?: SeoPageTypeRule[];
|
|
866
|
+
/**
|
|
867
|
+
* Optional hook to override per-page title / description / OG before tags are
|
|
868
|
+
* built — the site's title precedence and per-type description fallbacks.
|
|
869
|
+
* Receives the same context as the rules. Anything it omits falls back to the
|
|
870
|
+
* resolver / frontmatter defaults.
|
|
871
|
+
*/
|
|
872
|
+
resolvePage?: (ctx: SeoPageContext) => ResolvedPageOverrides | undefined;
|
|
873
|
+
/**
|
|
874
|
+
* Map a `relativePath` (+ params) to a route. Defaults to a VitePress-correct
|
|
875
|
+
* implementation: `index` becomes `/`, a trailing `/index` is dropped, `.md`
|
|
876
|
+
* is stripped, and dynamic `[name]` segments are substituted from
|
|
877
|
+
* `pageData.params`. Override only for unusual routing.
|
|
878
|
+
*/
|
|
879
|
+
relativePathToRoute?: (relativePath: string, params?: Record<string, unknown>) => string;
|
|
880
|
+
/**
|
|
881
|
+
* Emit a `<meta name="keywords">` from the resolved/overridden keywords.
|
|
882
|
+
* Default true (parity with the bespoke KDH emitter, which emitted keywords).
|
|
883
|
+
*/
|
|
884
|
+
includeKeywords?: boolean;
|
|
885
|
+
/**
|
|
886
|
+
* Emit the cross-linked global `@graph` spine (Organization + WebSite + the
|
|
887
|
+
* promoted LocalBusiness node) in place of the flat per-schema global JSON-LD.
|
|
888
|
+
* When ON, the LocalBusiness subtype in `global.schemas` is ABSORBED into the
|
|
889
|
+
* graph (not emitted twice); other global schemas (Person, etc.) are still
|
|
890
|
+
* emitted standalone. Default `false`, so existing sites (which supply their
|
|
891
|
+
* own graph via `global.schemas`) are unchanged until they opt in.
|
|
892
|
+
*/
|
|
893
|
+
emitGraph?: boolean;
|
|
894
|
+
/**
|
|
895
|
+
* Emit an automatic `BreadcrumbList` derived from the route depth on every
|
|
896
|
+
* non-home page. Default `false` (sites that already emit breadcrumbs via
|
|
897
|
+
* `pageTypeRules` should leave this off to avoid duplicates). Intermediate /
|
|
898
|
+
* leaf crumb titles come from `breadcrumbTitles(ctx)` when provided, else a
|
|
899
|
+
* slug-derived Title Case.
|
|
900
|
+
*/
|
|
901
|
+
emitBreadcrumbs?: boolean;
|
|
902
|
+
/**
|
|
903
|
+
* Map a context to a `{ route → title }` map for breadcrumb hop labels (e.g.
|
|
904
|
+
* `{ '/': 'Home', '/blogs': 'Blog' }`). Missing entries fall back to a
|
|
905
|
+
* slug-derived title. Only consulted when `emitBreadcrumbs` is true.
|
|
906
|
+
*/
|
|
907
|
+
breadcrumbTitles?: (ctx: SeoPageContext) => Record<string, string> | undefined;
|
|
908
|
+
/**
|
|
909
|
+
* Emit an automatic `BlogPosting` (author/publisher as `@id` refs) for blog
|
|
910
|
+
* routes, derived from frontmatter (`title`/`date`/`image`/`description`).
|
|
911
|
+
* `blogMatch` decides which routes are posts; default off. Skipped when a
|
|
912
|
+
* `pageTypeRule` already emitted a `BlogPosting` for the page.
|
|
913
|
+
*/
|
|
914
|
+
emitBlogPosting?: boolean;
|
|
915
|
+
/** Predicate selecting blog-post routes for `emitBlogPosting`. */
|
|
916
|
+
blogMatch?: (ctx: SeoPageContext) => boolean;
|
|
917
|
+
/**
|
|
918
|
+
* Emit an automatic, honesty-gated `FAQPage` from `frontmatter.faq` (an array
|
|
919
|
+
* of `{ q, a }` / `{ question, answer }`). Emits nothing when the frontmatter
|
|
920
|
+
* carries no structured Q&A. Default `false`.
|
|
921
|
+
*/
|
|
922
|
+
emitFaq?: boolean;
|
|
923
|
+
/**
|
|
924
|
+
* Provide REAL review items for the LocalBusiness node in the `@graph` (only
|
|
925
|
+
* used when `emitGraph` is true). Honesty-gated downstream — items lacking a
|
|
926
|
+
* rating/text/authorName are dropped, and an empty result emits no Review or
|
|
927
|
+
* aggregateRating. Default: none.
|
|
928
|
+
*/
|
|
929
|
+
resolveReviews?: (ctx: SeoPageContext) => ReviewSource[] | undefined;
|
|
930
|
+
/** Enable debug logging of the emitted head per page. */
|
|
931
|
+
debug?: boolean;
|
|
932
|
+
}
|
|
933
|
+
/** Default VitePress route derivation (matches the bespoke KDH helper). */
|
|
934
|
+
declare function defaultRelativePathToRoute(relativePath: string, params?: Record<string, unknown>): string;
|
|
935
|
+
/**
|
|
936
|
+
* Build just the SEO head tuples for a page (no `pageData` mutation). Exposed
|
|
937
|
+
* separately so it is unit-testable without a VitePress `pageData` round-trip
|
|
938
|
+
* and reusable by callers that manage the `head`/`title` sinks themselves.
|
|
939
|
+
*
|
|
940
|
+
* @returns `{ head, title, description }` — the head tuples to append, and the
|
|
941
|
+
* final title/description (already overridden) the caller should write to
|
|
942
|
+
* `pageData` when `applyTitle`/`applyDescription` are appropriate.
|
|
943
|
+
*/
|
|
944
|
+
declare function buildVitePressSeoHead(pageData: VitePressPageData, options: CreateSeoTransformPageDataOptions): {
|
|
945
|
+
head: VitePressHeadConfig[];
|
|
946
|
+
title?: string;
|
|
947
|
+
description?: string;
|
|
948
|
+
setPageTitle: boolean;
|
|
949
|
+
};
|
|
950
|
+
/**
|
|
951
|
+
* Create a VitePress `transformPageData(pageData)` function that bakes DCS SEO
|
|
952
|
+
* (global meta/OG/Twitter/canonical + global JSON-LD graph + pluggable
|
|
953
|
+
* page-type JSON-LD) into the SSG `<head>`.
|
|
954
|
+
*
|
|
955
|
+
* Mutations performed on `pageData`:
|
|
956
|
+
* - **`frontmatter.head`** — the resolved tags are *appended* to any existing
|
|
957
|
+
* `head` (so site-level `head` config is preserved).
|
|
958
|
+
* - **`description`** — set to the resolved/overridden description so
|
|
959
|
+
* VitePress emits exactly one `description` meta (no duplicate; we do not
|
|
960
|
+
* push our own description meta).
|
|
961
|
+
* - **`title`** — set only when the site's `resolvePage` hook returns
|
|
962
|
+
* `setPageTitle: true` for this page, mirroring the bespoke behaviour where
|
|
963
|
+
* seo.yaml/per-type titles are authoritative but a post's frontmatter title
|
|
964
|
+
* is left intact.
|
|
965
|
+
*
|
|
966
|
+
* Defensive: never throws (a failure logs a warning and leaves `pageData`
|
|
967
|
+
* untouched), so SEO can never break a production VitePress build.
|
|
968
|
+
*/
|
|
969
|
+
declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
|
|
970
|
+
|
|
971
|
+
export { findLocalBusinessSchema as $, AI_BOTS as A, type BuildSitemapParams as B, type ContentConfig as C, type DcsRobotsOptions as D, type BlogMeta as E, type ReviewSource as F, type GlobalSeoConfig as G, type FaqSource as H, type PageSeoConfig as I, type SeoAuthorConfig as J, type SeoSocialConfig as K, type SeoImagesConfig as L, type SeoOpenGraphConfig as M, type SeoTwitterConfig as N, type SeoSchemaConfig as O, type PageRouteEntry as P, type SeoAlternateConfig as Q, type ResolvedPageOverrides as R, type SeoConfiguration as S, type SeoVerificationConfig as T, type UseSeoReturn as U, type VitePressPageData as V, type ResolvedPageSeo as W, type UseSeoConfig as X, type HeadOverrides as Y, loadPagesManifest as Z, parsePagesManifest as _, type CreateSeoTransformPageDataOptions as a, graphAbsorbs as a0, deriveSameAs as a1, graphIds as a2, isLocalBusinessType as a3, type NormalisedReview as a4, type NormalisedFaq as a5, type ReviewSchemaParts as a6, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, type SeoPageContext as e, type SeoPageTypeRule as f, type VitePressHeadConfig as g, buildSitemapXml as h, buildRobotsTxt as i, buildLlmsTxt as j, isRouteIndexable as k, buildGlobalGraph as l, buildBreadcrumbList as m, breadcrumbTrailFromRoute as n, buildBlogPosting as o, buildFaqPage as p, buildReviewSchemaParts as q, filterRealReviews as r, filterRealFaq as s, findReviewItemsForPage as t, absolutizeUrl as u, slugToTitle as v, type BuildRobotsParams as w, type BuildLlmsParams as x, type SchemaObject as y, type BreadcrumbCrumb as z };
|