@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.
- package/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +53 -1
- package/dist/editor/editorBridge.js +141 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +369 -22
- package/dist/index.js +424 -22
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +173 -6
- package/dist/plugins/index.js +628 -54
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +26 -16
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/DcsReviewShowcase.vue +5 -1
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +94 -0
- package/src/components/ManagedImage.vue +58 -6
- package/src/components/PreviewRibbon.vue +4 -1
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useResponsiveImage.ts +6 -0
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- 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, // (
|
|
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
|
-
//
|
|
154
|
+
// Re-assert the baked head for this route.
|
|
155
155
|
applyHead()
|
|
156
|
+
```
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|