@duffcloudservices/cms 0.12.0 → 0.13.1

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 (40) 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 +25 -6
  27. package/src/components/raw-source-imports.test.ts +44 -0
  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/useSEO.applyHead.test.ts +150 -0
  32. package/src/composables/useSEO.ts +63 -17
  33. package/src/composables/useSiteVersion.ts +4 -1
  34. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  35. package/src/composables/useSiteVisitorSession.ts +39 -3
  36. package/src/composables/useTextContent.ts +9 -1
  37. package/dist/chunk-DAYLLSEE.js +0 -3
  38. package/dist/chunk-DAYLLSEE.js.map +0 -1
  39. package/dist/chunk-F3EIWEZD.js.map +0 -1
  40. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
package/dist/seo/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import '../chunk-DAYLLSEE.js';
2
- export { AI_BOTS, BodyPrerenderError, absolutizeUrl, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHasCredential, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSitemapXml, buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, filterRealFaq, filterRealReviews, findBusinessLicense, findLocalBusinessSchema, findReviewItemsForPage, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, isBodyPrerenderEnabled, isLocalBusinessType, isRouteIndexable, loadPagesManifest, parsePagesManifest, prerenderBodies, renderHeadTags, resolvePageSeo, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags } from '../chunk-F3EIWEZD.js';
1
+ export { HeadContractError, MIN_EXEMPTION_REASON_LENGTH, assertHeadContract, auditExemptionHosts, auditHeadContract, blankComments, formatExemptionAudit, formatHeadContractReport, hostsNamedIn, normalizeExemption, parseHostProbeHtml } from '../chunk-A5F4C72F.js';
2
+ export { AI_BOTS, BodyPrerenderError, CANONICAL_CHARSET_META, CHARSET_BUDGET_BYTES, CHARSET_HEADROOM_WARN_BYTES, CharsetBudgetError, EmittedUrlError, EmittedUrlNotRunError, HeadHonestyError, HeadHonestyNotRunError, SEO_HEAD_ROUTE_HAS_NO_META, absolutizeUrl, assertHeadHonesty, assertHeadHonestyExecuted, auditCheckedTotal, auditSameOriginAssets, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHasCredential, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSeoHeadRouteMap, buildSitemapXml, buildVitePressSeoHead, checkCharsetBudget, checkRemoteAssets, checkSameOriginAssets, classifyUrl, collectUrlsFromHtml, collectUrlsFromLlmsTxt, collectUrlsFromSitemap, createSeoTransformPageData, decodeHtmlEntities, dedupeUrls, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, extractBakedHead, filterRealFaq, filterRealReviews, findBusinessLicense, findCharsetByteOffset, findDuplicateNormalizedPaths, findHeadHonestyExecutionFaults, findHeadHonestyViolations, findLocalBusinessSchema, findReviewItemsForPage, formatCharsetHeadroomWarning, formatDuplicateNormalizedPaths, formatEmittedUrlAudit, formatEmittedUrlReport, formatHeadHonestyExecutionReport, formatHeadHonestyReport, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, hoistCharsetMeta, installSeoHead, isBodyPrerenderEnabled, isLocalBusinessType, isRouteEmitted, isRouteIndexable, loadPagesManifest, normalizeHeadText, normalizeSeoHeadPath, parsePagesManifest, prerenderBodies, renderHeadTags, resolveHonestyMode, resolvePageSeo, routeExclusionReason, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags } from '../chunk-HVSF23P7.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -26,6 +26,42 @@ interface SeoConfiguration {
26
26
  * JSON-LD emission is unaffected either way.
27
27
  */
28
28
  prerenderBody?: boolean;
