import * as vue from 'vue'; /** * Types for .dcs/seo.yaml structure * Matches contracts/generated/schemas/seo.json */ /** * Root structure of .dcs/seo.yaml */ interface SeoConfiguration { /** Schema version */ version: number; /** ISO timestamp of last update */ lastUpdated?: string; /** Email or identifier of who made the update */ updatedBy?: string; /** Global/site-wide SEO defaults */ global?: GlobalSeoConfig; /** Page-specific SEO configurations keyed by page slug */ pages?: Record; /** * Per-site escape hatch for build-time BODY prerender (default ON when the * static-HTML emitter is enabled). Set `false` to disable body prerender for * this one site with NO code change and NO cms republish — the portal owns * `.dcs/seo.yaml`, so flipping this and rebuilding is enough. Head meta + * JSON-LD emission is unaffected either way. */ prerenderBody?: boolean; /** * Per-site escape hatch for the P1 baked-vs-rendered `` assert (C-334). * `false`/`'off'` disables it, `'warn'` reports without gating, `'error'` * (default) fails the build. Lives here so the decision is portal-owned, in * git, and reviewable — there is no silent way to switch the gate off. */ headHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock; /** * Per-site escape hatch for the P2 emitted-URL resolution assert (C-334). * Same shape as {@link headHonesty}; `allow` lists URLs that are knowingly * unresolvable and are still logged on every build. */ urlHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock; /** * Per-site escape hatch for the P12 `` head-budget assert * (C-334). Set `'warn'` to report a document whose encoding declaration falls * outside the 1024-byte sniffing window without failing the build. */ charsetBudget?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock; } /** * The expanded form of a `.dcs/seo.yaml` honesty-rail escape hatch — a severity * plus an explicit, logged allow-list. */ interface SeoHonestyBlock { /** `error` (default) | `warn` | `off`. */ mode?: 'error' | 'warn' | 'off'; /** * Routes (P1) or URLs/paths (P2) knowingly exempted. Every entry is printed on * every build, so an exemption can never quietly become permanent. */ allow?: string[]; /** P1 only: also compare the meta description (default `true`). */ checkDescription?: boolean; /** P2 only: set `false` to skip the cross-origin network probes entirely. */ network?: boolean; } /** * Global/site-wide SEO configuration */ interface GlobalSeoConfig { /** Site name used in titles and structured data */ siteName?: string; /** Base URL of the site (e.g., https://example.com) */ siteUrl?: string; /** Locale for Open Graph (e.g., en_US) */ locale?: string; /** Default page title */ defaultTitle?: string; /** Default meta description */ defaultDescription?: string; /** Title template with %s placeholder (e.g., "%s | Site Name") */ titleTemplate?: string; /** Author information for structured data */ author?: SeoAuthorConfig; /** Social media handles */ social?: SeoSocialConfig; /** Default images for social sharing */ images?: SeoImagesConfig; /** Default robots directive (e.g., "index, follow") */ robots?: string; /** Global JSON-LD schemas (Organization, WebSite, etc.) */ schemas?: SeoSchemaConfig[]; /** Search engine verification codes */ verification?: SeoVerificationConfig; } /** * Page-specific SEO configuration */ interface PageSeoConfig { /** Page title */ title?: string; /** Meta description */ description?: string; /** Meta keywords (comma-separated) */ keywords?: string; /** Canonical URL */ canonical?: string; /** Page-specific robots directive */ robots?: string; /** Open Graph configuration */ openGraph?: SeoOpenGraphConfig; /** Twitter Card configuration */ twitter?: SeoTwitterConfig; /** Page-specific JSON-LD schemas */ schemas?: SeoSchemaConfig[]; /** Alternate language links */ alternates?: SeoAlternateConfig[]; /** If true, don't apply titleTemplate to this page */ noTitleTemplate?: boolean; } /** * Author information for structured data */ interface SeoAuthorConfig { /** Author name */ name?: string; /** Author email */ email?: string; /** Author image URL */ image?: string; /** Job title */ jobTitle?: string; /** Social profile URLs */ sameAs?: string[]; } /** * Social media handles */ interface SeoSocialConfig { /** Twitter handle (without @) */ twitter?: string; /** LinkedIn company or profile slug */ linkedin?: string; /** GitHub username */ github?: string; /** Facebook page name */ facebook?: string; /** Instagram username */ instagram?: string; /** YouTube channel */ youtube?: string; } /** * Default images for social sharing */ interface SeoImagesConfig { /** Logo image URL */ logo?: string; /** Default Open Graph image */ ogDefault?: string; /** Default Twitter Card image */ twitterDefault?: string; /** Favicon URL */ favicon?: string; } /** * Open Graph meta configuration */ interface SeoOpenGraphConfig { /** OG title (defaults to page title) */ title?: string; /** OG description (defaults to page description) */ description?: string; /** OG image URL */ image?: string; /** Alt text for OG image */ imageAlt?: string; /** OG image width in pixels */ imageWidth?: number; /** OG image height in pixels */ imageHeight?: number; /** OG type */ type?: 'website' | 'article' | 'profile' | 'book' | 'music.song' | 'music.album' | 'video.movie' | 'video.episode' | 'video.tv_show' | 'video.other'; /** OG URL (defaults to canonical) */ url?: string; /** Article published time (ISO 8601) */ publishedTime?: string; /** Article modified time (ISO 8601) */ modifiedTime?: string; /** Article author */ author?: string; /** Article section/category */ section?: string; /** Article tags */ tags?: string[]; } /** * Twitter Card configuration */ interface SeoTwitterConfig { /** Card type */ card?: 'summary' | 'summary_large_image' | 'app' | 'player'; /** Twitter title */ title?: string; /** Twitter description */ description?: string; /** Twitter image URL */ image?: string; /** Alt text for Twitter image */ imageAlt?: string; /** Site's Twitter handle (without @) */ site?: string; /** Content creator's Twitter handle (without @) */ creator?: string; } /** * JSON-LD schema configuration */ interface SeoSchemaConfig { /** Schema.org type (e.g., "WebSite", "Organization", "Article") */ type: string; /** Schema properties */ properties?: Record; } /** * Alternate language link */ interface SeoAlternateConfig { /** Language code (e.g., "en", "es", "x-default") */ hreflang: string; /** URL of alternate version */ href: string; } /** * Search engine verification codes */ interface SeoVerificationConfig { /** Google Search Console verification code */ google?: string; /** Bing Webmaster Tools verification code */ bing?: string; /** DuckDuckGo verification (reserved for future use) */ duckduckgo?: string; } /** * Resolved page SEO configuration (after merging global + page) */ interface ResolvedPageSeo { /** Final page title */ title: string; /** Final meta description */ description: string; /** Page keywords (comma-separated), if configured */ keywords?: string; /** Final canonical URL */ canonical: string; /** Final robots directive */ robots: string; /** Merged Open Graph configuration */ openGraph: Required> & SeoOpenGraphConfig; /** Merged Twitter configuration */ twitter: Required> & SeoTwitterConfig; /** All schemas (global + page) */ schemas: SeoSchemaConfig[]; /** Alternate links */ alternates: SeoAlternateConfig[]; } /** * Configuration for useSEO composable */ interface UseSeoConfig { /** Page slug matching entry in seo.yaml */ pageSlug: string; /** Optional page path for canonical URL generation */ pagePath?: string; } /** * Return type of useSEO composable */ interface UseSeoReturn { /** Computed page SEO configuration */ config: vue.ComputedRef; /** * RE-ASSERT the baked head for this route via useHead. Takes NO ARGUMENTS. * * `.dcs/seo.yaml` is the only writer of the managed head fields * (head-authority contract, C-356 — `.docs/plans/dynamic-site-resolution/README.md`). * The parameter was removed on purpose: passing one is a compile error, and a * caller that reaches the runtime with one anyway has it DROPPED plus a * console error. Change the value in `.dcs/seo.yaml`, not here. */ applyHead: () => void; /** Get JSON-LD schema objects for the page */ getSchema: () => object[]; /** Get canonical URL for the page */ getCanonical: () => string; /** Whether SEO config was loaded from build-time */ hasBuildTimeSeo: boolean; } /** * @deprecated RETIRED BY THE HEAD-AUTHORITY CONTRACT (C-356). `applyHead()` * accepts no overrides: `.dcs/seo.yaml` is the only writer of the managed head * fields. The interface is retained only so an external `import type` does not * break on the bump; nothing in the package consumes it. It will be deleted in * the next cms MAJOR alongside the cms-core phantom-caller deletion (Q-297=A). * * If you were reaching for this, the answer is: put the value in * `.dcs/seo.yaml` (or the portal SEO editor, which writes it). */ interface HeadOverrides { /** Override title */ title?: string; /** Override description */ description?: string; /** Override keywords meta tag */ keywords?: string; /** Additional or replacement schemas */ schemas?: object[]; /** Additional meta tags */ meta?: Array<{ name?: string; property?: string; content: string; }>; } /** * Framework-free schema.org JSON-LD builders shared by BOTH SEO emit paths. * * This is the single home for the structured-data "completeness" logic the DCS * SEO factory needs: a cross-linked `@graph` knowledge spine (Organization / * WebSite / LocalBusiness), `BreadcrumbList`, `BlogPosting`, `FAQPage`, and the * HONEST `Review` / `aggregateRating` injection. Both call sites consume it: * * - the Vue-SPA per-route emitter (`buildHeadTags` in `./headTags.ts`), and * - the VitePress `transformPageData` factory (`./vitepressTransform.ts`). * * Every export here is a PURE function of plain inputs (no Vue, no Vite, no * filesystem) returning plain `schema.org` objects, so the two paths stay * byte-identical and the builder logic is never duplicated. * * ── HONESTY (non-negotiable) ──────────────────────────────────────────────── * `Review`, `aggregateRating`, and `FAQPage` are emitted ONLY from REAL data: * • Review/aggregateRating come from `.dcs/content.yaml` review items that * carry a numeric `rating`, a non-empty `text`, and an `authorName`. * • FAQPage comes from a structured Q&A source (frontmatter `faq: [{q,a}]` or * `.dcs/faq.yaml`) where each entry has a non-empty question AND answer. * When the real source is missing or empty the builder short-circuits to an * empty result — it NEVER synthesises a placeholder rating, review, or Q&A. A * fabricated rating is a legal + trust risk; this module makes fabrication * impossible by construction (no defaults, no invented counts). */ /** A plain schema.org object (already shaped for JSON-LD serialisation). */ type SchemaObject = Record; /** Return true when a schema `@type` is a LocalBusiness (sub)type. */ declare function isLocalBusinessType(type: string | undefined): boolean; /** * A real review item, mirroring the shape stored in `.dcs/content.yaml` under a * `reviews..items` array (see `useReviewContent`). Only the fields the * honest Review/aggregateRating builder needs are typed here; extra fields are * ignored. */ interface ReviewSource { rating?: unknown; text?: unknown; authorName?: unknown; date?: unknown; locationName?: unknown; } /** A structured FAQ entry: `{ q, a }` (frontmatter) — honesty-gated. */ interface FaqSource { q?: unknown; a?: unknown; /** Alternate keys some sources use (`question`/`answer`). */ question?: unknown; answer?: unknown; } /** A single breadcrumb hop along the route to the current page. */ interface BreadcrumbCrumb { /** Human-readable name (e.g. `Home`, `Services`, the post title). */ name: string; /** Absolute URL for this hop. */ item: string; } /** Blog-post metadata used to build a `BlogPosting`, from the SPA or VitePress. */ interface BlogMeta { /** The headline / post title. */ headline?: string; /** ISO-ish publish date as authored (granularity preserved, e.g. `2026-01`). */ datePublished?: string; /** ISO-ish modified date, if distinct. */ dateModified?: string; /** Absolute canonical URL of the post (becomes `mainEntityOfPage`). */ url?: string; /** Header/social image URL for the post. */ image?: string; /** Short description / excerpt. */ description?: string; } /** Stable `@id` anchors derived from the site URL (no new YAML required). */ declare function graphIds(siteUrl: string): { organization: string; website: string; localBusiness: string; }; /** * Derive an `sameAs` array of absolute profile URLs from the global `social` * block. Returns `[]` when nothing is configured (so the key is omitted, never * emitted empty). */ declare function deriveSameAs(global: GlobalSeoConfig): string[]; /** * Find the LocalBusiness-subtype entry in `global.schemas`, if any. This is the * NAP-complete node a site already hand-authors; the graph builder absorbs it * (rather than letting `generateJsonLd` emit a second, unlinked copy). * * @returns the matching `SeoSchemaConfig` and its index, or `null`. */ declare function findLocalBusinessSchema(global: GlobalSeoConfig): { schema: SeoSchemaConfig; index: number; } | null; /** * The `PostalAddress` fields that together make a NAP a real, mappable address. * Order is the order a human writes one, which is the order the warning prints. */ declare const REQUIRED_POSTAL_ADDRESS_FIELDS: readonly ["streetAddress", "addressLocality", "addressRegion", "postalCode", "addressCountry"]; /** A LocalBusiness node that CLAIMS an address but does not finish it. */ interface PostalAddressGap { /** The LocalBusiness `@type` as authored (e.g. `HomeAndConstructionBusiness`). */ businessType: string; /** Required fields absent or blank, in `REQUIRED_POSTAL_ADDRESS_FIELDS` order. */ missing: string[]; /** Required fields actually supplied — what makes the node look answered. */ present: string[]; } /** * Report a LocalBusiness `address` that is PRESENT BUT PARTIAL. * * `buildGlobalGraph` spreads the hand-authored node's properties verbatim * (`...props`), so a `PostalAddress` carrying only a locality and a region ships * to crawlers exactly as authored, with nothing anywhere saying it is half an * address. Google matches a LocalBusiness to a place using the NAP; a partial * address is the shape that silently fails that match while every audit that * only asks "is there an address node?" stays green. * * ## Why an ABSENT address is not reported * * A service-area business (a contractor working out of a home, a mobile trade) * is *supposed* to omit `address` — that is Google's own guidance, and several * DCS fleet sites are exactly that. Warning there would be an accusation * levelled at correct configuration, and a warning people learn to ignore is * worth less than no warning. The defect is the CLAIM that is not finished, so * the trigger is an address node that exists and is incomplete. * * A non-object `address` (a plain string) is also skipped: it is unstructured, * not partial, and belongs to a different finding. * * @returns one gap per incomplete address, or `[]` when complete/absent. */ declare function findPostalAddressGaps(global: GlobalSeoConfig): PostalAddressGap[]; /** * Render the partial-address warning. Names the type, what is there, and what is * not — an operator must be able to act without re-deriving the finding, and the * "or drop the address entirely" branch is stated because for a service-area * business that is the CORRECT fix, not a workaround. */ declare function formatPostalAddressGapReport(gaps: PostalAddressGap[]): string; /** * True when `buildGlobalGraph` provides this schema canonically, so it must NOT * also be emitted standalone: the LocalBusiness subtype it folds in, or a global * `Organization` / `WebSite` (the graph emits cross-linked versions of both). * * Shared by both emit paths so the de-dup rule is identical. * * INVARIANT (C-609): every schema this returns `true` for must be ABSORBED by * `buildGlobalGraph` — its hand-authored properties merged into the corresponding * `@graph` node. Absorbing here without merging there is silent data loss: the * node is stripped from the per-schema emission and its properties never reach * the crawler (the C-418 defect this pairing now prevents). */ declare function graphAbsorbs(schema: SeoSchemaConfig, global: GlobalSeoConfig): boolean; /** * All hand-authored `global.schemas` entries of an exact `@type`, in document * order. Plural because `graphAbsorbs` absorbs EVERY `Organization` / `WebSite` * entry — so the graph must merge every one of them or the extras vanish. */ declare function findGlobalSchemasOfType(global: GlobalSeoConfig, type: string): SeoSchemaConfig[]; /** One normalised, REAL review (passed the honesty gate). */ interface NormalisedReview { rating: number; text: string; authorName: string; date?: string; locationName?: string; } /** * Keep ONLY real review items: a numeric `rating`, a non-empty `text`, and a * non-empty `authorName`. Anything missing any of the three is dropped (never * back-filled). Returns `[]` when nothing qualifies. */ declare function filterRealReviews(items: ReviewSource[] | undefined): NormalisedReview[]; /** A `Review` schema.org node array + an `aggregateRating`, both honesty-gated. */ interface ReviewSchemaParts { /** `Review` nodes (one per real item). Empty when no real reviews. */ review: SchemaObject[]; /** `AggregateRating` node, or `undefined` when no real reviews. */ aggregateRating?: SchemaObject; } /** * Build `Review[]` + `aggregateRating` from REAL review items only. * * Each `ratingValue` is CLAMPED to `[worstRating, bestRating]` (F3): a source * rating of 7 (or 0/-1) would otherwise emit an out-of-range, schema-invalid * value (and skew the aggregate). The aggregate mean is computed from the SAME * clamped values so the headline rating matches the displayed reviews. * * `aggregateRating.ratingValue` is the mean (rounded to one decimal), * `reviewCount`/`ratingCount` equal the real item count. When there are zero * real items, BOTH are omitted — never an invented rating or count. */ declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): ReviewSchemaParts; /** * Build a single `EducationalOccupationalCredential` node for a trade license * from the free-text `business.license` content key, or `undefined` when the key * is empty / whitespace / absent. * * Shape (schema.org-correct; mirrors the portal's `Credential` editor type in * `portal/src/lib/jsonld-schema-types.ts`): * * { '@type': 'EducationalOccupationalCredential', * credentialCategory: 'license', * name: } * * HONESTY (non-negotiable, mirrors the Review/FAQ gates in this module): an * empty / whitespace / absent value returns `undefined` so the caller omits * `hasCredential` entirely — never an empty string, never a fabricated license. * The value is carried verbatim in `name` (the field is free text — a license * number, a "Licensed & Insured" phrase, or a credential name — so the * always-valid `name` slot is the safest home; nothing is parsed or invented). */ declare function buildHasCredential(license: string | undefined): SchemaObject | undefined; /** * Build the cross-linked global knowledge graph as ONE JSON-LD object carrying * a `@graph` array: `Organization`, `WebSite` (publisher → org), and the site's * `LocalBusiness` node (parentOrganization → org), auto-derived from * `global.siteName` / `global.siteUrl` / `global.images.logo` / `global.social`. * Requires NO new YAML. * * ALL THREE nodes ABSORB their hand-authored `global.schemas[*]` counterpart * (C-609) — the properties are merged onto the derived node rather than * discarded, which is what makes the baked `@graph` a true SUPERSET of the * runtime per-schema emission that `graphAbsorbs` suppresses: * * • LocalBusiness — its NAP/geo/hours/offers are preserved verbatim and the * node is promoted with an `@id`; honest `review` + `aggregateRating` and a * `hasCredential` license are merged in when supplied. * • Organization — e.g. the fleet default's hand-authored `logo`, which * `global.images.logo` does not set. * • WebSite — e.g. a `description` or a `potentialAction` SearchAction. * * Precedence is uniform: hand-authored properties OVERRIDE the derived value for * the same key and ADD any key the graph does not derive, except the identity * keys (`@type`, `@id`) and the spine's cross-links (`publisher`, * `parentOrganization`), which the graph always owns so the `@id` refs resolve. * * Returns `[]` when there is no `siteUrl` (no stable `@id` anchor possible), so * the existing per-schema emission is left untouched for un-configured sites. * * @param opts.reviews REAL review items (from content.yaml) for the business * node. Optional; when omitted/empty no Review/aggregateRating is added. * @param opts.license Free-text trade/occupational license (from the * `business.license` content key). Optional; when empty/absent no * `hasCredential` is added (honesty-gated — see `buildHasCredential`). Only * ever attached to the LocalBusiness node, never to Organization/WebSite. */ declare function buildGlobalGraph(global: GlobalSeoConfig, opts?: { reviews?: ReviewSource[]; license?: string; }): SchemaObject[]; /** * Build a `BreadcrumbList` from an ordered trail of crumbs (Home → … → current). * * Honesty/cleanliness rules: * • The home page (a trail of length ≤ 1, i.e. just "Home") emits NOTHING — * a single-item breadcrumb is noise. * • Positions are 1-based and contiguous. * * @returns a single-element array `[BreadcrumbList]`, or `[]` for the home page. */ declare function buildBreadcrumbList(trail: BreadcrumbCrumb[]): SchemaObject[]; /** * Derive a Home → … → current breadcrumb trail from a route path. * * Each path segment becomes a crumb; the segment label comes from * `titles[segmentPath]` (an absolute-route → title map) when present, else a * slug-derived Title Case of the segment (never blank). * * @param route the page route, e.g. `/`, `/services`, `/blog/my-post`. * @param siteUrl normalised site URL (no trailing slash). * @param titles optional map of route → human title for intermediate hops * AND the leaf (e.g. `{ '/': 'Home', '/blog': 'Blog', * '/blog/my-post': 'My Post' }`). Missing entries fall back to * a slug-derived title. * @param homeName label for the root crumb (default `Home`). */ declare function breadcrumbTrailFromRoute(route: string, siteUrl: string, titles?: Record, homeName?: string): BreadcrumbCrumb[]; /** Title-case a slug segment (`my-post` → `My Post`); never blank. */ declare function slugToTitle(slug: string): string; /** * Build a single `BlogPosting`. * * `author`/`publisher` are emitted as SELF-CONTAINED Organization nodes — each * carrying its `@id`, plus a concrete `name` and `url`. This is the F2 fix: a * bare `{ '@id': … }` ref DANGLES when `emitBlogPosting` runs WITHOUT the global * `@graph` (the VitePress path with `emitGraph: false`), because nothing then * defines the Organization node that `@id` points at. An inline node is valid * standalone AND still carries the `@id`, so when the `@graph` IS present a * consumer merges the two by `@id` (no duplication, no dangle) either way. * * `datePublished` granularity is preserved exactly as authored (e.g. a * year-month `2026-01` is NOT padded to a fabricated day). * * @param global Optional global config — supplies the publisher/author `name`. * When omitted (or no `siteName`), the inline node still carries `@id` + `url` * so the reference never dangles. * @returns `[BlogPosting]`, or `[]` when there is no headline (nothing to emit). */ declare function buildBlogPosting(meta: BlogMeta, siteUrl: string, global?: GlobalSeoConfig): SchemaObject[]; /** One normalised, REAL FAQ pair (passed the honesty gate). */ interface NormalisedFaq { question: string; answer: string; } /** * Keep ONLY real FAQ entries: a non-empty question AND a non-empty answer. * Tolerant of both `{ q, a }` and `{ question, answer }` shapes. Returns `[]` * when nothing qualifies (so no FAQPage is emitted). */ declare function filterRealFaq(entries: FaqSource[] | undefined): NormalisedFaq[]; /** * Build a `FAQPage` from a STRUCTURED Q&A source ONLY (frontmatter `faq:` or a * `.dcs/faq.yaml`). A `` Vue component is NOT a valid source — there * is no machine-readable Q&A to read, so this correctly emits nothing. * * @returns `[FAQPage]` with one `Question` per real entry, or `[]` when there is * no valid structured Q&A. */ declare function buildFaqPage(entries: FaqSource[] | undefined): SchemaObject[]; /** * The relevant slice of `.dcs/content.yaml`: flat dotted keys live under * `global` and per-page maps. Reviews are stored under `reviews..items` * (e.g. iron-oak `reviews.reviews.items`, kept `reviews.testimonials.items`), * so the key differs per site — `findReviewItemsForPage` scans tolerantly. */ interface ContentConfig { global?: Record; pages?: Record>; } /** * Find the REAL review items for a page from `.dcs/content.yaml`, tolerant of * the per-site key naming (`reviews.reviews.items` vs `reviews.testimonials. * items`). Prefers a page-scoped block, then falls back to global; within a * block it picks the FIRST `reviews.*.items` array (sites carry one canonical * source). Returns `[]` when none is present — no synthesis. * * The honesty filter still runs downstream (`buildReviewSchemaParts`), so a * non-empty return here is NOT yet a guarantee of emission; items without a * rating/text/authorName are dropped there. */ declare function findReviewItemsForPage(content: ContentConfig | undefined, pageSlug: string): ReviewSource[]; /** * Read the free-text `business.license` content key from `.dcs/content.yaml` * (page-scoped block first, then global — mirroring `findReviewItemsForPage`; * NAP identity normally lives under `global`). Returns a trimmed non-empty * string, or `undefined` when the key is absent or empty. A bare numeric YAML * value (e.g. `business.license: 123456`) is coerced to its string form. No * synthesis — an unset key yields `undefined`, so the emit paths stay dark. */ declare function findBusinessLicense(content: ContentConfig | undefined, pageSlug: string): string | undefined; /** * Guarantee an absolute URL. When `value` is already absolute (`http(s)://`) it * is returned unchanged; when it is a site-relative path it is joined onto the * (trailing-slash-trimmed) `siteUrl`; when it is empty the `fallback` (typically * the already-absolute canonical) is returned. */ declare function absolutizeUrl(value: string | undefined, siteUrl: string | undefined, fallback?: string): string; /** * Loader for the `.dcs/pages.yaml` route manifest. * * `pages.yaml` is the canonical page registry maintained by the DCS portal and * by Copilot when scaffolding pages. For the build-time SEO emitter we only * need each route's `slug` and `path` (e.g. `{ slug: 'home', path: '/' }`). * * The parser is intentionally defensive: any missing/unparseable file or * malformed entry yields `null` (caller logs + no-ops) so a bad manifest can * never break a production build. */ /** A single route extracted from `.dcs/pages.yaml`. */ interface PageRouteEntry { /** Page slug, matching an entry in `seo.yaml` `pages.` (may be absent). */ slug: string; /** Route path, e.g. `/`, `/services`, `/blog/my-post`. */ path: string; /** * Human title from the manifest (e.g. "Kitchen Cabinet Refresh"). Used as the * per-route title fallback when `seo.yaml` has no entry for this page, so * un-configured routes (e.g. blog posts) get unique titles. Optional. */ title?: string; /** * Per-page last-modification date for the sitemap ``. A W3C-datetime * string authored in `pages.yaml` (`lastmod` / `lastUpdated` / `dateModified`). * Validated + de-fabricated at emit time; an invalid or absent value falls * back to the site-wide lastmod. Never invented. */ lastmod?: string; } /** * Resolve and parse `.dcs/pages.yaml` from one of the usual locations. * * Mirrors the `.dcs` path resolution used elsewhere in the package (project * root, parent dir for VitePress-style nesting, and cwd). * * @returns the list of `{ slug, path }` routes, or `null` if the file is * absent, unreadable, unparseable, or contains no usable page entries. */ declare function loadPagesManifest(projectRoot: string, relativePagesPath: string, debug?: boolean): PageRouteEntry[] | null; /** * Extract `{ slug, path }` routes from an already-parsed manifest object. * Exposed separately so tests can exercise it without touching the filesystem. * * Entries missing a string `path` are skipped; a missing slug falls back to the * empty string (so the route still gets baked, using global SEO defaults). */ declare function parsePagesManifest(raw: unknown): PageRouteEntry[] | null; /** * The two exclusion inputs the per-route HTML emitter honours. * * `exclude` is a plugin OPTION (paths or slugs); `excludedGlobs` is the * `pages.yaml` top-level `excluded:` list. Neither is a file the runtime can * read, which is exactly why the RUNTIME MANIFEST has to be filtered before it * is injected rather than filtered again at lookup time. */ interface RouteEmissionRules { /** `dcsSeoPlugin({ exclude })` — exact route paths OR slugs. */ exclude?: readonly string[]; /** `pages.yaml` top-level `excluded:` globs, e.g. `/dev-*`, `/_*`. */ excludedGlobs?: readonly string[]; } /** Why a manifest route is NOT emitted, or `null` when it is. */ type RouteExclusionReason = 'excluded' | 'excluded-glob'; /** * THE SINGLE EMISSION PREDICATE. Both the per-route HTML emitter and the * `__DCS_PAGES__` runtime manifest resolve "is this route emitted?" through * THIS function. * * WHY IT EXISTS (C-361 F5, verified). The emitter skipped `exclude` entries and * `excluded:` globs, while `__DCS_PAGES__` was built from the raw manifest and * carried EVERY loaded route. A private `/dev-*` route was therefore never baked * — correct — but `installSeoHead` would still runtime-assert a canonical and * `index, follow` over it on the first in-app navigation. The manifest has to * model what the emitter WRITES, not what `pages.yaml` DECLARES, and the only * way that survives a future edit is for there to be one predicate rather than * two implementations that agree today. */ declare function routeExclusionReason(route: Pick, rules: RouteEmissionRules): RouteExclusionReason | null; /** `true` when the build emits a per-route `` for this route. */ declare function isRouteEmitted(route: Pick, rules: RouteEmissionRules): boolean; /** * Shared, PURE (fs-free) cores for the DCS site-file emitters: * `sitemap.xml`, `robots.txt`, and `llms.txt`. * * This is the SINGLE SOURCE OF TRUTH for the three site-wide static files. Both * cms's own `dcsSeoPlugin` (the "factory" emit path) and — as a follow-up on a * separate branch — kit-vite's `dcsSitemapPlugin` import these cores so their * outputs are byte-identical. The file name/shape deliberately mirror the * proven kit-vite emitter (`packages/kit-vite/src/sitemap.ts`) so the eventual * DRY port is a one-line re-export swap. * * These cores reuse cms's single SEO source of truth — `resolvePageSeo` for * each route's canonical + robots — so the `` written into `sitemap.xml` * (and the link written into `llms.txt`) is byte-identical to the * `` the SEO emitter bakes into each page's ``. No * head-tag or manifest logic is forked here. * * Everything in this module is pure string-in / string-out and never throws. * The filesystem work lives in the plugin layer (`dcsSeoPlugin`), exactly like * the existing `headTags` / `pagesManifest` split — so these cores stay * Vue/Vite-free and unit-testable under jsdom. */ /** * AI-crawler user agents emitted as explicit allow/deny groups in robots.txt so * AI discovery is opt-in-friendly (and explicitly gated off in preview). A site * can opt the whole tier out via `robots.aiBots: false`. */ declare const AI_BOTS: readonly ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended", "CCBot", "OAI-SearchBot", "Applebot-Extended"]; /** * The SINGLE indexability predicate shared by `buildSitemapXml` and * `buildLlmsTxt` so the two outputs always agree on which routes appear. * * A route is NOT indexable when: its path/slug is in `exclude`, OR its * path/slug is in `noindex`, OR its path matches an `excludedGlobs` entry, OR * its resolved robots (from `seo.yaml`) matches `/noindex/i`. */ declare function isRouteIndexable(route: PageRouteEntry, seoConfig: SeoConfiguration | undefined, opts?: { exclude?: Set; noindex?: Set; excludedGlobs?: string[]; }): boolean; interface BuildSitemapParams { /** Route list (from `loadPagesManifest`). */ routes: PageRouteEntry[]; /** Canonical production origin (preferred base when no per-page canonical). */ siteUrl?: string; /** cms seo config (passed straight through to `resolvePageSeo`). */ seoConfig?: SeoConfiguration; /** Routes to skip entirely (path or slug). */ exclude?: string[]; /** Routes forced to noindex / omitted (path or slug). */ noindex?: string[]; /** `pages.yaml` top-level `excluded:` globs (coron8 parity). */ excludedGlobs?: string[]; /** Optional single site-wide `` (ISO date) — no fabricated per-page dates. */ lastmod?: string; } /** * Build the sitemap XML string from a route list. Pure: routes + siteUrl + * seoConfig in, XML out. * * A route is omitted when it is not indexable (see {@link isRouteIndexable}) OR * no absolute `` is derivable (missing siteUrl + no canonical). * * Returns the empty string when no route yields an absolute `` (the caller * treats this as a no-op signal — a sitemap with no absolute base is worse than * none). */ declare function buildSitemapXml(params: BuildSitemapParams): string; /** robots.txt override hooks. */ interface DcsRobotsOptions { /** Emit a robots.txt at all (default `true`). */ enabled?: boolean; /** `Disallow:` lines to emit (default `[]`). */ disallow?: string[]; /** `Allow:` lines to emit (default `['/']` in production). */ allow?: string[]; /** Raw lines appended verbatim after the generated directives. */ extra?: string[]; /** * Emit explicit AI-crawler allow/deny groups (GPTBot, ClaudeBot, * PerplexityBot, Google-Extended, CCBot, OAI-SearchBot, Applebot-Extended). * Default `true` — each `Allow: /` in * production, each `Disallow: /` in preview. Set `false` to drop the tier. */ aiBots?: boolean; /** * Overwrite an existing `dist/robots.txt` (e.g. a hand-authored * `public/robots.txt` Vite already copied). Default `false` — do not clobber. */ force?: boolean; } interface BuildRobotsParams { /** Canonical production origin (required for the absolute `Sitemap:` line). */ siteUrl?: string; /** Preview / staging gate: `Disallow: /`, no `Sitemap:` line. */ preview?: boolean; /** robots.txt override hooks. */ robots?: DcsRobotsOptions; /** Whether a sitemap is being emitted (drives the `Sitemap:` line). */ hasSitemap?: boolean; } /** * Build the robots.txt string. Pure: siteUrl + options in, text out. * * - **Preview mode** (`preview: true`): emits the privacy gate `User-agent: *` * + `Disallow: /`, the same `Disallow: /` for each AI-bot tier (when * enabled), and OMITS the `Sitemap:` line. * - **Production**: `User-agent: *`, any `disallow`/`allow` lines (default * `Allow: /`), explicit AI-bot allow groups (when enabled), a blank line, * then an absolute `Sitemap: {siteUrl}/sitemap.xml` (trailing slash trimmed). * The `Sitemap:` line is omitted when no sitemap is emitted or no `siteUrl` * is known. * - `extra` lines are appended verbatim. */ declare function buildRobotsTxt(params: BuildRobotsParams): string; interface BuildLlmsParams { /** Route list (from `loadPagesManifest`). */ routes: PageRouteEntry[]; /** Canonical production origin (preferred base when no per-page canonical). */ siteUrl?: string; /** cms seo config (passed straight through to `resolvePageSeo`). */ seoConfig?: SeoConfiguration; /** Routes to skip entirely (path or slug). */ exclude?: string[]; /** Routes forced to noindex / omitted (path or slug). */ noindex?: string[]; /** `pages.yaml` top-level `excluded:` globs (coron8 parity). */ excludedGlobs?: string[]; } /** * Build the `llms.txt` plain-text body following the emerging llms.txt * convention (Markdown-ish): an H1 `# {siteName}`, a one-line `> {summary}` * blockquote, then a `## Pages` section listing each INDEXABLE route as a * Markdown link `- [{title}]({canonical}): {description}`. * * Sourced from the SAME inputs as the sitemap (so there is zero new resolution * path): `siteName`/`defaultDescription` from `seoConfig.global`, the URL list * filtered through the IDENTICAL {@link isRouteIndexable} predicate, and per-page * title/description/canonical from `resolvePageSeo`. * * Returns the empty string (no-op) when there is no `siteName` AND no indexable * route with a derivable link. */ declare function buildLlmsTxt(params: BuildLlmsParams): string; /** * Build-time SEO for VitePress static-site generation. * * VitePress 1.6 does **not** use `unhead`, so the runtime `useSEO`/`applyHead` * composable is a no-op against the SSG HTML. The SSG-correct sink is the * `transformPageData(pageData)` build hook: writing ``/``/JSON-LD * into `pageData.frontmatter.head` (VitePress bakes those into the rendered * ``) and overwriting `pageData.title` / `pageData.description` (VitePress * renders the `` — via `titleTemplate` — and the `description` meta from * those two fields). * * This factory generalises the bespoke `buildSeoHead`/`transformPageData` that * shipped inline in a site's `.vitepress/config.ts`. It reuses the shared, * framework-agnostic resolver (`resolvePageSeo`, `generateOpenGraphMeta`, * `generateTwitterMeta`, `generateJsonLd`) for global + page meta / OG / Twitter * / canonical and the global JSON-LD knowledge graph, and delegates **page-type * JSON-LD** (e.g. Article / Place / CollectionPage / Service / FAQPage + * BreadcrumbList) to a *pluggable* rule set the site supplies. None of the * real-estate (or any other vertical's) schema logic lives in this package — it * is all site CONFIG. * * It is the VitePress counterpart to the Vue-SPA per-route emitter in * `dcsSeoPlugin({ emitStaticHtml: true })`; both produce identical global * meta/OG/Twitter/JSON-LD from the same `seo.yaml` via the shared resolver. * * @example * ```ts * // docs/.vitepress/config.ts * import { createSeoTransformPageData } from '@duffcloudservices/cms/plugins' * import seoConfig from '../../.dcs/seo.yaml' * * export default defineConfig({ * transformPageData: createSeoTransformPageData({ * seoConfig, * pageTypeRules: [ * { match: (ctx) => ctx.route.startsWith('/blogs/'), build: (ctx) => [ ... ] }, * // ...Place / CollectionPage / Service / FAQPage rules * ], * }), * }) * ``` */ /** * A VitePress `head` entry. Mirrors VitePress's `HeadConfig` without taking a * dependency on the `vitepress` package (which is not a dependency of this * library). The tuple forms are: * ['meta', { name|property, content }] * ['link', { rel, href, ... }] * ['script', { type: 'application/ld+json' }, '<serialised json>'] */ type VitePressHeadConfig = [string, Record<string, string>] | [string, Record<string, string>, string]; /** * The minimal slice of VitePress's `PageData` this factory reads and mutates. * Typed structurally so callers can pass VitePress's real `PageData` without a * cast and without this package importing `vitepress`. */ interface VitePressPageData { /** Source-relative path, e.g. `index.md`, `blogs/my-post.md`. */ relativePath: string; /** Dynamic-route params (e.g. `{ topic: 'home-buying' }`). */ params?: Record<string, unknown>; /** * Page frontmatter; `head` is appended to here. * * Deliberately `any`: site `seo.config.ts` rules dot-walk arbitrary frontmatter, * so `unknown` would be a breaking API change for every consuming site. */ frontmatter: Record<string, any>; /** VitePress page title (drives `<title>` via `titleTemplate`). */ title?: string; /** VitePress page description (drives the `description` meta). */ description?: string; [key: string]: unknown; } /** * Context handed to the site's page-type rules and resolver hooks. Everything a * site needs to derive its title/description/og/schemas for one page, computed * once per page by the factory. */ interface SeoPageContext { /** Route path, e.g. `/`, `/blogs/my-post`, `/locations/birmingham`. */ route: string; /** Slug: `'home'` for `/`, otherwise the route without its leading slash. */ slug: string; /** Absolute canonical URL for this route. */ canonical: string; /** Normalised site base URL (no trailing slash), e.g. `https://example.com`. */ siteUrl: string; /** * The page's frontmatter (read-only convenience; same object as pageData). * * Deliberately `any` — mirrors `VitePressPageData.frontmatter` above. */ frontmatter: Record<string, any>; /** The resolved global SEO config block. */ global: GlobalSeoConfig; /** The full VitePress page data (for rules that need more than the above). */ pageData: VitePressPageData; } /** * The resolved per-page title / description / OG type a site may override. * Returned by the optional `resolvePage` hook so a site can apply its own * per-page-type title precedence (e.g. "{City} Luxury Real Estate") and decide * whether that title should win over VitePress's `titleTemplate`. */ interface ResolvedPageOverrides { /** * The page title. When `setPageTitle` is true this is written to * `pageData.title` (VitePress then applies `titleTemplate`). */ title?: string; /** * When true, `title` is written back to `pageData.title`. Leave false for * pages whose frontmatter title should remain authoritative (e.g. blog posts * that already carry a good `<h1>`/title). */ setPageTitle?: boolean; /** The meta description. Written to `pageData.description` when truthy. */ description?: string; /** Open Graph type override (e.g. `'article'`, `'profile'`). */ ogType?: SeoOpenGraphConfig['type']; /** Open Graph image URL override (e.g. a post's header image). */ ogImage?: string; /** * Extra Open Graph fields to merge into the OG config (e.g. `publishedTime`, * `modifiedTime`, `section`, `tags`). The shared OG generator emits the * matching `article:*` tags when `ogType === 'article'`. This is how a site * supplies article metadata (date/category) without that logic living in the * package. Lower precedence than `ogType`/`ogImage`/`ogTitle`/`ogDescription`. */ og?: Partial<SeoOpenGraphConfig>; /** Keywords override (comma-separated) for the `keywords` meta. */ keywords?: string; /** * Open Graph title override. Defaults to `title` so og:title tracks <title>. */ ogTitle?: string; /** * Open Graph description override. Defaults to `description`. */ ogDescription?: string; } /** A single pluggable page-type rule: when `match` is true, emit `build`. */ interface SeoPageTypeRule { /** Return true when this rule applies to the page (by route/slug/etc.). */ match: (ctx: SeoPageContext) => boolean; /** Build the page-type JSON-LD objects to emit (already plain objects). */ build: (ctx: SeoPageContext) => Array<Record<string, unknown>>; } interface CreateSeoTransformPageDataOptions { /** The parsed `.dcs/seo.yaml` (global graph + per-page meta). */ seoConfig: SeoConfiguration | undefined; /** * Pluggable page-type rules. Evaluated in order; **every** matching rule's * `build` output is emitted (so a route can contribute both a primary schema * and a BreadcrumbList from one rule, or be matched by several). The * real-estate BlogPosting / Place / CollectionPage / Service / FAQPage logic * is supplied here by the site — never hardcoded in this package. */ pageTypeRules?: SeoPageTypeRule[]; /** * Optional hook to override per-page title / description / OG before tags are * built — the site's title precedence and per-type description fallbacks. * Receives the same context as the rules. Anything it omits falls back to the * resolver / frontmatter defaults. */ resolvePage?: (ctx: SeoPageContext) => ResolvedPageOverrides | undefined; /** * Map a `relativePath` (+ params) to a route. Defaults to a VitePress-correct * implementation: `index` becomes `/`, a trailing `/index` is dropped, `.md` * is stripped, and dynamic `[name]` segments are substituted from * `pageData.params`. Override only for unusual routing. */ relativePathToRoute?: (relativePath: string, params?: Record<string, unknown>) => string; /** * Emit a `<meta name="keywords">` from the resolved/overridden keywords. * Default true (parity with the bespoke KDH emitter, which emitted keywords). */ includeKeywords?: boolean; /** * Emit the cross-linked global `@graph` spine (Organization + WebSite + the * promoted LocalBusiness node) in place of the flat per-schema global JSON-LD. * When ON, the LocalBusiness subtype in `global.schemas` is ABSORBED into the * graph (not emitted twice); other global schemas (Person, etc.) are still * emitted standalone. Default `false`, so existing sites (which supply their * own graph via `global.schemas`) are unchanged until they opt in. */ emitGraph?: boolean; /** * Emit an automatic `BreadcrumbList` derived from the route depth on every * non-home page. Default `false` (sites that already emit breadcrumbs via * `pageTypeRules` should leave this off to avoid duplicates). Intermediate / * leaf crumb titles come from `breadcrumbTitles(ctx)` when provided, else a * slug-derived Title Case. */ emitBreadcrumbs?: boolean; /** * Map a context to a `{ route → title }` map for breadcrumb hop labels (e.g. * `{ '/': 'Home', '/blogs': 'Blog' }`). Missing entries fall back to a * slug-derived title. Only consulted when `emitBreadcrumbs` is true. */ breadcrumbTitles?: (ctx: SeoPageContext) => Record<string, string> | undefined; /** * Emit an automatic `BlogPosting` (author/publisher as `@id` refs) for blog * routes, derived from frontmatter (`title`/`date`/`image`/`description`). * `blogMatch` decides which routes are posts; default off. Skipped when a * `pageTypeRule` already emitted a `BlogPosting` for the page. */ emitBlogPosting?: boolean; /** Predicate selecting blog-post routes for `emitBlogPosting`. */ blogMatch?: (ctx: SeoPageContext) => boolean; /** * Emit an automatic, honesty-gated `FAQPage` from `frontmatter.faq` (an array * of `{ q, a }` / `{ question, answer }`). Emits nothing when the frontmatter * carries no structured Q&A. Default `false`. */ emitFaq?: boolean; /** * Provide REAL review items for the LocalBusiness node in the `@graph` (only * used when `emitGraph` is true). Honesty-gated downstream — items lacking a * rating/text/authorName are dropped, and an empty result emits no Review or * aggregateRating. Default: none. */ resolveReviews?: (ctx: SeoPageContext) => ReviewSource[] | undefined; /** * Provide the free-text trade/occupational license (the `business.license` * content.yaml key) for the LocalBusiness node in the `@graph` (only used when * `emitGraph` is true). When non-empty a `hasCredential` * (`EducationalOccupationalCredential`, `credentialCategory: "license"`) is * added to the business node; empty/absent ⇒ nothing. Default: none. */ resolveLicense?: (ctx: SeoPageContext) => string | undefined; /** Enable debug logging of the emitted head per page. */ debug?: boolean; } /** Default VitePress route derivation (matches the bespoke KDH helper). */ declare function defaultRelativePathToRoute(relativePath: string, params?: Record<string, unknown>): string; /** * Build just the SEO head tuples for a page (no `pageData` mutation). Exposed * separately so it is unit-testable without a VitePress `pageData` round-trip * and reusable by callers that manage the `head`/`title` sinks themselves. * * @returns `{ head, title, description }` — the head tuples to append, and the * final title/description (already overridden) the caller should write to * `pageData` when `applyTitle`/`applyDescription` are appropriate. */ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: CreateSeoTransformPageDataOptions): { head: VitePressHeadConfig[]; title?: string; description?: string; setPageTitle: boolean; }; /** * Create a VitePress `transformPageData(pageData)` function that bakes DCS SEO * (global meta/OG/Twitter/canonical + global JSON-LD graph + pluggable * page-type JSON-LD) into the SSG `<head>`. * * Mutations performed on `pageData`: * - **`frontmatter.head`** — the resolved tags are *appended* to any existing * `head` (so site-level `head` config is preserved). * - **`description`** — set to the resolved/overridden description so * VitePress emits exactly one `description` meta (no duplicate; we do not * push our own description meta). * - **`title`** — set only when the site's `resolvePage` hook returns * `setPageTitle: true` for this page, mirroring the bespoke behaviour where * seo.yaml/per-type titles are authoritative but a post's frontmatter title * is left intact. * * Defensive: never throws (a failure logs a warning and leaves `pageData` * untouched), so SEO can never break a production VitePress build. */ declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void; export { parsePagesManifest as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type SeoHonestyBlock as Y, type PageRouteEntry as Z, loadPagesManifest as _, buildSitemapXml as a, isRouteEmitted as a0, routeExclusionReason as a1, type RouteEmissionRules as a2, type RouteExclusionReason as a3, buildHasCredential as a4, findLocalBusinessSchema as a5, findGlobalSchemasOfType as a6, graphAbsorbs as a7, findBusinessLicense as a8, findPostalAddressGaps as a9, formatPostalAddressGapReport as aa, REQUIRED_POSTAL_ADDRESS_FIELDS as ab, deriveSameAs as ac, graphIds as ad, isLocalBusinessType as ae, type NormalisedReview as af, type NormalisedFaq as ag, type ReviewSchemaParts as ah, type PostalAddressGap as ai, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };