@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
@@ -28,6 +28,7 @@
28
28
  * ```
29
29
  */
30
30
 
31
+ import { platformFetch } from '@duffcloudservices/cms-core'
31
32
  import { ref, onMounted } from 'vue'
32
33
  import type { ReleaseNote, ReleaseNotesReturn } from '../types/release-notes'
33
34
 
@@ -88,7 +89,12 @@ export function useReleaseNotes(
88
89
 
89
90
  try {
90
91
  const url = `${apiBaseUrl}/api/v1/release-notes/${version}`
91
- const response = await fetch(url, {
92
+ // C-298 layer 2: an HTML body here means the call never reached the API (a host
93
+ // whose Front Door config has no /api/v1/* route answers with the SPA shell at
94
+ // HTTP 200). platformFetch names the URL + content-type instead of letting
95
+ // `response.json()` throw an opaque "Unexpected token '<'" into the catch below.
96
+ // A real 404 still lands in the `!response.ok` branch untouched.
97
+ const response = await platformFetch(url, {
92
98
  headers: {
93
99
  Accept: 'application/json',
94
100
  },
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ENFORCEMENT, IN ONE PLACE (C-356).
3
+ *
4
+ * The head-authority contract is enforced by the shared package, not by eleven
5
+ * site test suites: `applyHead` is typed `() => void`, and an argument that
6
+ * reaches it at runtime anyway is DROPPED with a loud message.
7
+ *
8
+ * Both halves are tested here because both are load-bearing:
9
+ * - the TYPE stops a type-checked caller at build (`pnpm type-check`, wired as
10
+ * the fleet's pre-deploy gate by C-303/C-316);
11
+ * - the RUNTIME stops the caller who is not type-checked — a `.vue` SFC whose
12
+ * script block never sees `vue-tsc`, an `as any`, or a plain-JS site
13
+ * (just-posh is exactly that: `jsconfig.json`, `.js` sources, no type-check
14
+ * script at all). That caller is not hypothetical; it is the fleet.
15
+ *
16
+ * The failure MODE is deliberately asymmetric — see the assertions at the end.
17
+ */
18
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
19
+
20
+ const applied: unknown[] = []
21
+ vi.mock('@unhead/vue', () => ({
22
+ useHead: (input: unknown) => {
23
+ applied.push(input)
24
+ },
25
+ }))
26
+
27
+ // The composable reads its config from the `__DCS_SEO__` build-time global.
28
+ const SEO = {
29
+ global: {
30
+ siteName: 'Fixture Co',
31
+ siteUrl: 'https://fixture.example.com',
32
+ titleTemplate: '%s | Fixture Co',
33
+ defaultTitle: 'Fixture Co',
34
+ defaultDescription: 'The approved description.',
35
+ },
36
+ pages: {
37
+ home: { title: 'Approved Title', description: 'The approved description.' },
38
+ },
39
+ }
40
+
41
+ const { useSEO, HEAD_OVERRIDE_REFUSED_MESSAGE } = await import('./useSEO')
42
+ const { buildHeadTags } = await import('../seo/headTags')
43
+
44
+ type ApplyHeadWithOverride = (overrides: Record<string, unknown>) => void
45
+
46
+ beforeEach(() => {
47
+ applied.length = 0
48
+ ;(globalThis as Record<string, unknown>).__DCS_SEO__ = SEO
49
+ })
50
+
51
+ afterEach(() => {
52
+ vi.restoreAllMocks()
53
+ delete (globalThis as Record<string, unknown>).__DCS_SEO__
54
+ })
55
+
56
+ describe('applyHead re-asserts the baked head', () => {
57
+ test('with no arguments it emits the seo.yaml-resolved head', () => {
58
+ const { applyHead } = useSEO('home', '/')
59
+ applyHead()
60
+ expect(applied).toHaveLength(1)
61
+ const head = applied[0] as { title: string; meta: Array<{ name?: string; content: string }> }
62
+ expect(head.title).toBe('Approved Title | Fixture Co')
63
+ expect(head.meta.find((m) => m.name === 'description')!.content).toBe('The approved description.')
64
+ })
65
+
66
+ test('the type says () => void — an override is a COMPILE error, not a runtime option', () => {
67
+ // NOTE: the compile-time assertion is NOT here. `packages/cms/tsconfig.json`
68
+ // excludes `**/*.test.ts`, so a `@ts-expect-error` in this file would never
69
+ // be evaluated by `pnpm type-check` — a comment dressed as a gate. The real
70
+ // pin is `APPLY_HEAD_TAKES_NO_ARGUMENTS` in `src/seo/headContract.ts`, which
71
+ // is a checked source file; kill-tested by re-adding the parameter (tsc then
72
+ // reports TS2322 on that line). This test covers the RUNTIME half only.
73
+ const { applyHead } = useSEO('home', '/')
74
+ expect(applyHead).toHaveLength(0)
75
+ applyHead()
76
+ expect(applied).toHaveLength(1)
77
+ })
78
+ })
79
+
80
+ describe('an override that reaches the runtime is REFUSED, not obeyed', () => {
81
+ test('the emitted head is byte-identical to the no-argument call', () => {
82
+ const errors: string[] = []
83
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => {
84
+ errors.push(String(m))
85
+ })
86
+
87
+ const a = useSEO('home', '/')
88
+ a.applyHead()
89
+ const clean = JSON.stringify(applied[0])
90
+
91
+ applied.length = 0
92
+ const b = useSEO('home', '/')
93
+ ;(b.applyHead as unknown as ApplyHeadWithOverride)({
94
+ title: 'Hand-written Title',
95
+ description: 'Hand-written description.',
96
+ keywords: 'hand, written',
97
+ })
98
+
99
+ expect(JSON.stringify(applied[0])).toBe(clean)
100
+ expect(JSON.stringify(applied[0])).not.toContain('Hand-written')
101
+ expect(errors.join('\n')).toContain('IGNORED')
102
+ expect(errors.join('\n')).toContain('.dcs/seo.yaml is the only writer')
103
+ expect(errors.join('\n')).toContain('title, description, keywords')
104
+ })
105
+
106
+ test('KILL-TEST: without the refusal the override WOULD have won', () => {
107
+ // The old behaviour, reproduced through the resolver the composable calls:
108
+ // `overrides?.title ?? resolved.title`. This is the exact line that shipped
109
+ // 93 divergences; it is asserted here so the fix cannot be quietly undone
110
+ // without this test going red.
111
+ const errors: string[] = []
112
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => errors.push(String(m)))
113
+ const overridden = buildHeadTags('home', '/', SEO as never, { title: 'Hand-written Title' })
114
+ expect(overridden.title).toBe('Hand-written Title')
115
+
116
+ const { applyHead } = useSEO('home', '/')
117
+ ;(applyHead as unknown as ApplyHeadWithOverride)({ title: 'Hand-written Title' })
118
+ expect((applied[0] as { title: string }).title).toBe('Approved Title | Fixture Co')
119
+ })
120
+
121
+ test('the refusal names the offending page slug and the ignored keys', () => {
122
+ const errors: string[] = []
123
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => errors.push(String(m)))
124
+ const { applyHead } = useSEO('home', '/')
125
+ ;(applyHead as unknown as ApplyHeadWithOverride)({ title: 'x', schemas: [] })
126
+ expect(errors[0]).toContain('page slug: home')
127
+ expect(errors[0]).toContain('ignored keys: title, schemas')
128
+ expect(errors[0]).toContain(HEAD_OVERRIDE_REFUSED_MESSAGE.slice(0, 60))
129
+ })
130
+
131
+ test('the failure mode is ASYMMETRIC on purpose: SEO stays correct, the build does not fall over', () => {
132
+ // A production throw would take a paying customer's page down over a
133
+ // metadata mistake. Dropping the override cannot: the worst case is that
134
+ // the page serves the OWNER-APPROVED value.
135
+ vi.spyOn(console, 'error').mockImplementation(() => {})
136
+ const { applyHead } = useSEO('home', '/')
137
+ expect(() => (applyHead as unknown as ApplyHeadWithOverride)({ title: 'x' })).not.toThrow()
138
+ expect((applied[0] as { title: string }).title).toBe('Approved Title | Fixture Co')
139
+ })
140
+
141
+ test('a null / undefined argument is not treated as a violation', () => {
142
+ const errors: string[] = []
143
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => errors.push(String(m)))
144
+ const { applyHead } = useSEO('home', '/')
145
+ ;(applyHead as unknown as (o?: unknown) => void)(undefined)
146
+ ;(applyHead as unknown as (o?: unknown) => void)(null)
147
+ expect(errors).toEqual([])
148
+ expect(applied).toHaveLength(2)
149
+ })
150
+ })
@@ -8,6 +8,12 @@
8
8
  * module so that the build-time static-HTML emitter (`dcsSeoPlugin`) produces
9
9
  * byte-identical output. This composable is a thin Vue/unhead wrapper over it.
10
10
  *
11
+ * THE HEAD-AUTHORITY CONTRACT (C-356). `.dcs/seo.yaml` is the ONLY writer of
12
+ * the managed head fields. `applyHead()` RE-ASSERTS the baked head at runtime;
13
+ * it never AUTHORS one, so it takes no arguments. Full text + reasoning:
14
+ * `.docs/plans/dynamic-site-resolution/README.md` § "The head-authority
15
+ * contract (C-356)".
16
+ *
11
17
  * @example
12
18
  * ```vue
13
19
  * <script setup lang="ts">
@@ -15,16 +21,15 @@
15
21
  *
16
22
  * const { applyHead, getSchema, config } = useSEO('home')
17
23
  *
18
- * // Apply all meta tags
24
+ * // Re-assert the baked head for this route. No arguments — ever.
19
25
  * applyHead()
20
- *
21
- * // Or customize before applying
22
- * applyHead({
23
- * title: 'Custom Override Title',
24
- * schemas: [...getSchema(), customSchema]
25
- * })
26
26
  * </script>
27
27
  * ```
28
+ *
29
+ * Need a different title? Change it in `.dcs/seo.yaml` (or in the portal SEO
30
+ * editor, which writes it). A value hardcoded here is a SECOND writer, and a
31
+ * second writer is a divergence by construction — whether or not today's two
32
+ * values happen to agree.
28
33
  */
