@certrev/cert-block 0.1.2 → 0.2.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/dist/builder/index.d.ts +54 -52
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +64 -47
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +18 -3
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +8 -2
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertReview.d.ts +7 -0
- package/dist/components/CertReview.d.ts.map +1 -1
- package/dist/components/CertReview.js +1 -1
- package/dist/components/CertReview.js.map +1 -1
- package/dist/components/format.d.ts +14 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +18 -5
- package/dist/components/format.js.map +1 -1
- package/dist/components/render-cert-block.d.ts +89 -0
- package/dist/components/render-cert-block.d.ts.map +1 -0
- package/dist/components/render-cert-block.js +282 -0
- package/dist/components/render-cert-block.js.map +1 -0
- package/dist/components/render-def.d.ts +178 -0
- package/dist/components/render-def.d.ts.map +1 -0
- package/dist/components/render-def.js +204 -0
- package/dist/components/render-def.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +8 -0
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +31 -4
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +13 -0
- package/src/__tests__/components.test.tsx +16 -1
- package/src/__tests__/render-cert-block.test.ts +259 -0
- package/src/__tests__/render-def.test.tsx +286 -0
- package/src/__tests__/webcomponent.test.tsx +10 -0
- package/src/builder/__tests__/builder.test.tsx +38 -51
- package/src/builder/index.tsx +86 -64
- package/src/components/CertBadge.tsx +56 -4
- package/src/components/CertReview.tsx +8 -0
- package/src/components/format.ts +22 -5
- package/src/components/render-cert-block.ts +403 -0
- package/src/components/render-def.ts +286 -0
- package/src/index.ts +35 -0
- package/src/webcomponent/render-badge-html.ts +46 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderCertBlock — the LOCKED 3-mode CertREV cert design (banner / sidebar /
|
|
3
|
+
* floating) as an SSR-safe HTML string.
|
|
4
|
+
*
|
|
5
|
+
* Coverage:
|
|
6
|
+
* 1. SNAPSHOTS — each mode for a representative envelope + a themed (non-navy)
|
|
7
|
+
* variant, so any drift from the owner-approved locked design is caught.
|
|
8
|
+
* 2. LOCKED disclosures — the compensation cue, the byte-exact FTC scope line,
|
|
9
|
+
* and the GROUPED dated credential verification appear on every CLAIM face
|
|
10
|
+
* (banner + sidebar). Floating is a modal pointer, not a claim face.
|
|
11
|
+
* 3. THEMING — the resolved tokens reach the root as the guide's CSS vars; an
|
|
12
|
+
* invalid token fails safe to navy; the fixed logo chrome is present.
|
|
13
|
+
* 4. ESCAPING — a value carrying `< & "` is escaped (this is storefront HTML).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
CERT_SCOPE_LINE,
|
|
19
|
+
type CertBlockFacts,
|
|
20
|
+
type CertBlockMode,
|
|
21
|
+
COMPENSATED_EXPERT_CUE,
|
|
22
|
+
CREDENTIAL_VERIFIED_ATTRIBUTION,
|
|
23
|
+
RENDER_BLOCK_FONT_STACKS,
|
|
24
|
+
type ResolvedBlockTheme,
|
|
25
|
+
renderCertBlock,
|
|
26
|
+
} from '../index.js'
|
|
27
|
+
|
|
28
|
+
/** A representative, well-formed cert (mirrors the locked-reference sample facts). */
|
|
29
|
+
const FACTS: CertBlockFacts = {
|
|
30
|
+
authorName: 'The Editorial Team',
|
|
31
|
+
authorTitle: 'Editorial',
|
|
32
|
+
reviewerName: 'Jane Doe',
|
|
33
|
+
credential: 'MD',
|
|
34
|
+
credentialVerifiedAt: '2026-04-14T00:00:00.000Z',
|
|
35
|
+
certifiedAt: '2026-04-14T00:00:00.000Z',
|
|
36
|
+
memo: 'Patients ask me almost every week whether the SPF in their foundation is enough on its own. Treat makeup with SPF as a helpful last layer, not the base of your routine.',
|
|
37
|
+
bio: 'Board-certified dermatologist in San Francisco specializing in photoprotection, skin-cancer prevention, and evidence-based skincare, with fifteen years of clinical practice.',
|
|
38
|
+
profileUrl: 'https://certrev.com/expert/sample',
|
|
39
|
+
certificateUrl: 'https://certrev.com/verify/sample',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A themed brand (non-navy accent + dark ink + custom corners/surface/font). */
|
|
43
|
+
const THEMED: ResolvedBlockTheme = {
|
|
44
|
+
accentColor: '#E8603C',
|
|
45
|
+
surface: '#faf8f4',
|
|
46
|
+
cornerRadius: '22px',
|
|
47
|
+
fontSlot: 'grotesk',
|
|
48
|
+
accentFg: '#141414',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MODES: CertBlockMode[] = ['banner', 'sidebar', 'floating']
|
|
52
|
+
const CLAIM_FACES: CertBlockMode[] = ['banner', 'sidebar']
|
|
53
|
+
|
|
54
|
+
/** Tag-strip a fragment of HTML to its visible text (collapse whitespace). */
|
|
55
|
+
function visibleText(html: string): string {
|
|
56
|
+
return html
|
|
57
|
+
.replace(/<[^>]+>/g, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// LOCKED disclosures are undroppable — omitted OR empty/whitespace defaults
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe('renderCertBlock — LOCKED disclosure strings are undroppable', () => {
|
|
67
|
+
for (const mode of CLAIM_FACES) {
|
|
68
|
+
it(`${mode}: an empty/whitespace override of the locked strings falls back to the byte-exact constants`, () => {
|
|
69
|
+
const html = renderCertBlock({
|
|
70
|
+
mode,
|
|
71
|
+
facts: { ...FACTS, compensationCue: '', scopeLine: '', credentialLine: ' ' },
|
|
72
|
+
})
|
|
73
|
+
expect(html).toContain(COMPENSATED_EXPERT_CUE)
|
|
74
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
75
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Snapshots — the locked-design byte target
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe('renderCertBlock — locked-design snapshots', () => {
|
|
85
|
+
for (const mode of MODES) {
|
|
86
|
+
it(`${mode} (navy default) matches the locked snapshot`, () => {
|
|
87
|
+
expect(renderCertBlock({ mode, facts: FACTS })).toMatchSnapshot()
|
|
88
|
+
})
|
|
89
|
+
it(`${mode} (themed, non-navy accent) matches the locked snapshot`, () => {
|
|
90
|
+
expect(renderCertBlock({ mode, theme: THEMED, facts: FACTS })).toMatchSnapshot()
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// LOCKED disclosures — on every claim face
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('the three LOCKED disclosures render on every CLAIM face', () => {
|
|
100
|
+
for (const mode of CLAIM_FACES) {
|
|
101
|
+
it(`${mode}: compensation cue + byte-exact FTC scope line + dated credential verification`, () => {
|
|
102
|
+
const html = renderCertBlock({ mode, facts: FACTS })
|
|
103
|
+
// (1) compensation cue
|
|
104
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
105
|
+
// (2) FTC scope line — byte-exact, including the load-bearing curly apostrophe U+2019
|
|
106
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
107
|
+
expect(CERT_SCOPE_LINE).toContain('’')
|
|
108
|
+
// (3) dated credential verification
|
|
109
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
110
|
+
expect(html).toContain('on Apr 14, 2026')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it(`${mode}: the credential + its dated verification stay GROUPED, in order`, () => {
|
|
114
|
+
const html = renderCertBlock({ mode, facts: FACTS })
|
|
115
|
+
const iName = html.indexOf('Jane Doe, MD')
|
|
116
|
+
const iAttribution = html.indexOf(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
117
|
+
const iDate = html.indexOf('on Apr 14, 2026')
|
|
118
|
+
expect(iName).toBeGreaterThanOrEqual(0)
|
|
119
|
+
expect(iAttribution).toBeGreaterThan(iName)
|
|
120
|
+
expect(iDate).toBeGreaterThan(iAttribution)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it(`${mode}: the disclosures survive even when the caller omits the locked strings`, () => {
|
|
124
|
+
// Omit compensationCue / scopeLine / credentialLine — they default to the
|
|
125
|
+
// byte-verbatim mirrored constants (the compliance baseline is never dropped).
|
|
126
|
+
const html = renderCertBlock({ mode, facts: FACTS })
|
|
127
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
128
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
129
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('the caller-supplied locked strings are rendered verbatim when provided', () => {
|
|
134
|
+
const html = renderCertBlock({
|
|
135
|
+
mode: 'banner',
|
|
136
|
+
facts: {
|
|
137
|
+
...FACTS,
|
|
138
|
+
compensationCue: COMPENSATED_EXPERT_CUE,
|
|
139
|
+
scopeLine: CERT_SCOPE_LINE,
|
|
140
|
+
credentialLine: CREDENTIAL_VERIFIED_ATTRIBUTION,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
144
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('the floating pill is a modal pointer — it makes no claim (no disclosures)', () => {
|
|
148
|
+
const html = renderCertBlock({ mode: 'floating', facts: FACTS })
|
|
149
|
+
expect(html).toContain('Expert reviewed:')
|
|
150
|
+
expect(html).not.toContain(CERT_SCOPE_LINE)
|
|
151
|
+
expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
152
|
+
expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
|
|
153
|
+
// but it still links to the two modal surfaces
|
|
154
|
+
expect(html).toContain('data-certrev-modal-open="expert"')
|
|
155
|
+
expect(html).toContain('data-certrev-modal-open ')
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
// Theming — CSS vars, fail-safe, fixed chrome
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('theming', () => {
|
|
164
|
+
it('un-themed: the root carries the navy default vars + the strings-version stamp', () => {
|
|
165
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
166
|
+
expect(html).toContain('--ba:#0a1b3f')
|
|
167
|
+
expect(html).toContain('--ba-fg:#ffffff')
|
|
168
|
+
expect(html).toContain('--br:14px')
|
|
169
|
+
expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.sans}`)
|
|
170
|
+
expect(html).toContain('--surface:#ffffff')
|
|
171
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('themed: the resolved tokens reach the root as CSS vars', () => {
|
|
175
|
+
const html = renderCertBlock({ mode: 'sidebar', theme: THEMED, facts: FACTS })
|
|
176
|
+
expect(html).toContain('--ba:#E8603C')
|
|
177
|
+
expect(html).toContain('--ba-fg:#141414')
|
|
178
|
+
expect(html).toContain('--br:22px')
|
|
179
|
+
expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.grotesk}`)
|
|
180
|
+
expect(html).toContain('--surface:#faf8f4')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('an invalid token fails SAFE to the navy default (never emitted into CSS)', () => {
|
|
184
|
+
const html = renderCertBlock({
|
|
185
|
+
mode: 'banner',
|
|
186
|
+
// injection attempts + malformed values across every token
|
|
187
|
+
theme: {
|
|
188
|
+
accentColor: 'red;}</style>',
|
|
189
|
+
surface: '#ffffffaa',
|
|
190
|
+
cornerRadius: 'calc(1px)',
|
|
191
|
+
fontSlot: 'comic-sans' as ResolvedBlockTheme['fontSlot'],
|
|
192
|
+
accentFg: 'blue' as ResolvedBlockTheme['accentFg'],
|
|
193
|
+
},
|
|
194
|
+
facts: FACTS,
|
|
195
|
+
})
|
|
196
|
+
expect(html).not.toContain('red;}')
|
|
197
|
+
expect(html).not.toContain('#ffffffaa')
|
|
198
|
+
// the injected radius is dropped (the card's own `calc(var(--br,14px) * 0.7)` is a fixed literal)
|
|
199
|
+
expect(html).not.toContain('calc(1px)')
|
|
200
|
+
expect(html).not.toContain('comic-sans')
|
|
201
|
+
expect(html).toContain('--ba:#0a1b3f')
|
|
202
|
+
expect(html).toContain('--ba-fg:#ffffff')
|
|
203
|
+
expect(html).toContain('--br:14px')
|
|
204
|
+
expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.sans}`)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('the fixed CertREV logo chrome renders on the banner + sidebar headers and the memo credit', () => {
|
|
208
|
+
const banner = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
209
|
+
const sidebar = renderCertBlock({ mode: 'sidebar', facts: FACTS })
|
|
210
|
+
// header marks track the accent ink (currentColor) + the memo credit mark (fixed navy) + the wordmark face
|
|
211
|
+
expect(banner).toContain('aria-label="CertREV"')
|
|
212
|
+
expect(banner).toContain('<path fill="currentColor"')
|
|
213
|
+
expect(banner).toContain('<path fill="#0a1b3f"')
|
|
214
|
+
expect(banner).toContain('font-family:var(--font-wordmark)')
|
|
215
|
+
expect(sidebar).toContain('aria-label="CertREV"')
|
|
216
|
+
expect(sidebar).toContain('<path fill="currentColor"')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
// Escaping — storefront HTML built by hand
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe('escaping (this is storefront HTML)', () => {
|
|
225
|
+
const HOSTILE = 'Ev<script>il & "quoted" \'x\''
|
|
226
|
+
const hostileFacts: CertBlockFacts = {
|
|
227
|
+
...FACTS,
|
|
228
|
+
authorName: HOSTILE,
|
|
229
|
+
authorTitle: HOSTILE,
|
|
230
|
+
reviewerName: HOSTILE,
|
|
231
|
+
credential: HOSTILE,
|
|
232
|
+
memo: HOSTILE,
|
|
233
|
+
bio: HOSTILE,
|
|
234
|
+
profileUrl: 'javascript:alert(1)',
|
|
235
|
+
certificateUrl: 'javascript:alert(2)',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const mode of CLAIM_FACES) {
|
|
239
|
+
it(`${mode}: every interpolated fact value is escaped; no raw injection survives`, () => {
|
|
240
|
+
const html = renderCertBlock({ mode, facts: hostileFacts })
|
|
241
|
+
// the injected markup never lands as live HTML
|
|
242
|
+
expect(html).not.toContain('<script>il')
|
|
243
|
+
expect(html).toContain('<script>')
|
|
244
|
+
expect(html).toContain('&')
|
|
245
|
+
expect(html).toContain('"')
|
|
246
|
+
expect(html).toContain(''')
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const mode of MODES) {
|
|
251
|
+
it(`${mode}: an unsafe URL scheme is dropped from every href`, () => {
|
|
252
|
+
const html = renderCertBlock({ mode, facts: hostileFacts })
|
|
253
|
+
expect(html).not.toContain('javascript:')
|
|
254
|
+
// the modal hook + a safe fallback href remain
|
|
255
|
+
expect(html).toContain('data-certrev-modal-open')
|
|
256
|
+
expect(html).toContain('href="#"')
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
})
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS6 — design-guide render-def support (themeable tokens + subtractive visibility)
|
|
3
|
+
* + the two always-on compliance strings.
|
|
4
|
+
*
|
|
5
|
+
* Two planes, both exercised through the SSR paths (React `renderToStaticMarkup` +
|
|
6
|
+
* the framework-agnostic `renderBadgeHtml` string renderer — the crawlable output):
|
|
7
|
+
* 1. the PURE resolution helpers (validators, `resolveRenderTheme`, visibility,
|
|
8
|
+
* the fixed font stacks, the byte-verbatim strings);
|
|
9
|
+
* 2. the RENDERED faces — a themed def emits the four `--certrev-*` vars + the
|
|
10
|
+
* def-version stamp + subtractive visibility; NO def is theme-byte-identical
|
|
11
|
+
* to the un-themed default; an invalid token is DROPPED (fail-safe); and the
|
|
12
|
+
* compensation cue + FTC scope line + strings-version stamp land on EVERY
|
|
13
|
+
* claim face (the compliance baseline, with or without a def).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
import { CertBadge } from '../components/CertBadge.js'
|
|
19
|
+
import { CertReview } from '../components/CertReview.js'
|
|
20
|
+
import {
|
|
21
|
+
CERT_SCOPE_LINE,
|
|
22
|
+
CERT_STRINGS_VERSION,
|
|
23
|
+
type CertRenderDef,
|
|
24
|
+
COMPENSATED_EXPERT_CUE,
|
|
25
|
+
type FontSlot,
|
|
26
|
+
FONT_STACKS,
|
|
27
|
+
isCornerRadius,
|
|
28
|
+
isFieldHidden,
|
|
29
|
+
isFontSlot,
|
|
30
|
+
isOpaqueHexColor,
|
|
31
|
+
resolveRenderTheme,
|
|
32
|
+
themeCssVarDeclarations,
|
|
33
|
+
} from '../components/render-def.js'
|
|
34
|
+
import { makeMockPayload } from '../contract/fixtures.js'
|
|
35
|
+
import { renderBadgeHtml } from '../webcomponent/render-badge-html.js'
|
|
36
|
+
|
|
37
|
+
const payload = makeMockPayload()
|
|
38
|
+
|
|
39
|
+
/** A fully-themed, valid render-def (every token overridden, both fields hidden). */
|
|
40
|
+
const THEMED: CertRenderDef = {
|
|
41
|
+
tokens: { accentColor: '#123456', surface: '#eeeeee', cornerRadius: '8px', fontSlot: 'serif' },
|
|
42
|
+
hidden: ['reviewerPhoto', 'memo'],
|
|
43
|
+
version: 4,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tag-strip a fragment of HTML to its visible text (collapse whitespace). */
|
|
47
|
+
function visibleText(html: string): string {
|
|
48
|
+
return html
|
|
49
|
+
.replace(/<[^>]+>/g, ' ')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Pure helpers
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('token validators (mirror portal src/lib/design-guide/tokens.ts)', () => {
|
|
59
|
+
it('isOpaqueHexColor accepts 3/6-digit hex, rejects alpha / functions / keywords', () => {
|
|
60
|
+
for (const ok of ['#fff', '#FFFFFF', '#0a1b3f', '#123']) expect(isOpaqueHexColor(ok)).toBe(true)
|
|
61
|
+
for (const bad of ['#ffff', '#ffffffaa', '#12', '#1234567', 'transparent', 'rgb(0,0,0)', 'red', '', ' #fff'])
|
|
62
|
+
expect(isOpaqueHexColor(bad)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('isCornerRadius accepts a bare 0 or a single unit length, rejects unitless / calc / spaces', () => {
|
|
66
|
+
for (const ok of ['0', '12px', '0.5rem', '.5em', '100%', '4px']) expect(isCornerRadius(ok)).toBe(true)
|
|
67
|
+
for (const bad of ['12', '12 px', 'calc(1px)', '1px 2px', '', 'px', '-4px']) expect(isCornerRadius(bad)).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('isFontSlot admits only the three enum members', () => {
|
|
71
|
+
for (const ok of ['sans', 'serif', 'system']) expect(isFontSlot(ok)).toBe(true)
|
|
72
|
+
for (const bad of ['mono', 'Arial', '', 'SANS']) expect(isFontSlot(bad)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('FONT_STACKS — byte-identical to the Shopify Liquid engine literals', () => {
|
|
77
|
+
it('matches the exact stacks (the parity anchor across the twins)', () => {
|
|
78
|
+
expect(FONT_STACKS.sans).toBe("'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif")
|
|
79
|
+
expect(FONT_STACKS.serif).toBe("'Playfair Display', Georgia, 'Times New Roman', serif")
|
|
80
|
+
expect(FONT_STACKS.system).toBe("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('resolveRenderTheme — validates + drops (fail-safe)', () => {
|
|
85
|
+
it('returns {} for an absent def', () => {
|
|
86
|
+
expect(resolveRenderTheme(undefined)).toEqual({})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('resolves every valid token + the fontSlot → fixed stack + the version', () => {
|
|
90
|
+
expect(resolveRenderTheme(THEMED)).toEqual({
|
|
91
|
+
accent: '#123456',
|
|
92
|
+
surface: '#eeeeee',
|
|
93
|
+
cornerRadius: '8px',
|
|
94
|
+
fontStack: FONT_STACKS.serif,
|
|
95
|
+
version: 4,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('DROPS every invalid token (alpha surface, function accent, calc radius, unknown slot)', () => {
|
|
100
|
+
const resolved = resolveRenderTheme({
|
|
101
|
+
tokens: {
|
|
102
|
+
accentColor: 'rgb(0,0,0)',
|
|
103
|
+
surface: '#ffffffaa',
|
|
104
|
+
cornerRadius: 'calc(1px)',
|
|
105
|
+
fontSlot: 'mono' as FontSlot,
|
|
106
|
+
},
|
|
107
|
+
version: 2,
|
|
108
|
+
})
|
|
109
|
+
expect(resolved).toEqual({
|
|
110
|
+
accent: undefined,
|
|
111
|
+
surface: undefined,
|
|
112
|
+
cornerRadius: undefined,
|
|
113
|
+
fontStack: undefined,
|
|
114
|
+
version: 2,
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('drops a non-finite version', () => {
|
|
119
|
+
expect(resolveRenderTheme({ version: Number.NaN }).version).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('subtractive visibility + var declarations', () => {
|
|
124
|
+
it('isFieldHidden is true only for a listed field', () => {
|
|
125
|
+
expect(isFieldHidden({ hidden: ['memo'] }, 'memo')).toBe(true)
|
|
126
|
+
expect(isFieldHidden({ hidden: ['memo'] }, 'reviewerPhoto')).toBe(false)
|
|
127
|
+
expect(isFieldHidden(undefined, 'memo')).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('themeCssVarDeclarations is empty for an un-themed face and ordered surface→radius→font', () => {
|
|
131
|
+
expect(themeCssVarDeclarations({})).toEqual([])
|
|
132
|
+
expect(themeCssVarDeclarations(resolveRenderTheme(THEMED))).toEqual([
|
|
133
|
+
['--certrev-surface', '#eeeeee'],
|
|
134
|
+
['--certrev-radius', '8px'],
|
|
135
|
+
['--certrev-font', FONT_STACKS.serif],
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('the two always-on compliance strings are byte-verbatim', () => {
|
|
141
|
+
it('cue + strings-version', () => {
|
|
142
|
+
expect(COMPENSATED_EXPERT_CUE).toBe('Compensated expert')
|
|
143
|
+
expect(CERT_STRINGS_VERSION).toBe('2026-07')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('the scope line carries the load-bearing curly apostrophe U+2019', () => {
|
|
147
|
+
expect(CERT_SCOPE_LINE).toBe(
|
|
148
|
+
'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
|
|
149
|
+
)
|
|
150
|
+
expect(CERT_SCOPE_LINE).toContain('’')
|
|
151
|
+
expect(CERT_SCOPE_LINE).not.toContain("'") // straight apostrophe would be a contract divergence
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
// Rendered — React <CertBadge>
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('<CertBadge> themed', () => {
|
|
160
|
+
it('emits the four --certrev-* vars, the def-version stamp, and hides subtracted fields', () => {
|
|
161
|
+
const html = renderToStaticMarkup(<CertBadge payload={payload} renderDef={THEMED} />)
|
|
162
|
+
expect(html).toContain('--certrev-accent:#123456')
|
|
163
|
+
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
164
|
+
expect(html).toContain('--certrev-radius:8px')
|
|
165
|
+
// React entity-encodes the font stack's single quotes; the family content is intact.
|
|
166
|
+
expect(html).toContain('--certrev-font:')
|
|
167
|
+
expect(html).toContain('Playfair Display')
|
|
168
|
+
expect(html).toContain('data-certrev-def-version="4"')
|
|
169
|
+
// subtractive visibility: reviewerPhoto + memo hidden
|
|
170
|
+
expect(html).not.toContain('__photo')
|
|
171
|
+
expect(html).not.toContain('retinol claims')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('an unthemed badge emits NO theme vars + NO def-version stamp (theme byte-neutral)', () => {
|
|
175
|
+
const html = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
176
|
+
expect(html).not.toContain('--certrev-surface')
|
|
177
|
+
expect(html).not.toContain('--certrev-radius')
|
|
178
|
+
expect(html).not.toContain('--certrev-font')
|
|
179
|
+
expect(html).not.toContain('data-certrev-def-version')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('compliance baseline — cue + scope + strings-version on EVERY claim face', () => {
|
|
184
|
+
it('full + unthemed: cue, verbatim scope line, and the strings-version stamp all present', () => {
|
|
185
|
+
const html = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
186
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
187
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
188
|
+
expect(html).toContain('data-ftc-line')
|
|
189
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('compact ALSO carries the cue + scope (a compact claim badge is still a claim face)', () => {
|
|
193
|
+
const html = renderToStaticMarkup(<CertBadge payload={payload} badgeStyle="compact" />)
|
|
194
|
+
expect(html).toContain('certrev-badge--compact')
|
|
195
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
196
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('the cue links "expert" to the reviewer profile; falls back to plain text when unsafe', () => {
|
|
200
|
+
const linked = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
201
|
+
expect(linked).toContain('>expert</a>')
|
|
202
|
+
const p = makeMockPayload({
|
|
203
|
+
content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
|
|
204
|
+
})
|
|
205
|
+
const unlinked = renderToStaticMarkup(<CertBadge payload={p} />)
|
|
206
|
+
expect(unlinked).not.toContain('javascript:')
|
|
207
|
+
expect(visibleText(unlinked)).toContain(COMPENSATED_EXPERT_CUE)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('<CertReview> forwards the render-def to the badge', () => {
|
|
212
|
+
it('a themed def reaches the badge; the JSON-LD stays presentation-blind', () => {
|
|
213
|
+
const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'render', payload }} renderDef={THEMED} />)
|
|
214
|
+
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
215
|
+
expect(html).toContain('data-certrev-def-version="4"')
|
|
216
|
+
// the projector never sees the def — the graph is byte-unchanged
|
|
217
|
+
expect(html).toContain('application/ld+json')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
// Rendered — the framework-agnostic string path (renderBadgeHtml)
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe('renderBadgeHtml themed (the crawlable string path)', () => {
|
|
226
|
+
it('emits each --certrev-* var exactly once; the font stack is the RAW fixed literal (byte-exact)', () => {
|
|
227
|
+
const html = renderBadgeHtml(payload, { renderDef: THEMED })
|
|
228
|
+
expect(html).toContain('--certrev-accent:#123456')
|
|
229
|
+
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
230
|
+
expect(html).toContain('--certrev-radius:8px')
|
|
231
|
+
expect(html).toContain(`--certrev-font:${FONT_STACKS.serif}`) // byte-exact, single quotes raw
|
|
232
|
+
for (const v of ['--certrev-accent:', '--certrev-surface:', '--certrev-radius:', '--certrev-font:'])
|
|
233
|
+
expect((html.match(new RegExp(v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? []).length).toBe(1)
|
|
234
|
+
expect(html).toContain('data-certrev-def-version="4"')
|
|
235
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
236
|
+
expect(html).not.toContain('__photo') // reviewerPhoto hidden
|
|
237
|
+
expect(html).not.toContain('__memo') // memo hidden
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('the compliance baseline is present: verbatim scope line + the cue', () => {
|
|
241
|
+
const html = renderBadgeHtml(payload, { renderDef: THEMED })
|
|
242
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
243
|
+
expect(html).toContain('data-ftc-line')
|
|
244
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('DROPS every invalid token (fail-safe to preset default); no injection reaches the style', () => {
|
|
248
|
+
const html = renderBadgeHtml(payload, {
|
|
249
|
+
renderDef: {
|
|
250
|
+
tokens: {
|
|
251
|
+
accentColor: 'rgb(0,0,0)',
|
|
252
|
+
surface: '#ffffffaa',
|
|
253
|
+
cornerRadius: 'calc(1px)',
|
|
254
|
+
fontSlot: 'mono' as FontSlot,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
expect(html).not.toContain('rgb(0,0,0)')
|
|
259
|
+
expect(html).not.toContain('#ffffffaa')
|
|
260
|
+
expect(html).not.toContain('calc(')
|
|
261
|
+
expect(html).not.toContain('--certrev-surface')
|
|
262
|
+
expect(html).not.toContain('--certrev-radius')
|
|
263
|
+
expect(html).not.toContain('--certrev-font')
|
|
264
|
+
// the invalid accent fell back to the signed display accent (never the rgb)
|
|
265
|
+
expect(html).toContain('--certrev-accent:#7c3aed')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('un-themed: theme output is BYTE-IDENTICAL to the pre-WS6 single-accent form', () => {
|
|
269
|
+
const html = renderBadgeHtml(payload)
|
|
270
|
+
expect(html).toContain('style="--certrev-accent:#7c3aed;border-inline-start-color:#7c3aed"')
|
|
271
|
+
expect(html).not.toContain('--certrev-surface')
|
|
272
|
+
expect(html).not.toContain('data-certrev-def-version')
|
|
273
|
+
// baseline compliance strings still land on the un-themed face
|
|
274
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
275
|
+
expect(html).toContain(CERT_SCOPE_LINE)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('renders the cue as plain text when the profile URL is unsafe/absent', () => {
|
|
279
|
+
const p = makeMockPayload({
|
|
280
|
+
content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
|
|
281
|
+
})
|
|
282
|
+
const html = renderBadgeHtml(p)
|
|
283
|
+
expect(html).not.toContain('javascript:')
|
|
284
|
+
expect(html).toContain('Compensated expert')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
@@ -71,6 +71,16 @@ describe('renderBadgeHtml (the shared, framework-agnostic markup)', () => {
|
|
|
71
71
|
expect(html).not.toContain('__memo')
|
|
72
72
|
expect(html).not.toContain('__dates')
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
it('carries the always-on compliance baseline (cue + verbatim scope line + strings-version) on every face', () => {
|
|
76
|
+
const html = renderBadgeHtml(payload)
|
|
77
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
78
|
+
expect(html).toContain(
|
|
79
|
+
'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
|
|
80
|
+
)
|
|
81
|
+
expect(html).toContain('data-ftc-line')
|
|
82
|
+
expect(html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')).toContain('Compensated expert')
|
|
83
|
+
})
|
|
74
84
|
})
|
|
75
85
|
|
|
76
86
|
describe('<certrev-badge> custom element', () => {
|