@duffcloudservices/cms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +13 -1
  7. package/dist/editor/editorBridge.js +75 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +365 -22
  11. package/dist/index.js +421 -21
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +90 -6
  15. package/dist/plugins/index.js +530 -49
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +17 -6
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/LiteMediaEmbed.vue +3 -3
  24. package/src/components/ManagedImage.test.ts +34 -0
  25. package/src/components/ManagedImage.vue +5 -0
  26. package/src/components/PreviewRibbon.vue +4 -1
  27. package/src/composables/useConversionTracking.test.ts +492 -0
  28. package/src/composables/useConversionTracking.ts +770 -0
  29. package/src/composables/useReleaseNotes.ts +7 -1
  30. package/src/composables/useSEO.applyHead.test.ts +150 -0
  31. package/src/composables/useSEO.ts +63 -17
  32. package/src/composables/useSiteVersion.ts +4 -1
  33. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  34. package/src/composables/useSiteVisitorSession.ts +39 -3
  35. package/src/composables/useTextContent.ts +9 -1
  36. package/dist/chunk-DAYLLSEE.js +0 -3
  37. package/dist/chunk-DAYLLSEE.js.map +0 -1
  38. package/dist/chunk-F3EIWEZD.js.map +0 -1
  39. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
@@ -0,0 +1,222 @@
1
+ /**
2
+ * P1 — baked-vs-rendered `<head>` honesty rail.
3
+ *
4
+ * ## The defect this exists for
5
+ *
6
+ * `.dcs/seo.yaml` is the owner-approved, portal-managed source of truth for a
7
+ * page's `<title>` and description, and {@link buildHeadTags} bakes it into the
8
+ * per-route static HTML. But the SAME resolver is also reachable at runtime via
9
+ * `useSEO().applyHead(overrides)`, and an `overrides.title` is used VERBATIM —
10
+ * it does not go through `titleTemplate`. So a view that hardcodes
11
+ * `applyHead({ title: 'Our Services' })` silently replaces the approved title
12
+ * the instant the app boots.
13
+ *
14
+ * Measured on live production (2026-07-27, before the site-local fix):
15
+ *
16
+ * ```
17
+ * route baked (non-JS crawler) runtime (Google + humans)
18
+ * / Rochester Hills Handyman, Repairs & Carpentry | Iron Oak Iron Oak Contractors | Crafted Repairs …
19
+ * /services Handyman, Carpentry & Home Repairs in SE Michigan | … Our Services
20
+ * ```
21
+ *
22
+ * Two crawlers, two different sites. Nothing failed. Nothing logged. The
23
+ * approved SEO decision (C-147 / Q-141=B) was live for AI crawlers and reverted
24
+ * for Google — for the whole time since it shipped.
25
+ *
26
+ * ## What the rail asserts
27
+ *
28
+ * After the build, for every route the emitter wrote, the prerender browser is
29
+ * already loading the page. This rail reads `document.title` and the effective
30
+ * `meta[name=description]` from that SAME render and compares them to the values
31
+ * baked into the file on disk. Any divergence names the route and BOTH values.
32
+ *
33
+ * ## Why the pass condition is stable (not a moving target)
34
+ *
35
+ * A site whose views call `applyHead()` with no title/description overrides
36
+ * produces byte-identical tags at runtime and at build time — same
37
+ * {@link buildHeadTags}, same `seo.yaml` (the plugin bakes it into
38
+ * `__DCS_SEO__`). Zero divergence is therefore the *structural* outcome of
39
+ * seo.yaml being the only writer, not a threshold someone has to keep tuning.
40
+ *
41
+ * ## Escape hatches (visible by construction)
42
+ *
43
+ * `mode` can be lowered to `warn`/`off` and individual routes can be listed in
44
+ * `allow`, but both live in `.dcs/seo.yaml` (portal-owned, in git, reviewable)
45
+ * or in an env var that prints in the build log — there is no silent way to
46
+ * switch this off. Every allowed route is still logged.
47
+ */
48
+ /** How a divergence is reported. */
49
+ type HonestyMode = 'error' | 'warn' | 'off';
50
+ /** One route's baked and rendered head values. */
51
+ interface HeadObservation {
52
+ /** Route path as it appears in `pages.yaml` (e.g. `/services`). */
53
+ route: string;
54
+ /** `<title>` as written into the emitted file (still HTML-escaped). */
55
+ bakedTitle: string | null;
56
+ /** `document.title` after the app mounted. */
57
+ runtimeTitle: string | null;
58
+ /** `meta[name=description]@content` as written into the emitted file. */
59
+ bakedDescription: string | null;
60
+ /** The EFFECTIVE `meta[name=description]` in the rendered DOM (the last one). */
61
+ runtimeDescription: string | null;
62
+ /** How many `meta[name=description]` tags the rendered DOM carried. */
63
+ runtimeDescriptionCount?: number;
64
+ }
65
+ /** A single baked≠rendered divergence. */
66
+ interface HeadHonestyViolation {
67
+ route: string;
68
+ field: 'title' | 'description';
69
+ baked: string | null;
70
+ runtime: string | null;
71
+ }
72
+ /** Configuration for the rail. */
73
+ interface HeadHonestyOptions {
74
+ /** `error` (default) fails the build, `warn` logs, `off` skips entirely. */
75
+ mode?: HonestyMode;
76
+ /** Also compare the meta description (default `true`). */
77
+ checkDescription?: boolean;
78
+ /**
79
+ * Route paths exempted from the comparison. An exemption is still LOGGED, so
80
+ * "who turned this off for which route" is answerable from the build output.
81
+ */
82
+ allow?: string[];
83
+ }
84
+ /**
85
+ * Decode the HTML character references the emitter can produce.
86
+ *
87
+ * This is NOT a general-purpose entity decoder — it covers the set
88
+ * `spliceHeadHtml`'s `escapeAttr` emits (`& " < >`) plus numeric references and
89
+ * the handful of named ones that appear in real CMS copy. Anything unrecognised
90
+ * is left verbatim, which is the safe direction: an unknown entity can only ever
91
+ * cause a *reported* divergence to be investigated, never a real one to be
92
+ * silently swallowed.
93
+ */
94
+ declare function decodeHtmlEntities(value: string): string;
95
+ /**
96
+ * Put a baked (HTML-source) value and a rendered (DOM) value on the same
97
+ * footing before comparing them.
98
+ *
99
+ * The three transforms exist because of three MEASURED false-positive
100
+ * mechanisms, not from caution:
101
+ * - **entity decode** — the baked side carries `Repairs &amp; Carpentry`
102
+ * while `document.title` yields `Repairs & Carpentry`. Identical strings.
103
+ * - **whitespace collapse** — `document.title` is specified to strip and
104
+ * collapse ASCII whitespace; the emitted `<title>` keeps the author's.
105
+ * - **Unicode NFC** — a composed `é` and a decomposed `é` render identically
106
+ * and mean the same thing; only the byte sequence differs.
107
+ *
108
+ * Deliberately NOT normalised: case, punctuation, and typographic look-alikes
109
+ * (`-` vs `—`, `'` vs `’`). Those are real content differences and a rail that
110
+ * hides them is the vacuous-green shape this whole class of work exists to kill.
111
+ */
112
+ declare function normalizeHeadText(value: string | null | undefined): string | null;
113
+ /** The baked `<title>` / description of an emitted document. */
114
+ interface BakedHead {
115
+ title: string | null;
116
+ description: string | null;
117
+ }
118
+ /**
119
+ * Read the baked `<title>` and `meta[name=description]` out of an emitted file.
120
+ *
121
+ * Regex-based on purpose — it mirrors `spliceHeadHtml`, runs over the emitter's
122
+ * own deterministic output, and keeps a heavy HTML parser out of the build.
123
+ */
124
+ declare function extractBakedHead(html: string): BakedHead;
125
+ /**
126
+ * Compare every observation and return the divergences.
127
+ *
128
+ * A `null` on either side is meaningful and reported: a runtime that DELETES the
129
+ * baked description is the same class of silent undoing as one that rewrites it.
130
+ * The one exception is a route where BOTH sides are absent — nothing was claimed,
131
+ * so nothing was undone.
132
+ */
133
+ declare function findHeadHonestyViolations(observations: HeadObservation[], options?: HeadHonestyOptions): HeadHonestyViolation[];
134
+ /**
135
+ * Render the full report. Every violation names the ROUTE and BOTH values, so
136
+ * the operator never has to reproduce the divergence to act on it — which is the
137
+ * difference between a rail people fix and a rail people mute.
138
+ */
139
+ declare function formatHeadHonestyReport(violations: HeadHonestyViolation[]): string;
140
+ /** Thrown in `error` mode so the build goes red on a divergence. */
141
+ declare class HeadHonestyError extends Error {
142
+ readonly violations: HeadHonestyViolation[];
143
+ constructor(violations: HeadHonestyViolation[]);
144
+ }
145
+ /** Inputs that can set the rail's severity, in increasing precedence. */
146
+ interface HonestyModeInput {
147
+ /** Compiled-in default. */
148
+ fallback?: HonestyMode;
149
+ /** The plugin option in `vite.config.ts`. */
150
+ option?: HonestyMode | boolean;
151
+ /** The per-site escape hatch in `.dcs/seo.yaml`. */
152
+ seoYaml?: HonestyMode | boolean;
153
+ /** The env override (e.g. `DCS_SEO_HEAD_HONESTY=warn`) — highest precedence. */
154
+ env?: string | undefined;
155
+ }
156
+ /**
157
+ * Resolve the effective severity.
158
+ *
159
+ * Precedence is env > seo.yaml > plugin option > default, i.e. the MOST
160
+ * operator-visible signal wins. An unrecognised value is ignored rather than
161
+ * treated as "off" — a typo'd escape hatch must never silently disable a gate.
162
+ */
163
+ declare function resolveHonestyMode(input: HonestyModeInput): HonestyMode;
164
+ /**
165
+ * What the render pass was able to observe, so the rail can prove it RAN.
166
+ *
167
+ * ## The defect this exists for
168
+ *
169
+ * C-334 shipped P1 with an environment-dependent silent skip: when
170
+ * `createPlaywrightRenderer` found no playwright/browser it warned and returned
171
+ * no observations, and the caller then *skipped the assert entirely* — so an
172
+ * `error`-severity rail let the build pass having checked nothing. That is the
173
+ * same shape as C-322 (the snapshot rail was vacuous for months because `gh` was
174
+ * missing on the runner) and the exact class this campaign exists to close.
175
+ *
176
+ * A rail is only a gate if "it did not run" is itself a failure. In `error`
177
+ * mode the three non-execution shapes below are hard failures; in `warn` mode
178
+ * they are warnings that name exactly what was not checked.
179
+ */
180
+ interface HeadHonestyExecution {
181
+ /** Did the build have a headless renderer at all? */
182
+ rendererAvailable: boolean;
183
+ /** Routes that reached the renderer (the denominator). */
184
+ eligibleRoutes: string[];
185
+ /** Routes that produced a baked-vs-rendered observation (the numerator). */
186
+ observedRoutes: string[];
187
+ /** Routes that never reached the renderer, with why. */
188
+ filteredRoutes: Array<{
189
+ route: string;
190
+ reason: string;
191
+ }>;
192
+ /** Every route in the manifest, for the "0 of N" arithmetic. */
193
+ totalRoutes: number;
194
+ }
195
+ /** Why the rail did not run (empty ⇒ it ran). */
196
+ declare function findHeadHonestyExecutionFaults(ex: HeadHonestyExecution): string[];
197
+ /** Render the "this rail did not run" report — it names what went unchecked. */
198
+ declare function formatHeadHonestyExecutionReport(ex: HeadHonestyExecution, faults: string[]): string;
199
+ /** Thrown in `error` mode when the P1 rail could not run. */
200
+ declare class HeadHonestyNotRunError extends Error {
201
+ readonly execution: HeadHonestyExecution;
202
+ readonly faults: string[];
203
+ constructor(execution: HeadHonestyExecution, faults: string[]);
204
+ }
205
+ /**
206
+ * Assert the rail RAN. Throws {@link HeadHonestyNotRunError} in `error` mode,
207
+ * warns in `warn` mode. Returns `true` when the comparison is worth running
208
+ * (i.e. there is at least one observation to compare).
209
+ */
210
+ declare function assertHeadHonestyExecuted(execution: HeadHonestyExecution, options: {
211
+ mode: HonestyMode;
212
+ }): boolean;
213
+ /**
214
+ * Apply the rail: throw in `error` mode, log in `warn` mode, do nothing in
215
+ * `off`. Returns the violations so callers can report counts.
216
+ */
217
+ declare function assertHeadHonesty(observations: HeadObservation[], options: HeadHonestyOptions & {
218
+ mode: HonestyMode;
219
+ debug?: boolean;
220
+ }): HeadHonestyViolation[];
221
+
222
+ export { type BakedHead as B, type HonestyMode as H, type HeadObservation as a, assertHeadHonesty as b, formatHeadHonestyReport as c, decodeHtmlEntities as d, extractBakedHead as e, findHeadHonestyViolations as f, HeadHonestyError as g, assertHeadHonestyExecuted as h, findHeadHonestyExecutionFaults as i, formatHeadHonestyExecutionReport as j, HeadHonestyNotRunError as k, type HeadHonestyExecution as l, type HonestyModeInput as m, normalizeHeadText as n, type HeadHonestyViolation as o, type HeadHonestyOptions as p, resolveHonestyMode as r };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as vue from 'vue';
2
2
  import { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
3
- import { U as UseSeoReturn, G as GlobalSeoConfig } from './vitepressTransform-DfmABXmK.js';
4
- export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource, X as HeadOverrides, P as PageSeoConfig, R as ResolvedPageOverrides, T as ResolvedPageSeo, z as ReviewSource, w as SchemaObject, O as SeoAlternateConfig, I as SeoAuthorConfig, H as SeoConfiguration, K as SeoImagesConfig, L as SeoOpenGraphConfig, S as SeoPageContext, r as SeoPageTypeRule, N as SeoSchemaConfig, J as SeoSocialConfig, M as SeoTwitterConfig, Q as SeoVerificationConfig, W as UseSeoConfig, t as VitePressHeadConfig, V as VitePressPageData, q as absolutizeUrl, j as breadcrumbTrailFromRoute, k as buildBlogPosting, h as buildBreadcrumbList, l as buildFaqPage, g as buildGlobalGraph, f as buildLlmsTxt, m as buildReviewSchemaParts, e as buildRobotsTxt, a as buildSitemapXml, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute, o as filterRealFaq, n as filterRealReviews, p as findReviewItemsForPage, i as isRouteIndexable, s as slugToTitle } from './vitepressTransform-DfmABXmK.js';
5
- export { h as HeadLinkTag, H as HeadMetaTag, i as HeadScriptTag, j as HeadTagOverrides, R as ResolvedHeadTags, b as buildHeadTags, f as escapeJsonLd, g as generateJsonLd, a as generateOpenGraphMeta, c as generateTwitterMeta, d as renderHeadTags, r as resolvePageSeo, s as spliceHeadHtml, e as stripManagedHeadTags } from './spliceHeadHtml-CsBEucGy.js';
3
+ import { U as UseSeoReturn, G as GlobalSeoConfig } from './vitepressTransform-JG_zlaux.js';
4
+ export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource, X as HeadOverrides, P as PageSeoConfig, R as ResolvedPageOverrides, T as ResolvedPageSeo, z as ReviewSource, w as SchemaObject, O as SeoAlternateConfig, I as SeoAuthorConfig, H as SeoConfiguration, K as SeoImagesConfig, L as SeoOpenGraphConfig, S as SeoPageContext, r as SeoPageTypeRule, N as SeoSchemaConfig, J as SeoSocialConfig, M as SeoTwitterConfig, Q as SeoVerificationConfig, W as UseSeoConfig, t as VitePressHeadConfig, V as VitePressPageData, q as absolutizeUrl, j as breadcrumbTrailFromRoute, k as buildBlogPosting, h as buildBreadcrumbList, l as buildFaqPage, g as buildGlobalGraph, f as buildLlmsTxt, m as buildReviewSchemaParts, e as buildRobotsTxt, a as buildSitemapXml, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute, o as filterRealFaq, n as filterRealReviews, p as findReviewItemsForPage, i as isRouteIndexable, s as slugToTitle } from './vitepressTransform-JG_zlaux.js';
5
+ export { j as HeadLinkTag, H as HeadMetaTag, k as HeadScriptTag, l as HeadTagOverrides, I as InstallSeoHeadOptions, R as ResolvedHeadTags, o as SeoHeadClientLike, p as SeoHeadEntryLike, q as SeoHeadInput, t as SeoHeadPageRoute, u as SeoHeadPagesManifest, v as SeoHeadResolution, w as SeoHeadResolutionReason, m as SeoHeadRouteLike, S as SeoHeadRouterLike, b as buildHeadTags, h as buildSeoHeadRouteMap, f as escapeJsonLd, g as generateJsonLd, a as generateOpenGraphMeta, c as generateTwitterMeta, i as installSeoHead, n as normalizeSeoHeadPath, d as renderHeadTags, r as resolvePageSeo, s as spliceHeadHtml, e as stripManagedHeadTags } from './installSeoHead-kWQwObez.js';
6
6
  import { ImageContext, ResponsiveImageResult } from '@duffcloudservices/cms-core';