29
34
 
30
35
  import { computed, type ComputedRef } from 'vue'
@@ -34,7 +39,6 @@ import type {
34
39
  GlobalSeoConfig,
35
40
  ResolvedPageSeo,
36
41
  UseSeoReturn,
37
- HeadOverrides,
38
42
  } from '../types/seo'
39
43
  import { buildHeadTags, resolvePageSeo, generateJsonLd } from '../seo/headTags'
40
44
 
@@ -56,6 +60,35 @@ function getBuildTimeSeo(): SeoConfiguration | undefined {
56
60
  return undefined
57
61
  }
58
62
 
63
+ /**
64
+ * The C-356 refusal. Exported so tests can assert the exact message and so a
65
+ * host app can spot it in a log.
66
+ *
67
+ * IT DOES NOT THROW, ANYWHERE. The build-time gates are the ones that must be
68
+ * unmissable — the `() => void` type (caught by every site's `type-check`, made
69
+ * pre-deploy-mandatory by C-303/C-316) and `assertHeadContract` in the source
70
+ * audit, which fails the build outright. By the time control reaches this
71
+ * function the page is already rendering for a real visitor, and throwing there
72
+ * would take a paying customer's page down over a metadata mistake. The
73
+ * asymmetry is the point: the worst outcome of a refused override is that the
74
+ * page serves the OWNER-APPROVED value with a console error next to it.
75
+ */
76
+ export const HEAD_OVERRIDE_REFUSED_MESSAGE =
77
+ '[dcs-seo] applyHead() was called WITH AN ARGUMENT and the argument was IGNORED. ' +
78
+ '.dcs/seo.yaml is the only writer of title/description/keywords/canonical/OG/Twitter/' +
79
+ 'JSON-LD (head-authority contract, C-356). A hardcoded value here overwrites the ' +
80
+ 'owner-approved baked head for Google and every human while non-JS crawlers keep ' +
81
+ 'receiving the approved one. Move the value into .dcs/seo.yaml and call applyHead() ' +
82
+ 'with no arguments.'
83
+
84
+ function reportHeadOverrideRefused(pageSlug: string, refused: unknown): void {
85
+ const keys =
86
+ refused && typeof refused === 'object' ? Object.keys(refused as object).join(', ') : String(refused)
87
+ console.error(
88
+ `${HEAD_OVERRIDE_REFUSED_MESSAGE}\n page slug: ${pageSlug}\n ignored keys: ${keys}`
89
+ )
90
+ }
91
+
59
92
  /**
60
93
  * useSEO composable for DCS-managed SEO configuration.
61
94
  *
@@ -87,20 +120,30 @@ export function useSEO(pageSlug: string, pagePath?: string): UseSeoReturn {
87
120
  }
88
121
 
89
122
  /**
90
- * Apply all meta tags via useHead.
123
+ * RE-ASSERT the baked head for this route. Takes no arguments.
91
124
  *
92
125
  * Delegates to the shared `buildHeadTags` resolver so the emitted tags match
93
126
  * the build-time static-HTML emitter exactly. Keywords are intentionally not
94
127
  * emitted at runtime (historical behaviour), so `includeKeywords` is omitted.
128
+ *
129
+ * ENFORCEMENT (C-356). The parameter is gone from the signature, so TypeScript
130
+ * consumers fail `type-check`. But a `.vue` file compiled without type
131
+ * checking, an `as any`, or a JS site can still reach this function with an
132
+ * argument, and THAT is the shape that shipped 93 divergences on KEPT. So the
133
+ * override is also refused at runtime: it is dropped, `.dcs/seo.yaml` still
134
+ * wins, and the violation is reported. A violating site therefore degrades to
135
+ * CORRECT SEO plus a loud message — never to a silently destroyed head.
95
136
  */
