@duffcloudservices/cms 0.12.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 +13 -1
- package/dist/editor/editorBridge.js +75 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +365 -22
- package/dist/index.js +421 -21
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +90 -6
- package/dist/plugins/index.js +530 -49
- 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 +17 -6
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +34 -0
- package/src/components/ManagedImage.vue +5 -0
- 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/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
|
@@ -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
|
-
|
|
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
|
-
* //
|
|
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
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
},
|
package/dist/chunk-DAYLLSEE.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-DAYLLSEE.js"}
|