@duffcloudservices/cms 0.10.0 → 0.11.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.
@@ -18,6 +18,14 @@ interface SeoConfiguration {
18
18
  global?: GlobalSeoConfig;
19
19
  /** Page-specific SEO configurations keyed by page slug */
20
20
  pages?: Record<string, PageSeoConfig>;
21
+ /**
22
+ * Per-site escape hatch for build-time BODY prerender (default ON when the
23
+ * static-HTML emitter is enabled). Set `false` to disable body prerender for
24
+ * this one site with NO code change and NO cms republish — the portal owns
25
+ * `.dcs/seo.yaml`, so flipping this and rebuilding is enough. Head meta +
26
+ * JSON-LD emission is unaffected either way.
27
+ */
28
+ prerenderBody?: boolean;
21
29
  }
22
30
  /**
23
31
  * Global/site-wide SEO configuration
@@ -403,6 +411,26 @@ interface ReviewSchemaParts {
403
411
  * real items, BOTH are omitted — never an invented rating or count.
404
412
  */
405
413
  declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): ReviewSchemaParts;
414
+ /**
415
+ * Build a single `EducationalOccupationalCredential` node for a trade license
416
+ * from the free-text `business.license` content key, or `undefined` when the key
417
+ * is empty / whitespace / absent.
418
+ *
419
+ * Shape (schema.org-correct; mirrors the portal's `Credential` editor type in
420
+ * `portal/src/lib/jsonld-schema-types.ts`):
421
+ *
422
+ * { '@type': 'EducationalOccupationalCredential',
423
+ * credentialCategory: 'license',
424
+ * name: <verbatim business.license value> }
425
+ *
426
+ * HONESTY (non-negotiable, mirrors the Review/FAQ gates in this module): an
427
+ * empty / whitespace / absent value returns `undefined` so the caller omits
428
+ * `hasCredential` entirely — never an empty string, never a fabricated license.
429
+ * The value is carried verbatim in `name` (the field is free text — a license
430
+ * number, a "Licensed & Insured" phrase, or a credential name — so the
431
+ * always-valid `name` slot is the safest home; nothing is parsed or invented).
432
+ */
433
+ declare function buildHasCredential(license: string | undefined): SchemaObject | undefined;
406
434
  /**
407
435
  * Build the cross-linked global knowledge graph as ONE JSON-LD object carrying
408
436
  * a `@graph` array: `Organization`, `WebSite` (publisher → org), and the site's
@@ -412,16 +440,22 @@ declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): Revi
412
440
  *
413
441
  * The LocalBusiness node ABSORBS the existing `global.schemas[*]` LocalBusiness
414
442
  * subtype (its NAP/geo/hours/offers are preserved verbatim) and is promoted with
415
- * an `@id`; honest `review` + `aggregateRating` are merged in when supplied.
443
+ * an `@id`; honest `review` + `aggregateRating` and a `hasCredential` license are
444
+ * merged in when supplied.
416
445
  *
417
446
  * Returns `[]` when there is no `siteUrl` (no stable `@id` anchor possible), so
418
447
  * the existing per-schema emission is left untouched for un-configured sites.
419
448
  *
420
449
  * @param opts.reviews REAL review items (from content.yaml) for the business
421
450
  * node. Optional; when omitted/empty no Review/aggregateRating is added.
451
+ * @param opts.license Free-text trade/occupational license (from the
452
+ * `business.license` content key). Optional; when empty/absent no
453
+ * `hasCredential` is added (honesty-gated — see `buildHasCredential`). Only
454
+ * ever attached to the LocalBusiness node, never to Organization/WebSite.
422
455
  */
423
456
  declare function buildGlobalGraph(global: GlobalSeoConfig, opts?: {
424
457
  reviews?: ReviewSource[];
458
+ license?: string;
425
459
  }): SchemaObject[];
426
460
  /**
427
461
  * Build a `BreadcrumbList` from an ordered trail of crumbs (Home → … → current).
@@ -514,6 +548,15 @@ interface ContentConfig {
514
548
  * rating/text/authorName are dropped there.
515
549
  */
516
550
  declare function findReviewItemsForPage(content: ContentConfig | undefined, pageSlug: string): ReviewSource[];