96
- function applyHead(overrides?: HeadOverrides): void {
97
- const { title, meta, link, script } = buildHeadTags(pageSlug, pagePath, seoConfig, {
98
- title: overrides?.title,
99
- description: overrides?.description,
100
- keywords: overrides?.keywords,
101
- schemas: overrides?.schemas,
102
- meta: overrides?.meta,
103
- })
137
+ // Declared with a rest parameter so a runtime argument is CAPTURED, then
138
+ // exposed through `UseSeoReturn` as `() => void` so a compile-time argument is
139
+ // REJECTED. Both halves are needed: the type stops the honest caller, the
140
+ // runtime stops the one who is not type-checked.
141
+ function applyHeadImpl(...refused: unknown[]): void {
142
+ if (refused.length > 0 && refused[0] !== undefined && refused[0] !== null) {
143
+ reportHeadOverrideRefused(pageSlug, refused[0])
144
+ }
145
+
146
+ const { title, meta, link, script } = buildHeadTags(pageSlug, pagePath, seoConfig)
104
147
 
105
148
  // Apply via useHead. The shared resolver returns framework-agnostic tag
106
149
  // shapes (HeadMetaTag/HeadLinkTag/HeadScriptTag); unhead's input types are
@@ -114,6 +157,9 @@ export function useSEO(pageSlug: string, pagePath?: string): UseSeoReturn {
114
157
  } as unknown as Parameters<typeof useHead>[0])
115
158
  }