29
+ /**
30
+ * Per-site escape hatch for the P1 baked-vs-rendered `<head>` assert (C-334).
31
+ * `false`/`'off'` disables it, `'warn'` reports without gating, `'error'`
32
+ * (default) fails the build. Lives here so the decision is portal-owned, in
33
+ * git, and reviewable — there is no silent way to switch the gate off.
34
+ */
35
+ headHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
36
+ /**
37
+ * Per-site escape hatch for the P2 emitted-URL resolution assert (C-334).
38
+ * Same shape as {@link headHonesty}; `allow` lists URLs that are knowingly
39
+ * unresolvable and are still logged on every build.
40
+ */
41
+ urlHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
42
+ /**
43
+ * Per-site escape hatch for the P12 `<meta charset>` head-budget assert
44
+ * (C-334). Set `'warn'` to report a document whose encoding declaration falls
45
+ * outside the 1024-byte sniffing window without failing the build.
46
+ */
47
+ charsetBudget?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
48
+ }
49
+ /**
50
+ * The expanded form of a `.dcs/seo.yaml` honesty-rail escape hatch — a severity
51
+ * plus an explicit, logged allow-list.
52
+ */
53
+ interface SeoHonestyBlock {
54
+ /** `error` (default) | `warn` | `off`. */
55
+ mode?: 'error' | 'warn' | 'off';
56
+ /**
57
+ * Routes (P1) or URLs/paths (P2) knowingly exempted. Every entry is printed on
58
+ * every build, so an exemption can never quietly become permanent.
59
+ */
60
+ allow?: string[];
61
+ /** P1 only: also compare the meta description (default `true`). */
62
+ checkDescription?: boolean;
63
+ /** P2 only: set `false` to skip the cross-origin network probes entirely. */
64
+ network?: boolean;
29
65
  }
30
66
  /**
31
67
  * Global/site-wide SEO configuration
@@ -243,8 +279,16 @@ interface UseSeoConfig {
243
279
  interface UseSeoReturn {
244
280
  /** Computed page SEO configuration */
245
281
  config: vue.ComputedRef<ResolvedPageSeo>;
246
- /** Apply all meta tags via useHead */
247
- applyHead: (overrides?: HeadOverrides) => void;
282
+ /**
283
+ * RE-ASSERT the baked head for this route via useHead. Takes NO ARGUMENTS.
284
+ *
285
+ * `.dcs/seo.yaml` is the only writer of the managed head fields
286
+ * (head-authority contract, C-356 — `.docs/plans/dynamic-site-resolution/README.md`).
287
+ * The parameter was removed on purpose: passing one is a compile error, and a
288
+ * caller that reaches the runtime with one anyway has it DROPPED plus a
289
+ * console error. Change the value in `.dcs/seo.yaml`, not here.
290
+ */
291
+ applyHead: () => void;
248
292
  /** Get JSON-LD schema objects for the page */
249
293
  getSchema: () => object[];
250
294
  /** Get canonical URL for the page */
@@ -253,7 +297,14 @@ interface UseSeoReturn {
253
297
  hasBuildTimeSeo: boolean;
254
298
  }
255
299
  /**
256
- * Overrides that can be passed to applyHead
300
+ * @deprecated RETIRED BY THE HEAD-AUTHORITY CONTRACT (C-356). `applyHead()`
301
+ * accepts no overrides: `.dcs/seo.yaml` is the only writer of the managed head
302
+ * fields. The interface is retained only so an external `import type` does not
303
+ * break on the bump; nothing in the package consumes it. It will be deleted in
304
+ * the next cms MAJOR alongside the cms-core phantom-caller deletion (Q-297=A).
305
+ *
306
+ * If you were reaching for this, the answer is: put the value in
307
+ * `.dcs/seo.yaml` (or the portal SEO editor, which writes it).
257
308
  */