7
7
  export { ImageContext, ResponsiveImageOptions, ResponsiveImageResult, ResponsiveSource, isCdnAssetUrl, resolveResponsiveImage } from '@duffcloudservices/cms-core';
8
8
 
@@ -119,6 +119,12 @@ declare function useTextContent(config: TextContentConfig): TextContentReturn;
119
119
  * module so that the build-time static-HTML emitter (`dcsSeoPlugin`) produces
120
120
  * byte-identical output. This composable is a thin Vue/unhead wrapper over it.
121
121
  *
122
+ * THE HEAD-AUTHORITY CONTRACT (C-356). `.dcs/seo.yaml` is the ONLY writer of
123
+ * the managed head fields. `applyHead()` RE-ASSERTS the baked head at runtime;
124
+ * it never AUTHORS one, so it takes no arguments. Full text + reasoning:
125
+ * `.docs/plans/dynamic-site-resolution/README.md` § "The head-authority
126
+ * contract (C-356)".
127
+ *
122
128
  * @example
123
129
  * ```vue
124
130
  * <script setup lang="ts">
@@ -126,16 +132,15 @@ declare function useTextContent(config: TextContentConfig): TextContentReturn;
126
132
  *
127
133
  * const { applyHead, getSchema, config } = useSEO('home')
128
134
  *
129
- * // Apply all meta tags
135
+ * // Re-assert the baked head for this route. No arguments — ever.
130
136
  * applyHead()
131
- *
132
- * // Or customize before applying
133
- * applyHead({
134
- * title: 'Custom Override Title',
135
- * schemas: [...getSchema(), customSchema]
136
- * })
137
137
  * </script>
138
138
  * ```
