@duffcloudservices/cms 0.11.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 (41) 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 +53 -1
  7. package/dist/editor/editorBridge.js +141 -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 +369 -22
  11. package/dist/index.js +424 -22
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +173 -6
  15. package/dist/plugins/index.js +628 -54
  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 +26 -16
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/DcsReviewShowcase.vue +5 -1
  24. package/src/components/LiteMediaEmbed.vue +3 -3
  25. package/src/components/ManagedImage.test.ts +94 -0
  26. package/src/components/ManagedImage.vue +58 -6
  27. package/src/components/PreviewRibbon.vue +4 -1
  28. package/src/composables/useConversionTracking.test.ts +492 -0
  29. package/src/composables/useConversionTracking.ts +770 -0
  30. package/src/composables/useReleaseNotes.ts +7 -1
  31. package/src/composables/useResponsiveImage.ts +6 -0
  32. package/src/composables/useSEO.applyHead.test.ts +150 -0
  33. package/src/composables/useSEO.ts +63 -17
  34. package/src/composables/useSiteVersion.ts +4 -1
  35. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  36. package/src/composables/useSiteVisitorSession.ts +39 -3
  37. package/src/composables/useTextContent.ts +9 -1
  38. package/dist/chunk-DAYLLSEE.js +0 -3
  39. package/dist/chunk-DAYLLSEE.js.map +0 -1
  40. package/dist/chunk-F3EIWEZD.js.map +0 -1
  41. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
package/README.md CHANGED
@@ -145,23 +145,57 @@ import { useSEO } from '@duffcloudservices/cms'
145
145
 
146
146
  const {
147
147
  config, // ComputedRef<ResolvedPageSeo>
148
- applyHead, // (overrides?: HeadOverrides) => void
148
+ applyHead, // () => void <- NO ARGUMENTS. See the head-authority contract.
149
149
  getSchema, // () => object[]
150
150
  getCanonical, // () => string
151
151
  hasBuildTimeSeo // boolean
152
152
  } = useSEO('home', '/') // pageSlug, optional pagePath
153
153
 
154
- // Apply all meta tags
154
+ // Re-assert the baked head for this route.
155
155
  applyHead()
156
+ ```
156
157
 
157
- // Or with overrides
158
- applyHead({
159
- title: 'Custom Title',
160
- description: 'Custom description',
161
- schemas: [...getSchema(), customSchema]
162
- })
158
+ #### The head-authority contract (C-356)
159
+
160
+ `.dcs/seo.yaml` is the **only** writer of `<title>`, description, keywords,
161
+ robots, canonical, `og:*`, `twitter:*` and the JSON-LD graph. `applyHead()`
162
+ RE-ASSERTS that head at runtime; it never authors one, so it takes no arguments.
163
+ Need a different value? Change it in `.dcs/seo.yaml` (or the portal SEO editor,
164
+ which writes it).
165
+
166
+ A hardcoded value that currently *matches* the baked one is still a violation —
167
+ the contract grades authority, not values. Full text, the measured evidence and
168
+ the rejected alternatives: **`.docs/plans/dynamic-site-resolution/README.md`
169
+ § "The head-authority contract (C-356)"**.
170
+
171
+ Enforced here, not per-site: the `() => void` type (fails `type-check`),
172
+ `auditHeadContract()` in `src/seo/headContract.ts` (fails the audit), and a
173
+ runtime refusal that drops the override and keeps `seo.yaml` authoritative.
174
+ Audit a real site with:
175
+
176
+ ```bash
177
+ pnpm --filter @duffcloudservices/cms build
178
+ node cli/head-contract-audit.mjs --all --json out.json
163
179
  ```
164
180
 
181
+ **Exemptions carry live claims (C-420).** An entry in a site's
182
+ `.dcs/head-contract.json` whose reason NAMES a host must also declare what that
183
+ host does, and the audit fetches it (redirects read, never followed):
184
+
185
+ ```jsonc
186
+ "site/src/views/HomeView.vue": {
187
+ "reason": "…why the seo.yaml factory cannot own this head…",
188
+ "hosts": { "example.com": { "status": 200, "robots": "noindex",
189
+ "canonicalHost": "other.example" } }
190
+ }
191
+ ```
192
+
193
+ A named host with no claim, a claim with no observation, and a claim the live
194
+ host contradicts all FAIL — `--no-probe` silences none of them. The measured
195
+ case: an exemption asserted a host was a noindex cross-domain handoff while that
196
+ host served `index, follow` and a self-canonical from a different SWA, and the
197
+ audit was green *because of* the exemption.
198
+
165
199
  ### useReleaseNotes
166
200
 
167
201
  Fetches release notes from the DCS Portal API.
@@ -191,6 +225,81 @@ const {
191
225
  } = useSiteVersion()
192
226
  ```
