@duffcloudservices/cms 0.6.0 → 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.
@@ -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 };