551
+ /**
552
+ * Read the free-text `business.license` content key from `.dcs/content.yaml`
553
+ * (page-scoped block first, then global — mirroring `findReviewItemsForPage`;
554
+ * NAP identity normally lives under `global`). Returns a trimmed non-empty
555
+ * string, or `undefined` when the key is absent or empty. A bare numeric YAML
556
+ * value (e.g. `business.license: 123456`) is coerced to its string form. No
557
+ * synthesis — an unset key yields `undefined`, so the emit paths stay dark.
558
+ */
559
+ declare function findBusinessLicense(content: ContentConfig | undefined, pageSlug: string): string | undefined;
517
560
  /**
518
561
  * Guarantee an absolute URL. When `value` is already absolute (`http(s)://`) it
519
562
  * is returned unchanged; when it is a site-relative path it is joined onto the
@@ -935,6 +978,14 @@ interface CreateSeoTransformPageDataOptions {
935
978
  * aggregateRating. Default: none.
936
979
  */
937
980
  resolveReviews?: (ctx: SeoPageContext) => ReviewSource[] | undefined;
981
+ /**
982
+ * Provide the free-text trade/occupational license (the `business.license`
983
+ * content.yaml key) for the LocalBusiness node in the `@graph` (only used when
984
+ * `emitGraph` is true). When non-empty a `hasCredential`
985
+ * (`EducationalOccupationalCredential`, `credentialCategory: "license"`) is
986
+ * added to the business node; empty/absent ⇒ nothing. Default: none.
987
+ */
988
+ resolveLicense?: (ctx: SeoPageContext) => string | undefined;
938
989
  /** Enable debug logging of the emitted head per page. */
939
990
  debug?: boolean;
940
991
  }
@@ -976,4 +1027,4 @@ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: Cre
976
1027
  */
977
1028
  declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
978
1029
 
979
- export { findLocalBusinessSchema as $, AI_BOTS as A, type BuildSitemapParams as B, type ContentConfig as C, type DcsRobotsOptions as D, type BlogMeta as E, type ReviewSource as F, type GlobalSeoConfig as G, type FaqSource as H, type PageSeoConfig as I, type SeoAuthorConfig as J, type SeoSocialConfig as K, type SeoImagesConfig as L, type SeoOpenGraphConfig as M, type SeoTwitterConfig as N, type SeoSchemaConfig as O, type PageRouteEntry as P, type SeoAlternateConfig as Q, type ResolvedPageOverrides as R, type SeoConfiguration as S, type SeoVerificationConfig as T, type UseSeoReturn as U, type VitePressPageData as V, type ResolvedPageSeo as W, type UseSeoConfig as X, type HeadOverrides as Y, loadPagesManifest as Z, parsePagesManifest as _, type CreateSeoTransformPageDataOptions as a, graphAbsorbs as a0, deriveSameAs as a1, graphIds as a2, isLocalBusinessType as a3, type NormalisedReview as a4, type NormalisedFaq as a5, type ReviewSchemaParts as a6, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, type SeoPageContext as e, type SeoPageTypeRule as f, type VitePressHeadConfig as g, buildSitemapXml as h, buildRobotsTxt as i, buildLlmsTxt as j, isRouteIndexable as k, buildGlobalGraph as l, buildBreadcrumbList as m, breadcrumbTrailFromRoute as n, buildBlogPosting as o, buildFaqPage as p, buildReviewSchemaParts as q, filterRealReviews as r, filterRealFaq as s, findReviewItemsForPage as t, absolutizeUrl as u, slugToTitle as v, type BuildRobotsParams as w, type BuildLlmsParams as x, type SchemaObject as y, type BreadcrumbCrumb as z };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,6 +31,12 @@
31
31
  },
32
32
  "./review-showcase": {
33
33
  "import": "./src/components/DcsReviewShowcase.vue"
34
+ },
35
+ "./call-button": {
36
+ "import": "./src/components/DcsCallButton.vue"
37
+ },
38
+ "./lite-media-embed": {
39
+ "import": "./src/components/LiteMediaEmbed.vue"
34
40
  }
35
41
  },
36
42
  "main": "./dist/index.js",
@@ -51,7 +57,13 @@
51
57
  },
52
58
  "peerDependencies": {
53
59
  "vue": "^3.4.0",
54
- "@unhead/vue": "^1.9.0"
60
+ "@unhead/vue": "^1.9.0",
61
+ "playwright": "^1.40.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "playwright": {
65
+ "optional": true
66
+ }
55
67
  },