193
227
 
228
+ ### Conversion capture (automatic — no site code)
229
+
230
+ Measures the money-moment. **Importing this package installs it.** One delegated,
231
+ capture-phase listener on `document` classifies every link and form submit on the site and
232
+ emits a `site_interaction` event.
233
+
234
+ There is nothing to mount. That is the point: this shipped as an opt-in
235
+ (`useConversionTracking`) and was adopted by *zero* of eleven fleet sites, so on 2026-07-26
236
+ two independent reviews found the same hole — Bryan's Handyman's five `tel:` CTAs, the only
237
+ conversion action the site has, emitting nothing at all. A capability a site must remember
238
+ to switch on is a capability that is off.
239
+
240
+ **Event schema**
241
+
242
+ | Property | Value |
243
+ |---|---|
244
+ | `interaction_type` | `booking` · `phone` · `email` · `form_submit` · `social` · `external` · `internal` · `button` |
245
+ | `is_conversion` | `"true"` for the first four. One predicate for owner reports. |
246
+ | `label` | Visible/aria label of the clicked control, truncated to 120 chars |
247
+ | `href` | Destination, query + fragment stripped. **Contact schemes are redacted** to `tel:#<digest>` |
248
+ | `href_scheme` | `tel:` · `sms:` · `mailto:` · `https:` · … |
249
+ | `href_host` | Destination host (`''` for contact schemes and buttons) |
250
+ | `href_hash` | Digest of the contact target — lets a report rank CTAs without storing a number |
251
+ | `page_path`, `host` | Where the interaction happened |
252
+ | `capture_version` | `2` |
253
+
254
+ No raw phone number or email address is ever emitted. The digest is a normalising
255
+ convenience, **not** anonymisation — a site publishes two or three numbers, so treat it as
256
+ "the contact string never reaches the analytics store", nothing stronger.
257
+
258
+ **Where the events go.** Two independent transports, and it will use whichever exist:
259
+
260
+ 1. **GA4** — picked up automatically from `window.gtag`, which the deploy workflow injects
261
+ whenever `.dcs/site.yaml` has a `google_analytics_id`. A site with GA4 configured
262
+ measures conversions with **no code change whatsoever**.
263
+ 2. **App Insights** — attaches through a global, so no package depends on a telemetry SDK:
264
+
265
+ ```typescript
266
+ // After your App Insights instance is live. One line, no import.
267
+ window.__dcsConversionAttach?.((e) => appInsights.trackEvent(e))
268
+ ```
269
+
270
+ `@duffcloudservices/telemetry` already does this inside `initialize()`. Clicks captured
271
+ before a transport exists are buffered (bounded FIFO) and flushed on attach — deferring or
272
+ lazy-loading the App Insights SDK must never silently zero a customer's booking numbers.
273
+
274
+ **Per-site configuration** — call the installer yourself at app entry; the first call wins:
275
+
276
+ ```typescript
277
+ import { installConversionCapture } from '@duffcloudservices/cms'
278
+
279
+ installConversionCapture({
280
+ bookingHosts: ['stridethera.com', 'momence.com'], // on top of the fleet defaults
281
+ bookingPaths: ['/book'], // self-hosted booking routes
282
+ })
283
+ ```
284
+
285
+ **It deliberately does nothing** when there is no `document` (SSR/prerender), inside an
286
+ iframe (the visual editor and preview surfaces — an editor's clicks are not leads), under
287
+ Do Not Track, or when the page sets `window.__dcsConversionOptOut = true` /
288
+ `<html data-dcs-analytics="off">`.
289
+
290
+ **Double counting is guarded, not hoped for.** Only one delegated listener may bind per
291
+ document — a second `start()` refuses with a console warning rather than doubling every
292
+ conversion — and a click on a form's submit control is dropped so the `submit` event can be
293
+ the single source of truth (a click that fails validation is not a lead). This is the same
294
+ discipline as `trackPageView`'s auto-route refusal: C-288 measured 52% duplicate page views
295
+ on a live site from exactly this class of mistake, and the portal reported them to the owner.
296
+
297
+ > Do **not** add `"sideEffects": false` to this package. It would let a consumer's bundler
298
+ > drop the auto-install and silently restore the blind spot.
299
+
300
+ `useConversionTracking` / `createConversionTracker` remain exported for non-cms consumers
301
+ and for tests.
302
+
194
303
  ## Vite Plugins
195
304
 
196
305
  ### dcsContentPlugin
@@ -224,6 +333,25 @@ dcsSeoPlugin({
224
333
  })
225
334
  ```
226
335
 
336
+ **`noindex` is a predicate over `pages.yaml`, not a standalone directive.**
337
+ Every consumer asks it route-first (`noindexSet.has(route.path) || …has(route.slug)`),
338
+ so an entry naming a page that is not in `pages.yaml` emits **no document at
339
+ all** — the directive silently does nothing and the URL stays `index, follow`.
340
+ Since **C-416** that is a hard build failure (`NoindexOrphanError`, thrown from
341
+ `config()` before a byte is emitted):
342
+
343
+ ```
344
+ [dcs-seo] noindex entries matching NO page in the route manifest: "/homepage-clone-1", "account".
345
+ … Fix it by adding the page to pages.yaml …, or by deleting the stale entry.
346
+ Matchable keys are the manifest's paths and slugs: "/", "/services", "home", "services"
347
+ ```
348
+
349
+ Notes: matching is raw, so `'/services/'` does **not** match a `'/services'`
350
+ route (the emitter would not match it either, so blessing it would certify a
351
+ no-op); matching runs against the **full** manifest, so an entry that is also in
352
+ `exclude` is fine. There is no severity knob — the fix is always "add the page"
353
+ or "delete the row".
354
+
227
355
  #### Body prerender (crawler-visible body content)
228
356
 
229
357
  When `emitStaticHtml` is on, the plugin also **prerenders each indexable
@@ -248,6 +376,114 @@ unaffected while crawlers get the body.
248
376
  prerender for one site, or pass `dcsSeoPlugin({ prerenderBody: false })`.
249
377
  Preview builds skip it automatically.
250
378
 
379
+ #### SEO emission honesty rails (C-334)
380
+
381
+ Three build-time asserts against one failure class: **a platform capability that
382
+ exists, is silently undone downstream, and has nothing watching.** All three are
383
+ **ON by default at severity `error`** whenever `emitStaticHtml` is on, so a cms
384
+ bump arms them fleet-wide with no per-site edit.
385
+
386
+ | Rail | Asserts | Kill-tested against |
387
+ |------|---------|---------------------|
388
+ | **P1 head honesty** | For every emitted route, the **baked** `<title>`/description equals the **rendered** ones after the app mounts | Live Iron Oak, 2026-07-27: baked `Handyman, Carpentry & Home Repairs in SE Michigan \| Iron Oak` vs rendered `Our Services` — 18/18 routes divergent |
389
+ | **P2 emitted-URL honesty** | Every URL the factory publishes (JSON-LD `logo`/`image`, `og:image`, icon links, sitemap `<loc>`s, llms.txt links) resolves to what it promises | Live Iron Oak, 2026-07-27: `Organization.logo` → `200 text/html`, byte-identical to the homepage |
390
+ | **P12 charset budget** | `<meta charset>` lands inside the spec's first-1024-byte encoding-sniffing window — asserted straight out of `dcsContentPlugin` (C-414), with the `emitStaticHtml` hoist as a backstop | Live Iron Oak **and** Bryan's, 2026-07-27: charset at byte 910, 90 bytes of headroom past the declaration, no header-level `charset` backstop |
391
+
392
+ Why they behave the way they do:
393
+
394
+ - **P1 rides the prerender browser** — the page is already being loaded, so the
395
+ marginal cost is one `page.evaluate()` per route. If a site has opted out of
396
+ body prerender the render still happens in **observe-only** mode (nothing is
397
+ written to `dist/`), because a gate that quietly does not exist on some sites
398
+ is worse than no gate.
399
+ A clean site passes *structurally*: `applyHead()` produces byte-identical tags
400
+ to the build-time emit. **Since C-356 an override is impossible** — the
401
+ parameter is gone from the signature and a runtime argument is dropped — which
402
+ is what makes "structurally clean" the default rather than an achievement.
403
+ (Historically `applyHead({ title })` used the override *verbatim*, skipping
404
+ `global.titleTemplate`, so an override that "looked right" still diverged; that
405
+ was the root cause of 93 divergences on KEPT and 18 more across three sites.)
406
+ P1 remains the rail that judges the values; the source-side
407
+ `auditHeadContract()` judges who is allowed to write them.
408
+ - **P2 splits by how certain the answer is.** Same-origin assets are proven
409
+ against `dist/` with **no network at all**: an emitted `https://site/logos/x.svg`
410
+ with no `dist/logos/x.svg` behind it means the host's SPA fallback *will*
411
+ answer HTML, so it is a hard failure. Cross-origin assets are probed under a
412
+ 24h cache, a 5s per-request timeout and a 20s total budget, where a
413
+ **definitive** wrong answer (HTML where an image was promised, or a 4xx/5xx)
414
+ fails the build and a **non-answer** (DNS, timeout, reset, budget exhausted)
415
+ is only a warning. A gate that reds on a network hiccup gets disabled within a
416
+ week and then protects nothing.
417
+ - **P12 fixes as well as asserts.** The motion-token `<style>` *was* injected
418
+ `head-prepend`, above the site's own `<meta charset>`; **C-414 fixed that at
419
+ the source** — it now injects `head` (append), which keeps it above the
420
+ bundled site CSS (so a per-site `:root` override still wins) while leaving the
421
+ encoding declaration first in `<head>`. The `emitStaticHtml` path still hoists
422
+ the declaration in the emitted shell (idempotent, moves nothing else, now a
423
+ no-op on a stock shell) and then asserts the result — that hoist is the
424
+ backstop for hand-rolled shells, and it never ran on VitePress sites, which is
425
+ why the source fix was the one that reached the whole fleet. **Also fix the
426
+ header** where the site's config lives — SWA currently answers
427
+ `Content-Type: text/html` with no `charset` parameter, so there is no
428
+ header-level backstop.
429
+
430
+ Escape hatches — deliberately impossible to use silently:
431
+
432
+ ```yaml
433
+ # .dcs/seo.yaml (portal-owned, in git, reviewable)
434
+ headHonesty:
435
+ mode: warn # error (default) | warn | off
436
+ allow: ['/legacy-route'] # every exemption is printed on every build
437
+ checkDescription: true
438
+ urlHonesty:
439
+ mode: error
440
+ allow: ['https://cdn.example.com/known-missing.svg']
441
+ network: true # false ⇒ same-origin still enforced
442
+ charsetBudget: error
443
+ ```
444
+
445
+ ```bash
446
+ # Per-build override — prints in the build log, so it is never invisible
447
+ DCS_SEO_HEAD_HONESTY=warn DCS_SEO_URL_HONESTY=warn pnpm build
448
+ DCS_SEO_URL_HONESTY_NETWORK=off pnpm build # offline CI
449
+ ```
450
+
451
+ Verification (needs a browser; not part of `pnpm test`):
452
+
453
+ ```bash
454
+ pnpm --filter @duffcloudservices/cms run verify:honesty-rails
455
+ # and, to see what a LIVE site's build will red on before bumping cms:
456
+ pnpm --filter @duffcloudservices/cms run probe:live-urls https://example.com
457
+ ```
458
+
459
+ #### The served-document pass (C-417)
460
+
461
+ All three rails above assert against the **candidate build** — the `dist/` on the
462
+ machine that ran the build. Nothing re-asserted them against the document a
463
+ browser receives, and that gap is how C-341's `charset hoisted 913 -> 48` claim
464
+ survived: true of a local build, false in production, where the served document
465
+ has `<meta charset>` at byte 910 (measured 2026-07-31, KEPT re-review F3). The
466
+ KEPT re-review estimated a post-deploy re-run would have caught it in 8 minutes.
467
+
468
+ Run the same rail primitives over the bytes the origin serves — **after every
469
+ deploy**, and always before citing a rail's number as evidence:
470
+
471
+ ```bash
472
+ # P2 + P12 on the served document (default)
473
+ pnpm --filter @duffcloudservices/cms run verify:served-honesty https://example.com --routes all
474
+
475
+ # ...and hold the CANDIDATE BUILD's recorded numbers against what shipped.
476
+ # `dist-honesty-report.json` is what DCS_SEO_HONESTY_REPORT wrote during the build.
477
+ DCS_SEO_HONESTY_REPORT=dist-honesty-report.json pnpm build
478
+ pnpm --filter @duffcloudservices/cms run verify:served-honesty https://example.com \
479
+ --routes all --rails p1,p2,p12 --rendered --compare dist-honesty-report.json
480
+ ```
481
+
482
+ Exit codes: `0` pass · `1` a rail FAILED or the recorded evidence does not
483
+ describe the served document · `3` a requested rail DID NOT RUN. A rail that
484
+ could not run is never folded into a pass — the C-338 execution assertion,
485
+ applied to the served side.
486
+
251
487
  ## Configuration Files
252
488
 
253
489
  ### .dcs/content.yaml