139
+ *
140
+ * Need a different title? Change it in `.dcs/seo.yaml` (or in the portal SEO
141
+ * editor, which writes it). A value hardcoded here is a SECOND writer, and a
142
+ * second writer is a divergence by construction — whether or not today's two
143
+ * values happen to agree.
139
144
  */
140
145
 
141
146
  /**
@@ -477,22 +482,347 @@ interface UseReviewContentReturn {
477
482
  }
478
483
  declare function useReviewContent(config: UseReviewContentConfig): UseReviewContentReturn;
479
484
 
485
+ /** How a clicked element was classified. */
486
+ type ConversionInteractionType = 'booking' | 'phone' | 'email' | 'form_submit' | 'social' | 'external' | 'internal' | 'button';
487
+ /**
488
+ * The interaction types that ARE the money moment — the ones an owner report counts.
489
+ *
490
+ * Everything else (`social`, `external`, `internal`, `button`) is navigation telemetry and
491
+ * is still captured, but it must never be summed into "conversions". Keeping the set here,
492
+ * rather than in each report's query, means one edit changes every consumer.
493
+ */
494
+ declare const CONVERSION_INTERACTION_TYPES: readonly ConversionInteractionType[];
495
+ /** Whether an interaction type counts as a conversion. */
496
+ declare function isConversionType(type: ConversionInteractionType): boolean;
497
+ /** A captured conversion event, in App Insights `trackEvent` shape. */
498
+ interface ConversionEvent {
499
+ /** Event name — `site_interaction` by default (see {@link ConversionTrackingOptions.eventName}). */
500
+ name: string;
501
+ /** Flat string properties; App Insights `customDimensions`. */
502
+ properties: {
503
+ interaction_type: ConversionInteractionType;
504
+ /**
505
+ * `'true'` when {@link isConversionType} holds. A string, not a boolean, because App
506
+ * Insights `customDimensions` and GA4 event params are both string maps — so the
507
+ * owner-report query is one predicate (`is_conversion == "true"`) instead of an
508
+ * interaction-type IN-list that every new report has to remember to keep in sync.
509
+ */
510
+ is_conversion: string;
511
+ /** Visible label / aria-label of the clicked element, truncated. */
512
+ label: string;
513
+ /**
514
+ * Destination, query string and fragment stripped, and REDACTED for contact schemes:
515
+ * a `tel:` / `sms:` / `mailto:` href becomes `tel:#<digest>` — never the raw number or
516
+ * address. See {@link redactHref}. Empty for buttons and form submits.
517
+ */
518
+ href: string;
519
+ /** URL scheme of the destination including the colon (`tel:`, `https:`), or `''`. */
520
+ href_scheme: string;
521
+ /** Host of the destination, or `''` for buttons, form submits and contact schemes. */
522
+ href_host: string;
523
+ /**
524
+ * Short digest of the contact target (the phone number / email address), or `''` for
525
+ * everything else. Lets a report say "CTA A got 12 taps, CTA B got 3" without ever
526
+ * storing the contact string itself.
527
+ */
528
+ href_hash: string;
529
+ /** Path of the page the click happened on. */
530
+ page_path: string;
531
+ /** Host of the page the click happened on. */
532
+ host: string;
533
+ /** Schema version, so a report can tell old rows from new ones. */
534
+ capture_version: string;
535
+ };
536
+ }
537
+ /** A telemetry transport. Typically `(e) => telemetry.trackEvent(e)`. */
538
+ type ConversionSink = (event: ConversionEvent) => void;
539
+ interface ConversionTrackingOptions {
540
+ /**
541
+ * Where to send events. Optional on purpose — omit it when the telemetry SDK is
542
+ * deferred, and call {@link attachConversionSink} once it has loaded. Events captured
543
+ * in the meantime are buffered, not dropped.
544
+ */
545
+ sink?: ConversionSink;
546
+ /**
547
+ * Fire-and-forget transports that receive every event AS WELL AS `sink`, and that do NOT
548
+ * count as "a sink exists" for buffering purposes.
549
+ *
550
+ * This distinction is load-bearing. GA4's `gtag` is a mirror: the deploy injects it as a
551
+ * synchronous inline snippet, so it is either there when the click happens or the hit is
552
+ * genuinely unavailable — there is nothing to wait for. App Insights is a `sink`: it boots
553
+ * on an idle callback minutes later, which is exactly what the buffer exists to survive.
554
+ * Treating GA4 as a `sink` would have satisfied the "do we have somewhere to send this?"
555
+ * test on every site and quietly disabled the deferred-SDK interlock.
556
+ */
557
+ mirrors?: ConversionSink[];
558
+ /**
559
+ * Custom event name. Defaults to `site_interaction` — the name Just Posh has been
560
+ * emitting since 2026-04, so its history stays one continuous series. Pass
561
+ * `'dcs_conversion'` on a site with no existing history if you prefer the canonical name.
562
+ */
563
+ eventName?: string;
564
+ /**
565
+ * Extra hostnames to treat as booking destinations, on top of {@link DEFAULT_BOOKING_HOSTS}.
566
+ * Matched on host suffix, so `vagaro.com` also matches `www.vagaro.com`.
567
+ */
568
+ bookingHosts?: string[];
569
+ /**
570
+ * Same-origin paths that mean "booking" (e.g. a self-hosted `/book`). Matched as a
571
+ * prefix on the pathname.
572
+ */
573
+ bookingPaths?: string[];
574
+ /** Extra social hostnames on top of {@link DEFAULT_SOCIAL_HOSTS}. */
575
+ socialHosts?: string[];
576
+ /** Max events held while no sink is attached. Default 50 — bounded so a bot cannot grow it. */
577
+ bufferLimit?: number;
578
+ /** Drop untrusted (script-dispatched) clicks. Default `false`. */
579
+ requireTrusted?: boolean;
580
+ /** Document to bind to. Defaults to the ambient `document`. Injected in tests. */
581
+ target?: Document;
582
+ /**
583
+ * Also capture managed-form submissions as `form_submit`. Default `true`.
584
+ *
585
+ * A form submit is a conversion on every DCS site that has a form, and it is the one
586
+ * affordance a click listener alone cannot see honestly: clicking "Send" on a form that
587
+ * then fails validation is not a lead. So the `submit` event — not the click — is
588
+ * authoritative, and clicks on submit controls are deliberately dropped to keep the two
589
+ * from counting the same action twice.
590
+ */
591
+ captureFormSubmits?: boolean;
592
+ /**
593
+ * Honour the visitor's Do Not Track signal. Default `true`.
594
+ *
595
+ * With DNT on, `start()` binds nothing at all — no listener, no buffer, no event. This
596
+ * is a measurement rail, not a consent platform: if a site ever grows a real consent
597
+ * banner, gate {@link installConversionCapture} on it rather than weakening this.
598
+ */
599
+ respectDoNotTrack?: boolean;
600
+ /**
601
+ * Bind a second delegated listener even though one is already live on this document.
602
+ *
603
+ * Off by default and it should stay off. Two delegated listeners on one document count
604
+ * every click twice — the same defect class C-288 measured as 52% duplicate page views
605
+ * on a live customer site, which the portal then reported to the owner as traffic. If
606
+ * you are reaching for this, you almost certainly want `stop()` on the existing tracker.
607
+ */
608
+ force?: boolean;
609
+ }
610
+ /**
611
+ * Schema version stamped on every captured event. Bump on a breaking property change.
612
+ *
613
+ * `2` (C-326): `href` is redacted for `tel:`/`sms:`/`mailto:`, and `is_conversion`,
614
+ * `href_scheme` + `href_hash` were added. Version `1` rows carry raw contact hrefs and no
615
+ * conversion flag, so a report spanning the boundary must branch on this.
616
+ */
617
+ declare const CONVERSION_CAPTURE_VERSION = "2";
618
+ /**
619
+ * Booking/scheduling vendors seen across the DCS fleet plus the common SMB schedulers.
620
+ * Host-suffix matched. Add per-site extras via `bookingHosts` rather than editing this.
621
+ */
622
+ declare const DEFAULT_BOOKING_HOSTS: readonly string[];
623
+ /** Social destinations. Host-suffix matched. */
624
+ declare const DEFAULT_SOCIAL_HOSTS: readonly string[];
625
+ /**
626
+ * FNV-1a (32-bit), base36. Synchronous on purpose: this runs inside a click handler, where
627
+ * `crypto.subtle` — the only real hash a browser offers — is async and would force the
628
+ * event to be built after the navigation has already started.
629
+ *
630
+ * BE HONEST ABOUT WHAT THIS IS. It is not anonymisation. A site publishes two or three
631
+ * phone numbers, so anybody holding the site could brute-force the digest back in
632
+ * milliseconds. What it buys is real but narrow: the contact string never lands in an
633
+ * analytics store (GA4 forbids PII in event params outright), a support screenshot of the
634
+ * events table cannot leak a customer's mailbox, and the value is still stable enough to
635
+ * answer "which CTA did they tap". Do not describe it as anything more than that.
636
+ */
637
+ declare function hashTarget(value: string): string;
638
+ /** The scheme of an href, including the colon (`tel:`, `https:`), or `''`. */
639
+ declare function hrefScheme(href: string, pageHost: string): string;
640
+ /**
641
+ * Split an href into the parts that are safe to emit.
642
+ *
643
+ * For `tel:` / `sms:` / `mailto:` the target is a person's or business's contact string, so
644
+ * it is replaced by `<scheme>#<digest>` and the digest is also surfaced on its own. For
645
+ * everything else the href passes through {@link sanitizeUrl} unchanged — a booking URL's
646
+ * path is the useful part and its query (which CAN carry a name or email) is already gone.
647
+ */
648
+ declare function redactHref(href: string, pageHost: string): {
649
+ href: string;
650
+ scheme: string;
651
+ hash: string;
652
+ };
653
+ /**
654
+ * Whether this element is the control that submits a form.
655
+ *
656
+ * Clicks on these are dropped so the `submit` event can be the single source of truth —
657
+ * see {@link ConversionTrackingOptions.captureFormSubmits}. Note the HTML default: a
658
+ * `<button>` inside a form with no `type` IS a submit button, which is exactly how every
659
+ * `<DcsForm>` renders its action.
660
+ */
661
+ declare function isSubmitControl(el: Element): boolean;
662
+ /** Read the visitor's Do Not Track signal across the three places browsers have put it. */
663
+ declare function doNotTrackEnabled(): boolean;
664
+ /**
665
+ * Classify a link.
666
+ *
667
+ * ORDER IS LOAD-BEARING and is asserted by tests. Protocol wins first (a `tel:` href has
668
+ * no host to match on), then booking — *before* the same-origin and social checks — so a
669
+ * self-hosted `/book` path and a booking vendor that also runs a social profile are both
670
+ * attributed to revenue rather than to `internal` / `social`.
671
+ */
672
+ declare function classifyHref(href: string, opts: {
673
+ pageHost: string;
674
+ bookingHosts: readonly string[];
675
+ bookingPaths: readonly string[];
676
+ socialHosts: readonly string[];
677
+ }): ConversionInteractionType;
678
+ /** The object returned by {@link createConversionTracker}. */
679
+ interface ConversionTracker {
680
+ /** Bind the capture-phase click listener. Idempotent. */
681
+ start(): void;
682
+ /** Unbind and clear the buffer. Idempotent. */
683
+ stop(): void;
684
+ /** Attach (or replace) the sink and immediately drain anything buffered. */
685
+ attachSink(sink: ConversionSink): void;
686
+ /** Events currently held because no sink is attached. Read-only copy. */
687
+ buffered(): ConversionEvent[];
688
+ /** Capture a click target directly. Exposed for tests and manual instrumentation. */
689
+ capture(element: Element): ConversionEvent | null;
690
+ }
691
+ /**
692
+ * Attach a sink to every live tracker and drain their buffers.
693
+ *
694
+ * Call this from a deferred/lazy App Insights loader once the SDK is ready:
695
+ *
696
+ * ```ts
697
+ * const { ApplicationInsights } = await import('@microsoft/applicationinsights-web')
698
+ * const ai = new ApplicationInsights({ config })
699
+ * ai.loadAppInsights()
700
+ * attachConversionSink((e) => ai.trackEvent({ name: e.name, properties: e.properties }))
701
+ * ```
702
+ *
703
+ * @returns how many trackers received the sink.
704
+ */
705
+ declare function attachConversionSink(sink: ConversionSink): number;
706
+ /**
707
+ * Framework-free conversion tracker. Vue callers usually want
708
+ * {@link useConversionTracking} instead.
709
+ */
710
+ declare function createConversionTracker(options?: ConversionTrackingOptions): ConversionTracker;
711
+ /**
712
+ * Vue composable: start conversion capture for the lifetime of the calling component.
713
+ *
714
+ * YOU PROBABLY DO NOT NEED THIS ANY MORE. Since C-326, importing `@duffcloudservices/cms`
715
+ * auto-installs a tracker (`installConversionCapture`), so every fleet site captures
716
+ * conversions with no site-repo code at all. Calling this on top of that is a no-op with a
717
+ * loud console warning — `start()` refuses to bind a second listener to a document that
718
+ * already has one, because two listeners double every conversion count.
719
+ *
720
+ * It remains exported for the cases the auto-install deliberately does not cover: a site
721
+ * that needs per-site `bookingHosts`/`bookingPaths`, or a non-cms consumer wiring capture
722
+ * by hand. In the first case, prefer configuring the auto-installer:
723
+ *
724
+ * ```ts
725
+ * import { installConversionCapture } from '@duffcloudservices/cms'
726
+ * installConversionCapture({ bookingHosts: ['stridethera.com'], bookingPaths: ['/book'] })
727
+ * ```
728
+ *
729
+ * Returns the tracker so a caller can attach a sink or inspect the buffer. Safe on the
730
+ * server: with no `document`, `start()` is a no-op.
731
+ */
732
+ declare function useConversionTracking(options?: ConversionTrackingOptions): ConversionTracker;
733
+
734
+ /**
735
+ * Fleet-wide conversion capture, installed without asking the site.
736
+ *
737
+ * WHY THIS FILE EXISTS
738
+ * --------------------
739
+ * `useConversionTracking` shipped in @duffcloudservices/cms, was exported from the package
740
+ * index, was documented with an example, and was adopted by ZERO of the eleven fleet sites.
741
+ * Two independent reviews on 2026-07-26 measured what that cost (C-326):
742
+ *
743
+ * - Bryan's Handyman Solutions exists to generate phone calls. It ships five `tel:` CTAs
744
+ * — hero, header, mobile icon, sticky call bar, footer — and every one of them fires
745
+ * nothing. `trackEvent` appears in the bundle exactly once: as the composable's own
746
+ * unused export. Its GA4 property was never created either, so the site has no
747
+ * measurement of any kind for the only action it is built to produce.
748
+ * - Kim Duff Homes, a flagship account, has 6 pageViews in App Insights across 365 days.
749
+ *
750
+ * The lesson is not "write better docs". A capability the site must remember to switch on
751
+ * is a capability that is off. So capture is a SIDE EFFECT of importing the package every
752
+ * fleet site already depends on, and it finds its own transport.
753
+ *
754
+ * HOW A SITE'S EVENTS GET OUT — three sinks, in the order they are usually available
755
+ * ---------------------------------------------------------------------------------
756
+ * 1. GA4 (`window.gtag`). The deploy workflow injects the gtag snippet into every built
757
+ * HTML file when `.dcs/site.yaml` carries a `google_analytics_id`. That means a site
758
+ * with GA4 configured captures conversions with LITERALLY no code change — which is
759
+ * precisely why the empty-`google_analytics_id` launch gate is the other half of C-326.
760
+ * 2. `window.__dcsConversionAttach(sink)` — a one-line, import-free contract published by
761
+ * this module for any telemetry transport that boots later. `@duffcloudservices/telemetry`
762
+ * calls it automatically inside `initialize()`; a site with a bespoke App Insights boot
763
+ * adds the single line. Events captured before that arrive are buffered, not lost.
764
+ * 3. `attachConversionSink(sink)` — the direct API, for callers holding the import.
765
+ *
766
+ * WHERE IT DELIBERATELY DOES NOTHING
767
+ * ----------------------------------
768
+ * - No `document` (SSR, VitePress build, the Playwright prerender pass).
769
+ * - Inside an iframe — the DCS visual editor and preview surfaces frame the site, and
770
+ * an editor clicking around a customer's page is not a lead.
771
+ * - Do Not Track is on.
772
+ * - The page opted out via `window.__dcsConversionOptOut = true` or
773
+ * `<html data-dcs-analytics="off">`.
774
+ *
775
+ * In every one of those cases it binds no listener and buffers nothing.
776
+ */
777
+
778
+ /** Options for {@link installConversionCapture}. */
779
+ interface InstallConversionCaptureOptions extends Omit<ConversionTrackingOptions, 'sink' | 'mirrors' | 'force'> {
780
+ /**
781
+ * Emit through GA4's `window.gtag` when it is present. Default `true`.
782
+ *
783
+ * Looked up at emit time, not install time: the gtag snippet is `async`, so on a fast
784
+ * connection the site's JS can install this before Google's script has defined `gtag`.
785
+ */
786
+ gtag?: boolean;
787
+ }
788
+ /** Whether the page has explicitly asked not to be measured. */
789
+ declare function conversionOptedOut(): boolean;
480
790
  /**
481
- * Site-visitor session probe client.
791
+ * Whether this document is being rendered inside a frame.
482
792
  *
483
- * Customer sites poll "who is signed in?" on load to render the right auth UI.
484
- * Signed-out is the EXPECTED state for the vast majority of visits, so this
485
- * client treats it as normal and never logs:
793
+ * The DCS portal embeds customer sites in an iframe for the visual editor and preview
794
+ * (`dcsEditorPlugin` keys off the same `window.parent !== window` test). Counting an
795
+ * editor's clicks as customer conversions would inflate exactly the number the owner
796
+ * report is supposed to make trustworthy.
797
+ */
798
+ declare function inFrame(): boolean;
799
+ /** Reason the installer declined, or `null` when it installed. */
800
+ type ConversionInstallSkipReason = 'no-document' | 'already-installed' | 'in-frame' | 'do-not-track' | 'opted-out';
801
+ /** Result of an {@link installConversionCapture} call. */
802
+ interface ConversionInstallResult {
803
+ installed: boolean;
804
+ reason: ConversionInstallSkipReason | null;
805
+ tracker: ConversionTracker | null;
806
+ }
807
+ /**
808
+ * Install fleet conversion capture. Idempotent; safe to call from anywhere, any number of
809
+ * times, on the server or in a frame.
486
810
  *
487
- * - New servers answer an anonymous probe with `200 { "visitor": null }`.
488
- * - Old servers (pre-fix) answer an anonymous probe with `401`.
811
+ * Called for its side effect from the package index, so a site gets this by importing
812
+ * `@duffcloudservices/cms` at all. Call it directly only to pass per-site configuration
813
+ * (extra `bookingHosts`, a self-hosted `bookingPaths`) — do that BEFORE the first click,
814
+ * i.e. at app entry, since the first call wins.
815
+ */
816
+ declare function installConversionCapture(options?: InstallConversionCaptureOptions): ConversionInstallResult;
817
+ /** The installed tracker, or `null`. Exposed for tests and diagnostics. */
818
+ declare function installedConversionTracker(): ConversionTracker | null;
819
+ /**
820
+ * Tear the auto-installed tracker down and clear the global flags.
489
821
  *
490
- * Sites bump `@duffcloudservices/cms` independently of server deploys, so this
491
- * client must accept BOTH shapes and stay silent on the anonymous path — a
492
- * `console.error`/`console.warn` on every signed-out page load is exactly the
493
- * console-noise this fixes. A genuine signed-in visitor is returned as a
494
- * normalized `SiteVisitor`; anything else resolves to `null`.
822
+ * Exists for tests and for a site that genuinely needs to re-install with different
823
+ * options. Not part of the normal runtime path.
495
824
  */