56
68
  "dependencies": {
57
69
  "@duffcloudservices/cms-core": "0.4.3",
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import DcsCallButton from './DcsCallButton.vue'
4
+
5
+ /**
6
+ * `useTextContent` reads the build-time `__DCS_CONTENT__` global synchronously
7
+ * at setup. Stub it to simulate `.dcs/content.yaml` `global` overrides; pass
8
+ * `null` to simulate a site with no injected content at all (the SSR default).
9
+ */
10
+ function stubContent(global: Record<string, string> | null): void {
11
+ if (global === null) {
12
+ vi.stubGlobal('__DCS_CONTENT__', undefined)
13
+ return
14
+ }
15
+ vi.stubGlobal('__DCS_CONTENT__', {
16
+ version: 1,
17
+ lastUpdated: '',
18
+ updatedBy: 'test',
19
+ global,
20
+ })
21
+ }
22
+
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals()
25
+ })
26
+
27
+ describe('DcsCallButton', () => {
28
+ it('renders a tel: link with the managed number when business.phone is set', () => {
29
+ stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Iron Oak' })
30
+
31
+ const wrapper = mount(DcsCallButton, { props: { variant: 'inline' } })
32
+ const link = wrapper.find('a')
33
+
34
+ expect(link.exists()).toBe(true)
35
+ expect(link.attributes('href')).toBe('tel:2483852926')
36
+ expect(link.text()).toContain('(248) 385-2926')
37
+ expect(link.attributes('aria-label')).toBe('Call Iron Oak at (248) 385-2926')
38
+ // The visible number is editor-discoverable via the managed content key.
39
+ expect(link.find('[data-dcs-text="business.phone"]').exists()).toBe(true)
40
+ })
41
+
42
+ it('renders nothing when the business.phone key is absent', () => {
43
+ stubContent({ 'business.name': 'Iron Oak' })
44
+
45
+ const wrapper = mount(DcsCallButton)
46
+
47
+ expect(wrapper.find('a').exists()).toBe(false)
48
+ expect(wrapper.text()).toBe('')
49
+ })
50
+
51
+ it('renders nothing when business.phone is present but blank', () => {
52
+ stubContent({ 'business.phone': ' ' })
53
+
54
+ const wrapper = mount(DcsCallButton)
55
+
56
+ expect(wrapper.find('a').exists()).toBe(false)
57
+ })
58
+
59
+ it('renders nothing when no managed content is injected at all (SSR default)', () => {
60
+ stubContent(null)
61
+
62
+ const wrapper = mount(DcsCallButton)
63
+
64
+ // No fabricated fallback number — the A-34 SSR-fabrication class must be
65
+ // impossible by construction.
66
+ expect(wrapper.find('a').exists()).toBe(false)
67
+ })
68
+
69
+ it('normalizes a US-formatted number to a digits-only tel: href', () => {
70
+ stubContent({ 'business.phone': '(248) 385-2926' })
71
+
72
+ const wrapper = mount(DcsCallButton)
73
+
74
+ expect(wrapper.find('a').attributes('href')).toBe('tel:2483852926')
75
+ })
76
+
77
+ it('preserves a single leading + for E.164 international numbers', () => {
78
+ stubContent({ 'business.phone': '+1 (248) 385-2926' })
79
+
80
+ const wrapper = mount(DcsCallButton)
81
+
82
+ expect(wrapper.find('a').attributes('href')).toBe('tel:+12483852926')
83
+ })
84
+
85
+ it('strips dots and spaces from the tel: href', () => {
86
+ stubContent({ 'business.phone': '248.385.2926' })
87
+
88
+ const wrapper = mount(DcsCallButton)
89
+
90
+ expect(wrapper.find('a').attributes('href')).toBe('tel:2483852926')
91
+ })
92
+
93
+ it('icon variant hides the visible number but keeps an accessible label', () => {
94
+ stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Iron Oak' })
95
+
96
+ const wrapper = mount(DcsCallButton, { props: { variant: 'icon' } })
97
+ const link = wrapper.find('a')
98
+
99
+ expect(link.exists()).toBe(true)
100
+ // No visible number text — it's icon-only.
101
+ expect(link.text()).toBe('')
102
+ expect(link.find('svg').exists()).toBe(true)
103
+ expect(link.attributes('aria-label')).toBe('Call Iron Oak at (248) 385-2926')
104
+ expect(link.attributes('title')).toBe('Call Iron Oak at (248) 385-2926')
105
+ })
106
+
107
+ it('falls back to "Call {number}" when no business name is available', () => {
108
+ stubContent({ 'business.phone': '(248) 385-2926' })
109
+
110
+ const wrapper = mount(DcsCallButton, { props: { variant: 'icon' } })
111
+
112
+ expect(wrapper.find('a').attributes('aria-label')).toBe('Call (248) 385-2926')
113
+ })
114
+
115
+ it('prefers an explicit businessName prop over managed business.name', () => {
116
+ stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Managed Name' })
117
+
118
+ const wrapper = mount(DcsCallButton, {
119
+ props: { variant: 'icon', businessName: 'Explicit Name' },
120
+ })
121
+
122
+ expect(wrapper.find('a').attributes('aria-label')).toBe(
123
+ 'Call Explicit Name at (248) 385-2926'
124
+ )
125
+ })
126
+ })
@@ -0,0 +1,185 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Click-to-call affordance driven by the portal-managed `business.phone`
4
+ * content key — the same NAP field that powers the site's LocalBusiness
5
+ * identity (see `.dcs/content.yaml` `global.business.phone`). Renders a real
6
+ * `tel:` link so mobile visitors get an above-the-fold call path.
7
+ *
8
+ * HONESTY / SSR SAFETY (non-negotiable): the number is read through
9
+ * `useTextContent`, which resolves build-time `.dcs/content.yaml` synchronously
10
+ * (no window/document at setup). When `business.phone` is unset or blank the
11
+ * component renders NOTHING — it never fabricates a placeholder number. A
12
+ * defaulted number leaking into prod SSR is exactly the A-34 fabrication class
13
+ * this repo already had an incident over, so the render gate is a hard `v-if`.
14
+ *
15
+ * Usage:
16
+ * import DcsCallButton from '@duffcloudservices/cms/call-button'
17
+ *
18
+ * <!-- compact, above-the-fold mobile header affordance -->
19
+ * <DcsCallButton variant="icon" />
20
+ *
21
+ * <!-- icon + label + number for a nav/menu row or hero -->
22
+ * <DcsCallButton variant="inline" />
23
+ */
24
+ import { computed } from 'vue'
25
+ import { useTextContent } from '../composables/useTextContent'
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /**
30
+ * Page slug to resolve NAP content from. `business.phone`/`business.name`
31
+ * live in the `global` block, which `useTextContent` always merges in, so
32
+ * the default `'global'` resolves correctly from any page.
33
+ */
34
+ pageSlug?: string
35
+ /**
36
+ * Visual variant: `icon` is a compact icon-only tap target (mobile header);
37
+ * `inline` renders icon + label + number (nav/menu row or hero).
38
+ */
39
+ variant?: 'icon' | 'inline'
40
+ /** Leading call-to-action label for the `inline` variant. */
41
+ label?: string
42
+ /**
43
+ * Business name for the accessible label ("Call {name} at {number}"). When
44
+ * empty, falls back to the managed `business.name`, then to just the number.
45
+ */
46
+ businessName?: string
47
+ }>(),
48
+ {
49
+ pageSlug: 'global',
50
+ variant: 'inline',
51
+ label: 'Call',
52
+ businessName: '',
53
+ }
54
+ )
55
+
56
+ // Empty defaults: the number MUST come from managed content, never a hardcoded
57
+ // fallback baked into the component.
58
+ const { texts } = useTextContent({ pageSlug: props.pageSlug, defaults: {} })
59
+
60
+ /**
61
+ * The portal-managed phone exactly as entered (the human-readable display
62
+ * form, e.g. "(248) 385-2926"). Empty when the key is unset/blank.
63
+ */
64
+ const phoneDisplay = computed(() => {
65
+ const value = texts.value['business.phone']
66
+ return typeof value === 'string' ? value.trim() : ''
67
+ })
68
+
69
+ /** Render only when a real, dialable number is present (has ≥ 1 digit). */
70
+ const hasPhone = computed(() => /\d/.test(phoneDisplay.value))
71
+
72
+ /**
73
+ * `tel:` target: strip all formatting to digits, preserving a single leading
74
+ * `+` for E.164 international numbers.
75
+ * "(248) 385-2926" → "tel:2483852926"
76
+ * "+1 (248) 385-2926" → "tel:+12483852926"
77
+ */
78
+ const telHref = computed(() => {
79
+ const raw = phoneDisplay.value
80
+ const plus = raw.startsWith('+') ? '+' : ''
81
+ return `tel:${plus}${raw.replace(/\D/g, '')}`
82
+ })
83
+
84
+ /** Business name for the aria-label: prop → managed `business.name` → none. */
85
+ const resolvedName = computed(() => {
86
+ const fromProp = props.businessName.trim()
87
+ if (fromProp) return fromProp
88
+ const value = texts.value['business.name']
89
+ return typeof value === 'string' ? value.trim() : ''
90
+ })
91
+
92
+ const ariaLabel = computed(() =>
93
+ resolvedName.value
94
+ ? `Call ${resolvedName.value} at ${phoneDisplay.value}`
95
+ : `Call ${phoneDisplay.value}`
96
+ )
97
+ </script>
98
+
99
+ <template>
100
+ <a
101
+ v-if="hasPhone"
102
+ :href="telHref"
103
+ class="dcs-call-button"
104
+ :class="`dcs-call-button--${variant}`"
105
+ :aria-label="ariaLabel"
106
+ :title="variant === 'icon' ? ariaLabel : undefined"
107
+ data-dcs-call
108
+ >
109
+ <svg
110
+ class="dcs-call-button__icon"
111
+ viewBox="0 0 24 24"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ stroke-width="2"
115
+ stroke-linecap="round"
116
+ stroke-linejoin="round"
117
+ aria-hidden="true"
118
+ >
119
+ <path
120
+ d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"
121
+ />
122
+ </svg>
123
+ <span v-if="variant === 'inline'" class="dcs-call-button__label">{{ label }}</span>
124
+ <span
125
+ v-if="variant === 'inline'"
126
+ class="dcs-call-button__number"
127
+ data-dcs-text="business.phone"
128
+ >{{ phoneDisplay }}</span
129
+ >
130
+ </a>
131
+ </template>
132
+
133
+ <style scoped>
134
+ .dcs-call-button {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ gap: var(--dcs-call-gap, 0.5rem);
138
+ color: var(--dcs-call-color, currentColor);
139
+ text-decoration: none;
140
+ font-weight: var(--dcs-call-font-weight, 600);
141
+ line-height: 1;
142
+ border-radius: var(--dcs-call-radius, 0.5rem);
143
+ transition:
144
+ color 0.2s ease,
145
+ background-color 0.2s ease;
146
+ }
147
+
148
+ .dcs-call-button:hover {
149
+ color: var(--dcs-call-color-hover, var(--brand-accent, currentColor));
150
+ }
151
+
152
+ .dcs-call-button:focus-visible {
153
+ outline: 2px solid var(--dcs-call-focus, currentColor);
154
+ outline-offset: 2px;
155
+ }
156
+
157
+ /* Icon-only: a WCAG 2.5.5 minimum 44×44 touch target for reliable tapping. */
158
+ .dcs-call-button--icon {
159
+ justify-content: center;
160
+ min-width: 2.75rem;
161
+ min-height: 2.75rem;
162
+ padding: var(--dcs-call-icon-padding, 0.5rem);
163
+ }
164
+
165
+ .dcs-call-button--inline {
166
+ padding: var(--dcs-call-inline-padding, 0);
167
+ }
168
+
169
+ .dcs-call-button__icon {
170
+ width: var(--dcs-call-icon-size, 1.25rem);
171
+ height: var(--dcs-call-icon-size, 1.25rem);
172
+ flex-shrink: 0;
173
+ }
174
+
175
+ .dcs-call-button__number {
176
+ font-variant-numeric: tabular-nums;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ @media (prefers-reduced-motion: reduce) {
181
+ .dcs-call-button {
182
+ transition: none;
183
+ }
184
+ }
185
+ </style>
@@ -60,16 +60,27 @@ function markAvatarFailed(reviewId: string) {
60
60
  failedAvatarIds.value = next
61
61
  }
62
62
 
63
- /** Format date string to readable format */
63
+ /**
64
+ * Format date string to readable format.
65
+ *
66
+ * Both the locale AND the time zone are pinned so the string is byte-identical
67
+ * between the SSR/SSG pass (which runs in the build host's locale/zone — and
68
+ * Windows Node ignores the `TZ` env var) and the client hydration (visitor's
69
+ * locale/zone). Without pinning, a date near a day boundary — or an en-GB vs
70
+ * en-US visitor — renders differently on server and client, tripping a Vue
71
+ * hydration mismatch. `timeZone: 'UTC'` is the key fix; the fixed `en-US` locale
72
+ * removes the remaining month-abbreviation drift.
73
+ */
64
74
  function formatDate(dateStr: string): string {
65
75
  const parsed = new Date(dateStr)
66
76
  if (Number.isNaN(parsed.getTime())) {
67
77
  return dateStr
68
78
  }
69
79
 
70
- return parsed.toLocaleDateString(undefined, {
80
+ return parsed.toLocaleDateString('en-US', {
71
81
  year: 'numeric',
72
82
  month: 'short',
83
+ timeZone: 'UTC',
73
84
  })
74
85
  }
75
86