258
309
  interface HeadOverrides {
259
310
  /** Override title */
@@ -614,6 +665,39 @@ declare function loadPagesManifest(projectRoot: string, relativePagesPath: strin
614
665
  * empty string (so the route still gets baked, using global SEO defaults).
615
666
  */
616
667
  declare function parsePagesManifest(raw: unknown): PageRouteEntry[] | null;
668
+ /**
669
+ * The two exclusion inputs the per-route HTML emitter honours.
670
+ *
671
+ * `exclude` is a plugin OPTION (paths or slugs); `excludedGlobs` is the
672
+ * `pages.yaml` top-level `excluded:` list. Neither is a file the runtime can
673
+ * read, which is exactly why the RUNTIME MANIFEST has to be filtered before it
674
+ * is injected rather than filtered again at lookup time.
675
+ */
676
+ interface RouteEmissionRules {
677
+ /** `dcsSeoPlugin({ exclude })` — exact route paths OR slugs. */
678
+ exclude?: readonly string[];
679
+ /** `pages.yaml` top-level `excluded:` globs, e.g. `/dev-*`, `/_*`. */
680
+ excludedGlobs?: readonly string[];
681
+ }
682
+ /** Why a manifest route is NOT emitted, or `null` when it is. */
683
+ type RouteExclusionReason = 'excluded' | 'excluded-glob';
684
+ /**
685
+ * THE SINGLE EMISSION PREDICATE. Both the per-route HTML emitter and the
686
+ * `__DCS_PAGES__` runtime manifest resolve "is this route emitted?" through
687
+ * THIS function.
688
+ *
689
+ * WHY IT EXISTS (C-361 F5, verified). The emitter skipped `exclude` entries and
690
+ * `excluded:` globs, while `__DCS_PAGES__` was built from the raw manifest and
691
+ * carried EVERY loaded route. A private `/dev-*` route was therefore never baked
692
+ * — correct — but `installSeoHead` would still runtime-assert a canonical and
693
+ * `index, follow` over it on the first in-app navigation. The manifest has to
694
+ * model what the emitter WRITES, not what `pages.yaml` DECLARES, and the only
695
+ * way that survives a future edit is for there to be one predicate rather than
696
+ * two implementations that agree today.
697
+ */
698
+ declare function routeExclusionReason(route: Pick<PageRouteEntry, 'slug' | 'path'>, rules: RouteEmissionRules): RouteExclusionReason | null;
699
+ /** `true` when the build emits a per-route `<head>` for this route. */
700
+ declare function isRouteEmitted(route: Pick<PageRouteEntry, 'slug' | 'path'>, rules: RouteEmissionRules): boolean;
617
701
 
618
702
  /**
619
703
  * Shared, PURE (fs-free) cores for the DCS site-file emitters:
@@ -824,7 +908,12 @@ interface VitePressPageData {
824
908
  relativePath: string;
825
909
  /** Dynamic-route params (e.g. `{ topic: 'home-buying' }`). */
826
910
  params?: Record<string, unknown>;
827
- /** Page frontmatter; `head` is appended to here. */
911
+ /**
912
+ * Page frontmatter; `head` is appended to here.
913
+ *
914
+ * Deliberately `any`: site `seo.config.ts` rules dot-walk arbitrary frontmatter,
915
+ * so `unknown` would be a breaking API change for every consuming site.
916
+ */
828
917
  frontmatter: Record<string, any>;
829
918
  /** VitePress page title (drives `<title>` via `titleTemplate`). */
830
919
  title?: string;
@@ -846,7 +935,11 @@ interface SeoPageContext {
846
935
  canonical: string;
847
936
  /** Normalised site base URL (no trailing slash), e.g. `https://example.com`. */
848
937
  siteUrl: string;
849
- /** The page's frontmatter (read-only convenience; same object as pageData). */
938
+ /**
939
+ * The page's frontmatter (read-only convenience; same object as pageData).
940
+ *
941
+ * Deliberately `any` — mirrors `VitePressPageData.frontmatter` above.
942
+ */
850
943
  frontmatter: Record<string, any>;
851
944
  /** The resolved global SEO config block. */
852
945
  global: GlobalSeoConfig;
@@ -1027,4 +1120,4 @@ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: Cre
1027
1120
  */
1028
1121
  declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
1029
1122
 
1030
- export { buildHasCredential as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type PageRouteEntry as Y, loadPagesManifest as Z, parsePagesManifest as _, buildSitemapXml as a, findLocalBusinessSchema as a0, graphAbsorbs as a1, findBusinessLicense as a2, deriveSameAs as a3, graphIds as a4, isLocalBusinessType as a5, type NormalisedReview as a6, type NormalisedFaq as a7, type ReviewSchemaParts as a8, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };
1123
+ export { parsePagesManifest as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type SeoHonestyBlock as Y, type PageRouteEntry as Z, loadPagesManifest as _, buildSitemapXml as a, isRouteEmitted as a0, routeExclusionReason as a1, type RouteEmissionRules as a2, type RouteExclusionReason as a3, buildHasCredential as a4, findLocalBusinessSchema as a5, graphAbsorbs as a6, findBusinessLicense as a7, deriveSameAs as a8, graphIds as a9, isLocalBusinessType as aa, type NormalisedReview as ab, type NormalisedFaq as ac, type ReviewSchemaParts as ad, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,9 +47,9 @@
47
47
  "src/composables"
48
48
  ],
49
49
  "peerDependencies": {
50
- "vue": "^3.4.0",
51
50
  "@unhead/vue": "^1.9.0",
52
- "playwright": "^1.40.0"
51
+ "playwright": "^1.40.0",
52
+ "vue": "^3.4.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "playwright": {
@@ -58,21 +58,28 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "js-yaml": "^4.1.0",
61
- "@duffcloudservices/cms-core": "0.7.0"
61
+ "@duffcloudservices/cms-core": "0.8.1"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/js-yaml": "^4.0.9",
65
65
  "@types/markdown-it": "^14.1.0",
66
66
  "@types/node": "^20.11.0",
67
+ "@unhead/vue": "^2.0.5",
68
+ "@unhead/vue-v1": "npm:@unhead/vue@1.11.20",
67
69
  "@vitejs/plugin-vue": "^6.0.1",
70
+ "@vitest/eslint-plugin": "^1.3.4",
71
+ "@vue/eslint-config-typescript": "^14.6.0",
68
72
  "@vue/test-utils": "^2.4.0",
73
+ "eslint": "^9.31.0",
74
+ "eslint-plugin-vue": "~10.3.0",
75
+ "jiti": "^2.4.2",
69
76
  "markdown-it": "^14.0.0",
70
77
  "tsup": "^8.0.0",
71
78
  "typescript": "~5.6.3",
72
79
  "vite": "^6.3.5",
73
80
  "vitest": "^3.2.3",
74
81
  "vue": "^3.5.16",
75
- "@unhead/vue": "^2.0.5"
82
+ "vue-router": "^4.5.1"
76
83
  },
77
84
  "keywords": [
78
85
  "vue",
@@ -103,7 +110,11 @@
103
110
  "dev": "tsup --watch",
104
111
  "test": "vitest run",
105
112
  "test:watch": "vitest",
113
+ "verify:honesty-rails": "node scripts/verify-honesty-rails.mjs",
114
+ "probe:live-urls": "node scripts/probe-live-emitted-urls.mjs",
115
+ "verify:served-honesty": "node scripts/probe-live-emitted-urls.mjs",
106
116
  "type-check": "tsc --noEmit",
107
- "lint": "eslint src --ext .ts"
117
+ "lint": "eslint .",
118
+ "lint:fix": "eslint . --fix"
108
119
  }
109
120
  }
@@ -1,7 +1,25 @@
1
1
  import { describe, it, expect, afterEach, vi } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
3
5
  import DcsCallButton from './DcsCallButton.vue'
4
6
 
7
+ /**
8
+ * Raw SFC source, used by the touch-target tests below.
9
+ *
10
+ * SCOPE LIMIT (stated deliberately): `mount()` does not apply an SFC's scoped
11
+ * `<style>` — jsdom never lays the component out — so these assertions prove
12
+ * the 44 px rule is DECLARED, not that a browser renders 44 px. They exist to
13
+ * stop the floor being silently deleted again; the rendered-height proof is a
14
+ * live `getBoundingClientRect()` measurement against a deployed site.
15
+ */
16
+ const sfcSource = readFileSync(
17
+ // vitest runs with cwd = the package root; `import.meta.url` is not a file:
18
+ // URL under the jsdom environment, so resolve from cwd instead.
19
+ resolve(process.cwd(), 'src/components/DcsCallButton.vue'),
20
+ 'utf8'
21
+ )
22
+
5
23
  /**
6
24
  * `useTextContent` reads the build-time `__DCS_CONTENT__` global synchronously
7
25
  * at setup. Stub it to simulate `.dcs/content.yaml` `global` overrides; pass
@@ -123,4 +141,44 @@ describe('DcsCallButton', () => {
123
141
  'Call Explicit Name at (248) 385-2926'
124
142
  )
125
143
  })
144
+
145
+ // -------------------------------------------------------------------------
146
+ // C-321 — WCAG 2.5.5 / 2.5.8 minimum touch target on BOTH variants.
147
+ //
148
+ // Regression guard: the floor used to live only on `--icon`, which left the
149
+ // DEFAULT `inline` variant (hero / nav tap-to-call — the primary mobile
150
+ // conversion on a service-business site) at its line-box height.
151
+ // -------------------------------------------------------------------------
152
+ describe('touch target', () => {
153
+ it('declares the 44px floor on the BASE class, so it applies to every variant', () => {
154
+ const base = sfcSource.match(/\.dcs-call-button\s*\{[^}]*\}/)
155
+ expect(base, 'base .dcs-call-button rule must exist').not.toBeNull()
156
+ expect(base![0]).toMatch(/min-height:\s*var\(--dcs-call-min-target,\s*2\.75rem\)/)
157
+ })
158
+
159
+ it('keeps the icon variant square by pinning min-width to the same token', () => {
160
+ const icon = sfcSource.match(/\.dcs-call-button--icon\s*\{[^}]*\}/)
161
+ expect(icon, '.dcs-call-button--icon rule must exist').not.toBeNull()
162
+ expect(icon![0]).toMatch(/min-width:\s*var\(--dcs-call-min-target,\s*2\.75rem\)/)
163
+ })
164
+
165
+ it('gives the inline variant real hit area on its long axis too', () => {
166
+ const inline = sfcSource.match(/\.dcs-call-button--inline\s*\{[^}]*\}/)
167
+ expect(inline, '.dcs-call-button--inline rule must exist').not.toBeNull()
168
+ // Padding must not be the old flat `0` — that is what produced a 24px tall
169
+ // target on the flagship real-estate site.
170
+ expect(inline![0]).not.toMatch(/padding:\s*var\(--dcs-call-inline-padding,\s*0\)/)
171
+ expect(inline![0]).toMatch(/padding:/)
172
+ })
173
+
174
+ it('never lets a variant override the floor below 44px', () => {
175
+ // Any `min-height` declaration in the stylesheet must resolve to >= 2.75rem.
176
+ const declared = [...sfcSource.matchAll(/min-height:\s*([^;]+);/g)].map((m) => m[1]!.trim())
177
+ expect(declared.length).toBeGreaterThan(0)
178
+ for (const value of declared) {
179
+ const rem = Number(value.match(/(\d+(?:\.\d+)?)rem/)?.[1] ?? '0')
180
+ expect(rem, `min-height "${value}" must be >= 2.75rem`).toBeGreaterThanOrEqual(2.75)
181
+ }
182
+ })
183
+ })
126
184
  })
@@ -134,6 +134,16 @@ const ariaLabel = computed(() =>
134
134
  .dcs-call-button {
135
135
  display: inline-flex;
136
136
  align-items: center;
137
+ /*
138
+ * WCAG 2.5.5 / 2.5.8 minimum touch target, on the BASE class so it applies to
139
+ * EVERY variant. C-321: the floor used to live only on `--icon`, which left
140
+ * the DEFAULT `inline` variant — the hero / nav tap-to-call, i.e. the primary
141
+ * mobile conversion action on a service-business site — at its line-box
142
+ * height. Measured live on the real-estate flagship, the equivalent
143
+ * hand-rolled markup came out 147x24 and 173x28 against the 44px floor.
144
+ * `min-height` (not `height`) keeps the label free to wrap.
145
+ */
146
+ min-height: var(--dcs-call-min-target, 2.75rem);
137
147
  gap: var(--dcs-call-gap, 0.5rem);
138
148
  color: var(--dcs-call-color, currentColor);
139
149
  text-decoration: none;
@@ -154,16 +164,21 @@ const ariaLabel = computed(() =>
154
164
  outline-offset: 2px;
155
165
  }
156
166
 
157
- /* Icon-only: a WCAG 2.5.5 minimum 44×44 touch target for reliable tapping. */
167
+ /* Icon-only: square it off at the same floor so the target is 44x44, not 44xN. */
158
168
  .dcs-call-button--icon {
159
169
  justify-content: center;
160
- min-width: 2.75rem;
161
- min-height: 2.75rem;
170
+ min-width: var(--dcs-call-min-target, 2.75rem);
162
171
  padding: var(--dcs-call-icon-padding, 0.5rem);
163
172
  }
164
173
 
174
+ /*
175
+ * Horizontal padding gives the inline variant real hit area on its long axis
176
+ * too; the negative inline margin keeps the button optically flush with
177
+ * adjacent text so adding the target does not visibly indent existing layouts.
178
+ */
165
179
  .dcs-call-button--inline {
166
- padding: var(--dcs-call-inline-padding, 0);
180
+ padding: var(--dcs-call-inline-padding, 0.25rem 0.5rem);
181
+ margin-inline: var(--dcs-call-inline-margin, -0.5rem);
167
182
  }
168
183
 
169
184
  .dcs-call-button__icon {
@@ -77,13 +77,13 @@ const isEmbed = computed(() => isEmbedType.value && embedSrc.value !== '')
77
77
  */
78
78
  function extractYouTubeId(url: string): string {
79
79
  const embedMatch = url.match(/\/embed\/([\w-]{11})/)
80
- if (embedMatch) return embedMatch[1]
80
+ if (embedMatch) return embedMatch[1] ?? ''
81
81
 
82
82
  const watchMatch = url.match(/[?&]v=([\w-]{11})/)
83
- if (watchMatch) return watchMatch[1]
83
+ if (watchMatch) return watchMatch[1] ?? ''
84
84
 
85
85
  const shortMatch = url.match(/youtu\.be\/([\w-]{11})/)
86
- if (shortMatch) return shortMatch[1]
86
+ if (shortMatch) return shortMatch[1] ?? ''
87
87
 
88
88
  if (/^[\w-]{11}$/.test(url)) return url
89
89
  return ''
@@ -131,5 +131,39 @@ describe('ManagedImage layout-shift (CLS) dimension hints', () => {
131
131
  // The VE stages replacements against the `<base>.src` key.
132
132
  expect(img.attributes('data-dcs-image-key')).toBe('hero.image.src')
133
133
  expect(img.attributes('data-dcs-image-url')).toBe(ORIGINAL)
134
+ // … and alt changes against the `<base>.alt` key (C-207 item 7).
135
+ expect(img.attributes('data-dcs-image-alt-key')).toBe('hero.image.alt')
136
+ })
137
+ })
138
+
139
+ describe('ManagedImage managed alt key exposure (data-dcs-image-alt-key)', () => {
140
+ it('exposes the legacy altKey so the VE can stage a persistable alt change', () => {
141
+ const wrapper = mount(ManagedImage, {
142
+ props: {
143
+ pageSlug: 'home',
144
+ imageKey: 'hero.image',
145
+ altKey: 'hero.imageAlt',
146
+ fallbackSrc: ORIGINAL,
147
+ fallbackAlt: 'Hero',
148
+ context: 'hero',
149
+ },
150
+ })
151
+
152
+ expect(wrapper.find('img').attributes('data-dcs-image-alt-key')).toBe('hero.imageAlt')
153
+ })
154
+
155
+ it('omits the attribute when the image has no managed alt key', () => {
156
+ const wrapper = mount(ManagedImage, {
157
+ props: {
158
+ pageSlug: 'home',
159
+ imageKey: 'hero.image',
160
+ fallbackSrc: ORIGINAL,
161
+ fallbackAlt: 'Hero',
162
+ context: 'hero',
163
+ },
164
+ })
165
+
166
+ // No alt key ⇒ the VE must not preview an alt change it cannot persist.
167
+ expect(wrapper.find('img').attributes('data-dcs-image-alt-key')).toBeUndefined()
134
168
  })
135
169
  })
@@ -111,6 +111,11 @@ const imgAttrs = computed(() => ({
111
111
  'data-dcs-image-key': srcKey.value,
112
112
  'data-dcs-image-url': imageSrc.value,
113
113
  'data-dcs-image-alt': imageAlt.value,
114
+ // The ALT key (when this image has one) so the VE can stage an alt change
115
+ // that Save actually persists — `<base>.alt` (D1-A) or the legacy altKey.
116
+ // Omitted when no managed alt key exists, so the VE knows NOT to preview
117
+ // an alt change it cannot persist.
118
+ ...(effectiveAltKey.value ? { 'data-dcs-image-alt-key': effectiveAltKey.value } : {}),
114
119
  }))
115
120
  </script>
116
121
 
@@ -86,6 +86,16 @@
86
86
  <script setup lang="ts">
87
87
  import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
88
88
 
89
+ /*
90
+ * NO cms-core VALUE imports in this file. The `./components` export ships RAW SFC
91
+ * source (tsup builds no .vue), so consumer sites type-check this file directly —
92
+ * and cms-core is a transitive dep pnpm's strict layout never exposes to them.
93
+ * A value import here is TS2307 in every fleet site's type-check gate (measured on
94
+ * the 0.13.0 canary, 2026-08-10; kept/boogie-babies/bryans/mi-handyman all red).
95
+ * `import type` is erased and safe. The C-298 layer-2 guard this file needs from
96
+ * platformFetch is inlined below at its single call site instead.
97
+ */
98
+
89
99
  /* ------------------------------------------------------------------ */
90
100
  /* Props */
91
101
  /* ------------------------------------------------------------------ */
@@ -234,13 +244,22 @@ onMounted(async () => {
234
244
  const base =
235
245
  (import.meta.env as Record<string, string | undefined>)
236
246
  .VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
237
- const res = await fetch(
238
- `${base}/api/v1/release-notes/latest`,
239
- { headers: { Accept: 'application/json' } },
240
- )
247
+ // C-298 layer 2, INLINED (see the no-cms-core-value-imports note at the top of
248
+ // this <script>): the `catch { /* non-critical */ }` below is a swallow site, so
249
+ // a non-JSON (HTML-shell) body must go LOUD here before it can be swallowed.
250
+ const url = `${base}/api/v1/release-notes/latest`
251
+ const res = await fetch(url, { headers: { Accept: 'application/json' } })
241
252
  if (res.ok) {
242
- const data = await res.json()
243
- if (data.version) resolvedVersion.value = data.version
253
+ const ct = res.headers?.get?.('content-type') ?? ''
254
+ if (!/\bjson\b/i.test(ct)) {
255
+ console.error(
256
+ `[dcs-cms] PreviewRibbon: ${url} returned content-type "${ct}" — ` +
257
+ 'an HTML body here means a platform misroute (C-298); refusing to parse it.',
258
+ )
259
+ } else {
260
+ const data = await res.json()
261
+ if (data.version) resolvedVersion.value = data.version
262
+ }
244
263
  }
245
264
  } catch {
246
265
  /* non-critical */
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * The `./components` export ships RAW SFC source (tsup builds no .vue), so every
7
+ * consumer site type-checks these files directly — against a dependency tree where
8
+ * cms-core is TRANSITIVE and pnpm's strict layout never exposes it. A VALUE import
9
+ * of cms-core in any raw-shipped .vue is therefore TS2307 in every fleet site's
10
+ * type-check gate, invisible to this package's own checks (cms-core resolves fine
11
+ * HERE — that is exactly why only a consumer-shaped gate or this test can see it).
12
+ *
13
+ * Measured on the 0.13.0 canary (2026-08-10): one value import in PreviewRibbon.vue
14
+ * would have redded CI on kept (the payer), boogie-babies, bryans and mi-handyman.
15
+ * `import type` is erased at compile time and stays allowed.
16
+ */
17
+ describe('raw-source .vue exports must not value-import cms-core', () => {
18
+ const dir = __dirname
19
+ const vueFiles = readdirSync(dir).filter((f) => f.endsWith('.vue'))
20
+
21
+ it('finds the raw-shipped component set (non-vacuity)', () => {
22
+ expect(vueFiles.length).toBeGreaterThan(0)
23
+ expect(vueFiles).toContain('PreviewRibbon.vue')
24
+ })
25
+
26
+ for (const file of vueFiles) {
27
+ it(`${file} has no cms-core value import`, () => {
28
+ const src = readFileSync(join(dir, file), 'utf8')
29
+ const valueImports = Array.from(src.matchAll(/^\s*import\s+(?!type\b)[^;'"]*['"]@duffcloudservices\/cms-core['"]/gm))
30
+ expect(
31
+ valueImports.map((m) => m[0].trim()),
32
+ `${file} value-imports cms-core — this ships as raw source and breaks every consumer site's type-check (see header comment)`,
33
+ ).toEqual([])
34
+ })
35
+ }
36
+
37
+ it('kill-test: the guard regex catches the 0.13.0 defect shape', () => {
38
+ const bad = "import { platformFetch } from '@duffcloudservices/cms-core'"
39
+ const good = "import type { PlatformFetchOptions } from '@duffcloudservices/cms-core'"
40
+ const re = /^\s*import\s+(?!type\b)[^;'"]*['"]@duffcloudservices\/cms-core['"]/gm
41
+ expect(Array.from(bad.matchAll(re)).length).toBe(1)
42
+ expect(Array.from(good.matchAll(re)).length).toBe(0)
43
+ })
44
+ })