825
+ declare function resetConversionCapture(): void;
496
826
 
497
827
  /** A signed-in site visitor. */
498
828
  interface SiteVisitor {
@@ -507,6 +837,14 @@ interface SiteVisitorSessionResult {
507
837
  visitor: SiteVisitor | null;
508
838
  /** Convenience flag — `true` iff a visitor is present. */
509
839
  authenticated: boolean;
840
+ /**
841
+ * `true` when the probe did not reach the platform API at all — the response carried a
842
+ * non-JSON body (C-298 layer 2), i.e. this host does not route `/api/v1/*` to the API.
843
+ * Distinct from an ordinary signed-out visit: a site can render "sign-in temporarily
844
+ * unavailable" instead of a login button that cannot possibly work. Absent/`false` on
845
+ * every normal path, so existing consumers are unaffected.
846
+ */
847
+ apiUnreachable?: boolean;
510
848
  }
511
849
  interface FetchSiteVisitorSessionOptions {
512
850
  /** API base (default `/api/v1`, same-origin via Front Door). */
@@ -531,6 +869,11 @@ interface UseSiteVisitorSessionReturn {
531
869
  isAuthenticated: ComputedRef<boolean>;
532
870
  /** `true` while a probe is in flight. */
533
871
  isLoading: Ref<boolean>;
872
+ /**
873
+ * `true` when the last probe did not reach the platform API (non-JSON body — C-298
874
+ * layer 2). Render "sign-in unavailable" rather than a login button that cannot work.
875
+ */
876
+ apiUnreachable: Ref<boolean>;
534
877
  /** Re-run the probe. */
535
878
  refresh: () => Promise<void>;
536
879
  }
@@ -540,4 +883,4 @@ interface UseSiteVisitorSessionReturn {
540
883
  */
541
884
  declare function useSiteVisitorSession(options?: UseSiteVisitorSessionOptions): UseSiteVisitorSessionReturn;
542
885
 
543
- export { type DcsContentFile, type FetchSiteVisitorSessionOptions, GlobalSeoConfig, type MediaCarouselItem, type ReleaseNote, type ReleaseNotesReturn, type ReviewItem, type SiteVersionReturn, type SiteVisitor, type SiteVisitorSessionResult, type TextContentConfig, type TextContentReturn, type UseMediaCarouselConfig, type UseMediaCarouselReturn, type UseReviewContentConfig, type UseReviewContentReturn, UseSeoReturn, type UseSiteVisitorSessionOptions, type UseSiteVisitorSessionReturn, createSiteSEO, fetchSiteVisitorSession, useMediaCarousel, useReleaseNotes, useResponsiveImage, useReviewContent, useSEO, useSiteVersion, useSiteVisitorSession, useTextContent };
886
+ export { CONVERSION_CAPTURE_VERSION, CONVERSION_INTERACTION_TYPES, type ConversionEvent, type ConversionInstallResult, type ConversionInstallSkipReason, type ConversionInteractionType, type ConversionSink, type ConversionTracker, type ConversionTrackingOptions, DEFAULT_BOOKING_HOSTS, DEFAULT_SOCIAL_HOSTS, type DcsContentFile, type FetchSiteVisitorSessionOptions, GlobalSeoConfig, type InstallConversionCaptureOptions, type MediaCarouselItem, type ReleaseNote, type ReleaseNotesReturn, type ReviewItem, type SiteVersionReturn, type SiteVisitor, type SiteVisitorSessionResult, type TextContentConfig, type TextContentReturn, type UseMediaCarouselConfig, type UseMediaCarouselReturn, type UseReviewContentConfig, type UseReviewContentReturn, UseSeoReturn, type UseSiteVisitorSessionOptions, type UseSiteVisitorSessionReturn, attachConversionSink, classifyHref, conversionOptedOut, createConversionTracker, createSiteSEO, doNotTrackEnabled, fetchSiteVisitorSession, hashTarget, hrefScheme, inFrame, installConversionCapture, installedConversionTracker, isConversionType, isSubmitControl, redactHref, resetConversionCapture, useConversionTracking, useMediaCarousel, useReleaseNotes, useResponsiveImage, useReviewContent, useSEO, useSiteVersion, useSiteVisitorSession, useTextContent };