116
159
 
160
+ // The narrowing that makes the contract compile-checkable for consumers.
161
+ const applyHead: () => void = applyHeadImpl
162
+
117
163
  return {
118
164
  config,
119
165
  applyHead,
@@ -22,6 +22,7 @@
22
22
  * ```
23
23
  */
24
24
 
25
+ import { platformFetch } from '@duffcloudservices/cms-core'
25
26
  import { ref, computed, onMounted } from 'vue'
26
27
  import type { SiteVersionReturn } from '../types/release-notes'
27
28
 
@@ -79,7 +80,9 @@ export function useSiteVersion(options: { fetchOnMount?: boolean } = {}): SiteVe
79
80
  try {
80
81
  // Fetch the latest release notes to get the version
81
82
  const url = `${apiBaseUrl}/api/v1/release-notes/latest`
82
- const response = await fetch(url, {
83
+ // C-298 layer 2 — see useReleaseNotes: an HTML body is a misroute, not "no release
84
+ // notes yet", and this composable's catch would otherwise swallow it silently.
85
+ const response = await platformFetch(url, {
83
86
  headers: {
84
87
  Accept: 'application/json',
85
88
  },
@@ -70,6 +70,62 @@ describe('fetchSiteVisitorSession — anonymous probe is silent', () => {
70
70
  })
71
71
  })
72
72
 
73
+ describe('fetchSiteVisitorSession — a misroute is NOT an anonymous visit (C-298 layer 2)', () => {
74
+ /**
75
+ * The default base is the RELATIVE `/api/v1`. On a host whose Front Door config has no
76
+ * `/api/v1/*` route the catch-all answers with the SPA shell at HTTP 200 — which used to
77
+ * be indistinguishable from "signed out", the reason KEPT ran with all nine login routes
78
+ * dead for ~2 weeks (C-261) and the reason it is still live on www.nateduff.com.
79
+ */
80
+ const SPA_SHELL =
81
+ '<!DOCTYPE html><html><head><script type="module" src="/assets/index.js"></script></head><body><div id="app"></div></body></html>'
82
+
83
+ function htmlResponse() {
84
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' })
85
+ const build = (): Response =>
86
+ ({
87
+ ok: true,
88
+ status: 200,
89
+ headers,
90
+ clone: () => build(),
91
+ text: async () => SPA_SHELL,
92
+ json: async () => JSON.parse(SPA_SHELL),
93
+ }) as unknown as Response
94
+ return build()
95
+ }
96
+
97
+ it('200 text/html → signed out for the UI, apiUnreachable=true, and LOUD on the console', async () => {
98
+ const spies = installConsoleSpies()
99
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse()))
100
+
101
+ const result = await fetchSiteVisitorSession()
102
+
103
+ // The page still renders signed-out (a login button that cannot work is worse)…
104
+ expect(result.visitor).toBeNull()
105
+ expect(result.authenticated).toBe(false)
106
+ // …but the caller can tell the difference, and the console is not silent.
107
+ expect(result.apiUnreachable).toBe(true)
108
+ expect(spies.error).toHaveBeenCalledTimes(1)
109
+ const message = String(spies.error.mock.calls[0][0])
110
+ expect(message).toContain('/api/v1/site-auth/session')
111
+ expect(message).toContain('text/html')
112
+ expect(message).toContain('bodyClass=spa-html')
113
+ })
114
+
115
+ it('the ordinary anonymous paths never set apiUnreachable', async () => {
116
+ const spies = installConsoleSpies()
117
+ vi.stubGlobal(
118
+ 'fetch',
119
+ vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) }),
120
+ )
121
+
122
+ const result = await fetchSiteVisitorSession()
123
+
124
+ expect(result.apiUnreachable).toBeFalsy()
125
+ expectSilent(spies)
126
+ })
127
+ })
128
+
73
129
  describe('fetchSiteVisitorSession — authenticated shapes', () => {
74
130
  it('200 {visitor:{...}} → returns the visitor', async () => {
75
131
  vi.stubGlobal(
@@ -13,7 +13,22 @@
13
13
  * `console.error`/`console.warn` on every signed-out page load is exactly the
14
14
  * console-noise this fixes. A genuine signed-in visitor is returned as a
15
15
  * normalized `SiteVisitor`; anything else resolves to `null`.
16
+ *
17
+ * ONE EXCEPTION, added by C-298 layer 2. "Signed out" and "this host does not route
18
+ * `/api/v1/*` to the platform API at all" used to be the SAME observation here: the
19
+ * default base is the RELATIVE `/api/v1`, so on a host whose Front Door config lacks the
20
+ * route, Front Door's catch-all answers with the SPA shell at HTTP 200, `response.json()`
21
+ * throws, and this client silently resolves to signed-out. That is precisely how KEPT — a
22
+ * paying customer — ran with all nine login routes dead for ~2 weeks with nothing
23
+ * detecting it (C-261), and it is still live today on `www.nateduff.com`.
24
+ *
25
+ * A non-JSON body is therefore NOT treated as an anonymous visit: it is reported loudly
26
+ * (once per probe, naming the URL and the received content-type) and the UI still degrades
27
+ * to signed-out, because a login button that cannot work is better than a broken page.
28
+ * The anonymous path — `200 {"visitor":null}`, a legacy `401`, a network error — stays
29
+ * exactly as silent as before.
16
30
  */
31
+ import { platformFetch, isPlatformFetchError } from '@duffcloudservices/cms-core'
17
32
  import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
18
33
 
19
34
  /** A signed-in site visitor. */
@@ -30,6 +45,14 @@ export interface SiteVisitorSessionResult {
30
45
  visitor: SiteVisitor | null
31
46
  /** Convenience flag — `true` iff a visitor is present. */
32
47
  authenticated: boolean
48
+ /**
49
+ * `true` when the probe did not reach the platform API at all — the response carried a
50
+ * non-JSON body (C-298 layer 2), i.e. this host does not route `/api/v1/*` to the API.
51
+ * Distinct from an ordinary signed-out visit: a site can render "sign-in temporarily
52
+ * unavailable" instead of a login button that cannot possibly work. Absent/`false` on
53
+ * every normal path, so existing consumers are unaffected.
54
+ */
55
+ apiUnreachable?: boolean
33
56
  }
34
57
 
35
58
  export interface FetchSiteVisitorSessionOptions {
@@ -83,13 +106,19 @@ export async function fetchSiteVisitorSession(
83
106
 
84
107
  let response: Response
85
108
  try {
86
- response = await fetch(`${base}/site-auth/session`, {
109
+ response = await platformFetch(`${base}/site-auth/session`, {
87
110
  method: 'GET',
88
111
  credentials: 'include',
89
112
  headers: { Accept: 'application/json' },
90
113
  signal: options.signal,
91
114
  })
92
- } catch {
115
+ } catch (e) {
116
+ // A NON-JSON body is a misroute, not an anonymous visit: the request never reached
117
+ // the API. platformFetch has already written the diagnosable message to the console;
118
+ // flag it so the UI can say so, and still degrade to signed-out so the page renders.
119
+ if (isPlatformFetchError(e)) {
120
+ return { visitor: null, authenticated: false, apiUnreachable: true }
121
+ }
93
122
  // Network error / aborted / CORS — signed-out is the safe probe assumption.
94
123
  return SIGNED_OUT
95
124
  }
@@ -123,6 +152,11 @@ export interface UseSiteVisitorSessionReturn {
123
152
  isAuthenticated: ComputedRef<boolean>
124
153
  /** `true` while a probe is in flight. */
125
154
  isLoading: Ref<boolean>
155
+ /**
156
+ * `true` when the last probe did not reach the platform API (non-JSON body — C-298
157
+ * layer 2). Render "sign-in unavailable" rather than a login button that cannot work.
158
+ */
159
+ apiUnreachable: Ref<boolean>
126
160
  /** Re-run the probe. */
127
161
  refresh: () => Promise<void>
128
162
  }
@@ -138,6 +172,7 @@ export function useSiteVisitorSession(
138
172
 
139
173
  const visitor = ref<SiteVisitor | null>(null)
140
174
  const isLoading = ref(false)
175
+ const apiUnreachable = ref(false)
141
176
  const isAuthenticated = computed(() => visitor.value !== null)
142
177
 
143
178
  async function refresh(): Promise<void> {
@@ -145,6 +180,7 @@ export function useSiteVisitorSession(
145
180
  try {
146
181
  const result = await fetchSiteVisitorSession(fetchOptions)
147
182
  visitor.value = result.visitor
183
+ apiUnreachable.value = result.apiUnreachable === true
148
184
  } finally {
149
185
  isLoading.value = false
150
186
  }
@@ -156,5 +192,5 @@ export function useSiteVisitorSession(
156
192
  })
157
193
  }
158
194
 
159
- return { visitor, isAuthenticated, isLoading, refresh }
195
+ return { visitor, isAuthenticated, isLoading, apiUnreachable, refresh }
160
196
  }
@@ -32,6 +32,7 @@
32
32
  * ```
33
33
  */
34
34
 
35
+ import { platformFetch } from '@duffcloudservices/cms-core'
35
36
  import { ref, computed, readonly, onMounted, type Ref } from 'vue'
36
37
  import type { DcsContentFile, TextContentConfig, TextContentReturn } from '../types/content'
37
38
 
@@ -231,7 +232,14 @@ export function useTextContent(config: TextContentConfig): TextContentReturn {
231
232
  // Site is resolved server-side from the request Host or the dedicated
232
233
  // Container App's DCS_SITE_SLUG; the slug is no longer encoded in the path.
233
234
  const url = `${apiBaseUrl}/api/v1/pages/${pageSlug}/text`
234
- const response = await fetch(url, {
235
+ // C-298 layer 2. `VITE_API_BASE_URL` defaults to '' here, so an unset base in
236
+ // `runtime` mode produces a RELATIVE call — the exact shape of C-261. The sweep also
237
+ // proved the configured base (api.duffcloudservices.com) is tenant-unresolvable for
238
+ // this host-resolved route (400 "Unable to resolve site from request"), which arrives
239
+ // as JSON and is therefore left to the `!response.ok` branch below, unchanged. What
240
+ // platformFetch adds is that an HTML shell can no longer be mistaken for "no
241
+ // overrides for this page".
242
+ const response = await platformFetch(url, {
235
243
  headers: {
236
244
  Accept: 'application/json',
237
245
  },
@@ -1,3 +0,0 @@
1
-
2
- //# sourceMappingURL=chunk-DAYLLSEE.js.map
3
- //# sourceMappingURL=chunk-DAYLLSEE.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-DAYLLSEE.js"}