import { Plugin, HtmlTagDescriptor } from 'vite';
import { D as DcsRobotsOptions, Y as SeoHonestyBlock, Z as PageRouteEntry, H as SeoConfiguration, E as ContentConfig } from '../vitepressTransform-CZ_IB3dq.js';
export { C as CreateSeoTransformPageDataOptions, R as ResolvedPageOverrides, S as SeoPageContext, r as SeoPageTypeRule, t as VitePressHeadConfig, V as VitePressPageData, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute } from '../vitepressTransform-CZ_IB3dq.js';
import { H as HonestyMode } from '../headHonesty-OzxvLuwd.js';
import { Component, Plugin as Plugin$1 } from 'vue';
import MarkdownIt from 'markdown-it';
/**
* DCS Content Plugin for Vite
*
* Reads `.dcs/content.yaml` at build time and injects content
* as `__DCS_CONTENT__` global variable for use by useTextContent.
*
* @example
* ```typescript
* // vite.config.ts
* import { dcsContentPlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* plugins: [
* dcsContentPlugin({ debug: true })
* ]
* })
* ```
*
* For VitePress:
* ```typescript
* // .vitepress/config.ts
* import { defineConfig } from 'vitepress'
* import { dcsContentPlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* vite: {
* plugins: [
* dcsContentPlugin()
* ]
* }
* })
* ```
*/
interface DcsContentPluginOptions {
/** Path to content.yaml relative to project root (default: '.dcs/content.yaml') */
contentPath?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* Vite plugin that injects .dcs/content.yaml at build time.
*
* @param options - Plugin configuration
* @returns Vite plugin
*/
declare function dcsContentPlugin(options?: DcsContentPluginOptions): Plugin;
/**
* DCS SEO Plugin for Vite
*
* Two responsibilities, both driven by `.dcs/seo.yaml`:
*
* 1. **Build-time define** (always on): reads `.dcs/seo.yaml` and injects it as
* the `__DCS_SEO__` global for the runtime `useSEO` composable.
*
* 2. **Static `
` emitter** (opt-in via `emitStaticHtml: true`, default
* OFF): after the bundle is written, reads the SPA's built `index.html`,
* and for every route in `.dcs/pages.yaml` writes a per-route
* `dist//index.html` whose `` carries the resolved title, meta,
* canonical, Open Graph, Twitter, and JSON-LD tags. This gives a Vue SPA
* per-route static SEO **without** vite-ssg.
*
* VitePress sites already bake SEO via their own config, so they leave this
* option OFF and are completely unaffected.
*
* @example Runtime define only (default — safe for VitePress)
* ```typescript
* // vite.config.ts
* import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* plugins: [dcsSeoPlugin({ debug: true })]
* })
* ```
*
* @example Vue SPA with per-route static emission
* ```typescript
* // vite.config.ts
* import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* plugins: [
* dcsSeoPlugin({
* emitStaticHtml: true, // turn the emitter ON
* pagesPath: '.dcs/pages.yaml', // route manifest (default)
* noindex: ['account', 'projects'], // robots: noindex,nofollow
* exclude: ['/preview'], // skip these routes entirely
* })
* ]
* })
* ```
*/
interface DcsSeoPluginOptions {
/** Path to seo.yaml relative to project root (default: '.dcs/seo.yaml') */
seoPath?: string;
/** Enable debug logging */
debug?: boolean;
/**
* Opt-in: emit per-route static `` (meta + JSON-LD) into the built
* `dist/` at the end of the build. Default `false` — VitePress sites and any
* site that bakes its own SEO are unaffected when this is off.
*/
emitStaticHtml?: boolean;
/**
* Build-time BODY prerender: after the per-route `` is emitted, render
* each indexable route's real DOM from the just-built SPA (headless Chromium
* via the site's `playwright`) and splice it into the mount container so non-JS
* AI crawlers see the page's body prose — not just ``.
*
* Defaults to whatever `emitStaticHtml` is, so every SPA already emitting
* per-route HTML gains crawler-visible bodies on a cms bump with no per-site
* edit. Set `false` here to keep head-only emission. It is ALSO overridable
* per-site (no code change / no cms republish) via `.dcs/seo.yaml`
* `prerenderBody: false`. noindex/auth-gated routes are never body-prerendered;
* a route that crashes at render time fails the build loud rather than shipping
* a broken body. Requires `playwright` in the site (already the fleet default);
* absent ⇒ graceful no-op (head + JSON-LD still emitted).
*/
prerenderBody?: boolean;
/**
* Path to the route manifest relative to project root, used only when
* `emitStaticHtml` is true. Default `'.dcs/pages.yaml'`.
*/
pagesPath?: string;
/**
* Path to `content.yaml` relative to project root, used only when
* `emitStaticHtml` is true. Threads REAL reviews into the honest
* Review/aggregateRating on the per-route LocalBusiness node. Default
* `'.dcs/content.yaml'`. Missing/unparseable ⇒ no reviews emitted.
*/
contentPath?: string;
/**
* Routes to skip entirely (no per-route HTML written). Matched against the
* route `path` (e.g. `'/preview'`) OR the route `slug` (e.g. `'account'`).
*/
exclude?: string[];
/**
* Routes that should receive `robots: noindex, nofollow`. Matched against the
* route `path` OR `slug`. The home route (`/`) still overwrites
* `dist/index.html`.
*
* This is a PREDICATE OVER `pages.yaml`, not a standalone directive about a
* URL. An entry that matches no page is a **build error**
* (`NoindexOrphanError`, C-416) — with no matching page nothing is emitted for
* it, so the entry would silently do nothing while the URL stayed
* `index, follow`. Matched raw, so `'/account/'` does not match a `'/account'`
* route; matched against the full manifest, so an entry that is also in
* `exclude` is fine.
*/
noindex?: string[];
/**
* Opt-in: emit the site-wide static files `dist/sitemap.xml`,
* `dist/robots.txt`, and `dist/llms.txt` after the per-route HTML loop.
*
* Defaults to whatever `emitStaticHtml` is — so every site already calling
* `dcsSeoPlugin({ emitStaticHtml: true })` gets sitemap/robots/llms for free
* with zero per-site edits. Set explicitly to `false` to keep emitting
* per-route HTML without the site files. The whole block is wrapped in the
* same try/catch + existsSync guards, so it can never break a build.
*/
emitSiteFiles?: boolean;
/**
* Canonical production origin, e.g. `https://ironoakcontractors.com`. Used as
* the sitemap/llms base and the absolute `Sitemap:` line in robots.txt. When
* absent, falls back to `seo.yaml`'s `global.siteUrl`; if neither is known and
* no per-page canonicals exist, the sitemap + llms.txt no-op (robots still
* emits). Never guessed.
*/
siteUrl?: string;
/** robots.txt override hooks (disallow/allow/extra/aiBots/force). */
robots?: DcsRobotsOptions;
/**
* Preview / staging gate. When `true`, robots.txt becomes `Disallow: /` (no
* `Sitemap:` line) and neither sitemap.xml nor llms.txt is emitted (privacy).
* Detected ONLY via this explicit option — never guessed.
*/
preview?: boolean;
/** Emit `dist/llms.txt` (default `true`; always off in preview mode). */
llms?: boolean;
/**
* P1 — assert that each route's BAKED ``/description equals what the
* app leaves in the `` after it mounts. Rides the prerender browser, so
* the marginal cost is one `page.evaluate()` per route.
*
* Env override: `DCS_SEO_HEAD_HONESTY=error|warn|off`.
* seo.yaml override: `headHonesty: warn` or `headHonesty: { mode, allow }`.
*/
headHonesty?: boolean | HonestyMode | SeoHonestyBlock;
/**
* P2 — assert that every URL the factory publishes (JSON-LD logo/image IRIs,
* `og:image`, icon links, sitemap ``s, llms.txt links) resolves to what it
* promises. Same-origin assets are proven against `dist/` with no network;
* cross-origin assets are probed under a cache + time budget and only a
* DEFINITIVE wrong answer fails the build.
*
* Env override: `DCS_SEO_URL_HONESTY=error|warn|off`,
* `DCS_SEO_URL_HONESTY_NETWORK=off` to skip the cross-origin probes.
*/
urlHonesty?: boolean | HonestyMode | SeoHonestyBlock;
/**
* P12 — hoist `` to the top of `` and assert it lands
* inside the spec's 1024-byte encoding-sniffing window.
*
* Env override: `DCS_SEO_CHARSET_BUDGET=error|warn|off`.
*/
charsetBudget?: boolean | HonestyMode | SeoHonestyBlock;
}
/**
* Every key a `noindex` entry can legitimately match.
*
* The plugin's contract for `noindex` is a PREDICATE OVER THE ROUTE MANIFEST,
* never a directive about a bare URL. Every consumer asks the question
* route-first and identically —
* `noindexSet.has(route.path) || (route.slug && noindexSet.has(route.slug))` —
* in {@link emitStaticSeoHtml}, in the sitemap/llms `isRouteIndexable`
* predicate, and in the baked `__DCS_PAGES__` manifest the runtime re-assert
* reads. So the set of entries that can ever do anything is exactly the union
* of the manifest's paths and slugs, and this function is the exact inverse of
* that same relation rather than a second, drifting implementation of it.
*/
declare function collectNoindexMatchKeys(routes: PageRouteEntry[]): Set;
/**
* `noindex` entries that match no route in the manifest — i.e. entries that
* are, today, silent no-ops.
*
* Matched with the SAME raw string equality the consumers use, deliberately:
* a `'/account/'` entry against a `'/account'` route does not match in
* `emitStaticSeoHtml`, so it must not "match" here either, or the check would
* bless the very entry the emitter is about to ignore.
*/
declare function findOrphanNoindexEntries(noindex: string[], routes: PageRouteEntry[]): string[];
/** Thrown when a `noindex` entry names nothing in `pages.yaml` (C-416). */
declare class NoindexOrphanError extends Error {
readonly orphans: string[];
readonly knownKeys: string[];
constructor(orphans: string[], knownKeys: string[]);
}
/**
* Core site-file emitter, separated from Vite so it is unit-testable.
*
* Writes the site-wide static files into `outDir`, reusing the SAME routes +
* seoConfig already loaded by the per-route HTML loop (no second manifest read):
* - `sitemap.xml` (skipped in preview, or when no absolute base is derivable)
* - `robots.txt` (always; preview = `Disallow: /`; don't-clobber unless force)
* - `llms.txt` (skipped in preview, or when nothing indexable)
*
* Returns the list of relative filenames written. Pure aside from `fs` writes —
* deterministic + idempotent. Never throws (the caller also wraps in try/catch).
*/
declare function emitSiteFiles(params: {
outDir: string;
projectRoot: string;
pagesPath: string;
routes: PageRouteEntry[];
seoConfig: SeoConfiguration | undefined;
siteUrl?: string;
exclude?: string[];
noindex?: string[];
preview?: boolean;
robots?: DcsRobotsOptions;
llms?: boolean;
debug?: boolean;
}): string[];
/**
* Core emitter, separated from Vite so it is unit-testable.
*
* For each route: build the head tags from the shared resolver, splice them
* into the shell HTML, and write `dist//index.html`. Returns the number
* of files written. Pure aside from `fs` writes — deterministic + idempotent.
*/
declare function emitStaticSeoHtml(params: {
outDir: string;
shellHtml: string;
routes: PageRouteEntry[];
seoConfig: SeoConfiguration | undefined;
/**
* Parsed `.dcs/content.yaml` — threads REAL review items (honesty-gated) into
* the per-route LocalBusiness node. Optional; omit ⇒ no Review/aggregateRating.
*/
contentConfig?: ContentConfig;
exclude?: string[];
noindex?: string[];
/** `pages.yaml` top-level `excluded:` globs — routes matching are NOT emitted. */
excludedGlobs?: string[];
debug?: boolean;
}): number;
/**
* Vite plugin that injects .dcs/seo.yaml at build time and, optionally, emits
* per-route static `` HTML for SPA SEO.
*
* @param options - Plugin configuration
* @returns Vite plugin
*/
declare function dcsSeoPlugin(options?: DcsSeoPluginOptions): Plugin;
/**
* DCS Editor Plugin for Vite
*
* Injects the editor bridge script into customer sites when running
* in portal preview mode (inside the visual editor iframe).
*
* The bridge enables:
* - Inline text editing via contenteditable
* - Section hover highlights and AI ✨ buttons
* - postMessage communication with the portal
*
* This plugin should only be active in development/preview mode.
* It's safe to include in production builds — it does nothing unless
* the page detects it's inside an iframe.
*
* @example
* ```typescript
* // vite.config.ts
* import { dcsEditorPlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* plugins: [
* dcsEditorPlugin()
* ]
* })
* ```
*/
interface DcsEditorPluginOptions {
/** Enable debug logging */
debug?: boolean;
}
/**
* Vite plugin that injects the editor bridge script for portal preview integration.
*
* The bridge auto-initializes only when the page detects it's running inside an iframe,
* so it's safe to include in all builds — it's a no-op in standalone browsing.
*
* Uses a virtual module (`/__dcs-editor-bridge.js`) served through Vite's dev server
* so that bare module specifiers (like `@duffcloudservices/cms/editor`) are properly
* resolved through Vite's module graph rather than hitting the browser's native ESM
* resolver, which can't handle bare specifiers.
*/
declare function dcsEditorPlugin(options?: DcsEditorPluginOptions): Plugin;
/**
* DCS Preview Plugin for Vue
*
* Registers a supplied ribbon component as a global `DcsPreviewRibbon`
* component. The ribbon handles its own visibility — it only renders on
* `preview.duffcloudservices.com` and hides everywhere else (localhost,
* production domains, and inside the visual page editor iframe).
*
* Why does the caller pass the component in? Because this file is compiled
* by tsup (esbuild) which has no `.vue` SFC loader. Keeping the raw `.vue`
* import out of the compiled plugins bundle avoids the build error while
* still letting consumer code (which *does* run through Vite) resolve the
* SFC at dev/build time.
*
* @example VitePress theme/index.ts
* ```typescript
* import { dcsPreviewPlugin } from '@duffcloudservices/cms/plugins'
* import PreviewRibbon from '@duffcloudservices/cms/components'
*
* export default {
* Layout,
* enhanceApp({ app }) {
* app.use(dcsPreviewPlugin(PreviewRibbon))
* }
* }
* ```
*/
interface DcsPreviewPluginOptions {
/**
* Override the version string displayed on the ribbon.
* If omitted, the ribbon auto-detects from VITE_SITE_VERSION or the API.
*/
version?: string | null;
}
/**
* Creates and returns the DCS Preview plugin.
*
* When installed, it registers the supplied ribbon component as a global
* `DcsPreviewRibbon` component. Add `` to your root
* Layout, or use the `dcsEditorPlugin` Vite plugin which injects it via
* `transformIndexHtml`.
*
* @param ribbonComponent - The PreviewRibbon SFC (imported by the consumer)
* @param options - Optional configuration
*/
declare function dcsPreviewPlugin(ribbonComponent: Component, options?: DcsPreviewPluginOptions): Plugin$1;
/**
* DCS CDN Image Plugin for Vite
*
* Rewrites local static image references (e.g. `/images/staff/photo.jpg`)
* to CDN URLs at build time using the `.dcs/cdn-image-map.json` mapping file
* generated by the `image-migrate adopt` CLI command.
*
* For raster images with WebP variants, `` elements are transformed into
* responsive `` elements with `srcset` for optimised delivery.
* SVGs receive a simple URL swap with no variant handling.
*
* **In development mode this plugin is a no-op** — local `/images/` paths
* continue to work via Vite's static asset serving so hot-reload is unaffected.
*
* The plugin handles two in-pipeline replacement vectors:
*
* 1. **Module transform** (`transform` hook) — rewrites `` tags and
* string literals in Vue SFCs, TS, JS, CSS, MD, and HTML modules.
* 2. **Chunk rendering** (`renderChunk` hook) — rewrites image paths in
* final rendered JS/CSS chunks AFTER Vite's `define` substitution.
* This catches data injected by `dcsContentPlugin` via `__DCS_CONTENT__`
* (from `.dcs/content.yaml`) which bypasses the `transform` hook.
*
* A third vector — **post-build file processing** — is handled by the
* companion `dcsCdnBuildEnd` hook, which must be registered separately in
* VitePress config. VitePress generates HTML *after* both Vite builds
* complete, so Vite plugin hooks (`closeBundle`, `transformIndexHtml`)
* cannot catch SSR-rendered `` tags or static `public/` files like
* `service-worker.js`.
*
* @example
* ```ts
* // .vitepress/config.ts
* import { dcsCdnImagePlugin, dcsCdnBuildEnd } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* vite: {
* plugins: [
* dcsCdnImagePlugin()
* ]
* },
* buildEnd: dcsCdnBuildEnd()
* })
* ```
*/
interface DcsCdnImagePluginOptions {
/** Path to cdn-image-map.json relative to project root (default: '.dcs/cdn-image-map.json') */
mapPath?: string;
/**
* Patterns to match for replacement. Each must be a **leading-slash path prefix**
* that appears in source code (e.g. `/images/`). The `localPath` field in the
* mapping file is compared *without* a leading slash.
*
* Default: `['/images/']`
*/
pathPrefixes?: string[];
/**
* Default `sizes` attribute for responsive `` elements.
* Override per-context via the `data-sizes` attribute on the original ``.
*
* Default: `'(max-width: 1024px) 100vw, 1024px'`
*/
defaultSizes?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* Vite plugin that rewrites static `/images/` paths to CDN URLs at build time.
*/
declare function dcsCdnImagePlugin(options?: DcsCdnImagePluginOptions): Plugin;
interface DcsCdnBuildEndOptions {
/** Path to cdn-image-map.json relative to project root (default: '.dcs/cdn-image-map.json') */
mapPath?: string;
/**
* Patterns to match for replacement.
* Default: `['/images/']`
*/
pathPrefixes?: string[];
/** Enable debug logging */
debug?: boolean;
/**
* File extensions to process in the output directory.
* Default: `['.html', '.js']`
*/
extensions?: string[];
}
/**
* VitePress `buildEnd` hook factory that post-processes generated HTML and
* static files in the output directory to replace remaining `/images/` paths
* with CDN URLs.
*
* VitePress generates HTML **after** both Vite builds complete, so Vite
* plugin hooks (`closeBundle`, `transformIndexHtml`) cannot catch
* SSR-rendered `` tags (favicons, OG images). This hook runs after all
* HTML files are written to disk.
*
* Also rewrites static files (e.g. `service-worker.js`) copied from
* `public/` that are not part of Vite's module pipeline.
*
* @example
* ```ts
* // .vitepress/config.ts
* import { dcsCdnBuildEnd } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* buildEnd: dcsCdnBuildEnd({ debug: true })
* })
* ```
*/
declare function dcsCdnBuildEnd(options?: DcsCdnBuildEndOptions): (siteConfig: {
root: string;
outDir: string;
}) => Promise;
/**
* markdown-it plugin that transforms standard `` image syntax
* into responsive `` elements when the URL matches the DCS CDN
* asset pattern.
*
* Non-CDN images are rendered with the default image renderer (plain ``).
*
* @example
* ```ts
* // .vitepress/config.ts
* import { responsiveImagePlugin } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* markdown: {
* config: (md) => {
* md.use(responsiveImagePlugin)
* },
* },
* })
* ```
*
* Input markdown:
* ```md
* 
* ```
*
* Rendered HTML:
* ```html
*
*
*
*
* ```
*/
declare function responsiveImagePlugin(md: MarkdownIt): void;
/**
* Build-time responsive-variant lint for DCS customer sites.
*
* The 2026-07-03 flagship-excellence review (P7) found desktop pages shipping
* un-suffixed **full-size** CDN images (e.g. a 321.9 KB hero `47af1e8f….webp`)
* instead of a `-md`/`-lg` responsive step. That happens when an image reaches
* the output HTML through a path that does NOT run the `` → ``
* transform — content injected via `__DCS_CONTENT__` and then swapped to the
* base `cdnUrl` by `dcsCdnBuildEnd`, or a hand-authored `` pointing at
* a base CDN asset URL.
*
* A DCS CDN asset URL has the shape
* `https://files./[content/]/assets/[/].`
* Responsive variants insert a `-sm`/`-md`/`-lg` suffix before the extension
* (`…-md.webp`). Those suffixes contain non-hex letters, so a URL that
* still matches the *base* pattern below is, by definition, an un-suffixed
* full-size asset. This lint scans the built HTML's `` and
* `` attributes for such URLs and reports (or fails on) them.
*
* It is intentionally a **static-output** lint (VitePress generates HTML after
* both Vite builds finish, so a Vite plugin hook cannot see the SSG output) and
* is registered via VitePress `buildEnd`.
*
* @example
* ```ts
* // .vitepress/config.ts
* import { dcsCdnBuildEnd, dcsResponsiveImageLint, chainBuildEnd } from '@duffcloudservices/cms/plugins'
*
* export default defineConfig({
* // run the CDN rewrite first, then lint the result
* buildEnd: chainBuildEnd(dcsCdnBuildEnd(), dcsResponsiveImageLint()),
* })
* ```
*/
interface ResponsiveImageViolation {
/** The un-suffixed full-size CDN URL that should have been a responsive variant. */
url: string;
/** Which element referenced it. */
tag: 'img' | 'source';
}
/** True when `url` is a base (un-suffixed, full-size) DCS CDN asset URL. */
declare function isFullSizeCdnUrl(url: string): boolean;
/**
* Scan an HTML string and return every `` / `` reference
* that resolves to a base (un-suffixed) full-size DCS CDN asset. `srcset`
* candidates are split on commas and only the URL portion (before the width /
* density descriptor) is tested. `onerror` and other attributes are ignored —
* only the actual `src` / `srcset` sources are inspected.
*/
declare function findFullSizeCdnImages(html: string): ResponsiveImageViolation[];
interface DcsResponsiveImageLintOptions {
/**
* Throw (fail the build) when full-size images are found. Default `false`
* (warn only) so the guard can land ahead of the per-site cleanup; flip to
* `true` in CI once a site's output is clean to prevent regressions.
*/
failOnViolation?: boolean;
/** File extensions to scan in the output directory. Default `['.html']`. */
extensions?: string[];
/** Enable per-file debug logging. */
debug?: boolean;
}
interface ResponsiveImageLintReport {
filesScanned: number;
violations: Array;
}
/**
* VitePress `buildEnd` factory that lints the generated HTML for un-suffixed
* full-size CDN images. Returns the report (also useful for tests) and either
* warns or throws depending on `failOnViolation`.
*/
declare function dcsResponsiveImageLint(options?: DcsResponsiveImageLintOptions): (siteConfig: {
outDir: string;
}) => Promise;
/**
* Compose multiple VitePress `buildEnd` hooks into one (VitePress accepts a
* single `buildEnd`). Hooks run sequentially in the order given, so a rewrite
* hook (`dcsCdnBuildEnd`) can precede a lint (`dcsResponsiveImageLint`).
*/
declare function chainBuildEnd(...hooks: Array<((siteConfig: T) => unknown | Promise) | undefined | null>): (siteConfig: T) => Promise;
/**
* DCS motion tokens — the customer-site copy of the shared motion vocabulary.
*
* Injected as a constant `