@certrev/cert-block 0.1.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 +154 -0
- package/dist/components/CertBadge.d.ts +29 -0
- package/dist/components/CertBadge.d.ts.map +1 -0
- package/dist/components/CertBadge.js +36 -0
- package/dist/components/CertBadge.js.map +1 -0
- package/dist/components/CertJsonLd.d.ts +23 -0
- package/dist/components/CertJsonLd.d.ts.map +1 -0
- package/dist/components/CertJsonLd.js +10 -0
- package/dist/components/CertJsonLd.js.map +1 -0
- package/dist/components/CertRevBacklink.d.ts +18 -0
- package/dist/components/CertRevBacklink.d.ts.map +1 -0
- package/dist/components/CertRevBacklink.js +16 -0
- package/dist/components/CertRevBacklink.js.map +1 -0
- package/dist/components/CertReview.d.ts +23 -0
- package/dist/components/CertReview.d.ts.map +1 -0
- package/dist/components/CertReview.js +11 -0
- package/dist/components/CertReview.js.map +1 -0
- package/dist/components/ExpertBio.d.ts +17 -0
- package/dist/components/ExpertBio.d.ts.map +1 -0
- package/dist/components/ExpertBio.js +17 -0
- package/dist/components/ExpertBio.js.map +1 -0
- package/dist/components/escape.d.ts +36 -0
- package/dist/components/escape.d.ts.map +1 -0
- package/dist/components/escape.js +76 -0
- package/dist/components/escape.js.map +1 -0
- package/dist/components/format.d.ts +22 -0
- package/dist/components/format.d.ts.map +1 -0
- package/dist/components/format.js +42 -0
- package/dist/components/format.js.map +1 -0
- package/dist/contract/fixtures.d.ts +36 -0
- package/dist/contract/fixtures.d.ts.map +1 -0
- package/dist/contract/fixtures.js +87 -0
- package/dist/contract/fixtures.js.map +1 -0
- package/dist/contract/kernel-contract.d.ts +154 -0
- package/dist/contract/kernel-contract.d.ts.map +1 -0
- package/dist/contract/kernel-contract.js +35 -0
- package/dist/contract/kernel-contract.js.map +1 -0
- package/dist/contract/kernel-stub.d.ts +44 -0
- package/dist/contract/kernel-stub.d.ts.map +1 -0
- package/dist/contract/kernel-stub.js +163 -0
- package/dist/contract/kernel-stub.js.map +1 -0
- package/dist/contract/kernel.d.ts +20 -0
- package/dist/contract/kernel.d.ts.map +1 -0
- package/dist/contract/kernel.js +19 -0
- package/dist/contract/kernel.js.map +1 -0
- package/dist/contract/verdict-kernel.d.ts +34 -0
- package/dist/contract/verdict-kernel.d.ts.map +1 -0
- package/dist/contract/verdict-kernel.js +13 -0
- package/dist/contract/verdict-kernel.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonld/project.d.ts +71 -0
- package/dist/jsonld/project.d.ts.map +1 -0
- package/dist/jsonld/project.js +183 -0
- package/dist/jsonld/project.js.map +1 -0
- package/dist/verify/cache.d.ts +56 -0
- package/dist/verify/cache.d.ts.map +1 -0
- package/dist/verify/cache.js +93 -0
- package/dist/verify/cache.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +65 -0
- package/dist/verify/get-verified-envelope.d.ts.map +1 -0
- package/dist/verify/get-verified-envelope.js +104 -0
- package/dist/verify/get-verified-envelope.js.map +1 -0
- package/dist/verify/resolve-kid.d.ts +38 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -0
- package/dist/verify/resolve-kid.js +71 -0
- package/dist/verify/resolve-kid.js.map +1 -0
- package/dist/webcomponent/certrev-badge.d.ts +38 -0
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -0
- package/dist/webcomponent/certrev-badge.js +98 -0
- package/dist/webcomponent/certrev-badge.js.map +1 -0
- package/dist/webcomponent/render-badge-html.d.ts +25 -0
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -0
- package/dist/webcomponent/render-badge-html.js +81 -0
- package/dist/webcomponent/render-badge-html.js.map +1 -0
- package/package.json +70 -0
- package/src/__tests__/components.test.tsx +191 -0
- package/src/__tests__/project.test.ts +128 -0
- package/src/__tests__/verify.test.ts +203 -0
- package/src/__tests__/webcomponent.test.tsx +106 -0
- package/src/components/CertBadge.tsx +164 -0
- package/src/components/CertJsonLd.tsx +36 -0
- package/src/components/CertRevBacklink.tsx +63 -0
- package/src/components/CertReview.tsx +42 -0
- package/src/components/ExpertBio.tsx +77 -0
- package/src/components/escape.ts +72 -0
- package/src/components/format.ts +55 -0
- package/src/contract/fixtures.ts +107 -0
- package/src/contract/kernel.ts +20 -0
- package/src/contract/verdict-kernel.ts +47 -0
- package/src/index.ts +85 -0
- package/src/jsonld/project.ts +206 -0
- package/src/verify/cache.ts +116 -0
- package/src/verify/get-verified-envelope.ts +156 -0
- package/src/verify/resolve-kid.ts +103 -0
- package/src/webcomponent/certrev-badge.ts +100 -0
- package/src/webcomponent/render-badge-html.ts +106 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Web Component + string-renderer tests.
|
|
4
|
+
*
|
|
5
|
+
* `renderBadgeHtml` is the framework-agnostic markup source shared by <CertBadge> (it
|
|
6
|
+
* mirrors the JSX) and the <certrev-badge> custom element. We assert it produces the same
|
|
7
|
+
* crawlable structure, hand-escapes every interpolated field, and drops unsafe URLs — then
|
|
8
|
+
* verify the custom element registers, honors a server-rendered light-DOM child (SSR mode),
|
|
9
|
+
* and fails closed in client mode without a configured key resolver.
|
|
10
|
+
*
|
|
11
|
+
* jsdom is opted into per-file (the rest of the suite runs in node) for `HTMLElement` +
|
|
12
|
+
* `customElements`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { makeMockPayload } from '../contract/fixtures.js'
|
|
17
|
+
import {
|
|
18
|
+
CERTREV_BADGE_TAG,
|
|
19
|
+
CertRevBadgeElement,
|
|
20
|
+
defineCertRevBadge,
|
|
21
|
+
setCertRevKidResolver,
|
|
22
|
+
} from '../webcomponent/certrev-badge.js'
|
|
23
|
+
import { renderBadgeHtml } from '../webcomponent/render-badge-html.js'
|
|
24
|
+
|
|
25
|
+
const payload = makeMockPayload()
|
|
26
|
+
|
|
27
|
+
describe('renderBadgeHtml (the shared, framework-agnostic markup)', () => {
|
|
28
|
+
it('renders the same crawlable structure as <CertBadge>: section, expert, credentials, verify link', () => {
|
|
29
|
+
const html = renderBadgeHtml(payload)
|
|
30
|
+
expect(html).toMatch(/^<section class="certrev-badge certrev-badge--full"/)
|
|
31
|
+
expect(html).toContain('Dr. Jane Doe')
|
|
32
|
+
expect(html).toContain('MD, FAAD')
|
|
33
|
+
expect(html).toContain('Verify on CertREV')
|
|
34
|
+
expect(html).toContain('data-certrev-cert-id="cert_fixture_001"')
|
|
35
|
+
expect(html).toContain('aria-label="Content reviewed by Dr. Jane Doe, MD, FAAD"')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('HAND-ESCAPES a hostile display name (no React here — the string path must escape itself)', () => {
|
|
39
|
+
const p = makeMockPayload({
|
|
40
|
+
content: {
|
|
41
|
+
...payload.content,
|
|
42
|
+
expert: { ...payload.content.expert, displayName: '<img src=x onerror=alert(1)>' },
|
|
43
|
+
memo: null,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
const html = renderBadgeHtml(p)
|
|
47
|
+
expect(html).not.toContain('<img src=x')
|
|
48
|
+
expect(html).toContain('<img src=x onerror=alert(1)>')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('DROPS a javascript: verify URL (safeHttpUrl) — no verify anchor emitted', () => {
|
|
52
|
+
const p = makeMockPayload({ content: { ...payload.content, verifyUrl: 'javascript:alert(1)' } })
|
|
53
|
+
const html = renderBadgeHtml(p)
|
|
54
|
+
expect(html).not.toContain('javascript:')
|
|
55
|
+
expect(html).not.toContain('__verify')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects an unsafe accentColor and falls back to the default token', () => {
|
|
59
|
+
const p = makeMockPayload({
|
|
60
|
+
content: { ...payload.content, display: { ...payload.content.display, accentColor: 'url(evil)' } },
|
|
61
|
+
})
|
|
62
|
+
const html = renderBadgeHtml(p)
|
|
63
|
+
expect(html).not.toContain('url(evil)')
|
|
64
|
+
expect(html).toContain('--certrev-accent:#0f766e')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('compact style omits the photo, memo, and dates', () => {
|
|
68
|
+
const html = renderBadgeHtml(payload, { badgeStyle: 'compact' })
|
|
69
|
+
expect(html).toContain('certrev-badge--compact')
|
|
70
|
+
expect(html).not.toContain('__photo')
|
|
71
|
+
expect(html).not.toContain('__memo')
|
|
72
|
+
expect(html).not.toContain('__dates')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('<certrev-badge> custom element', () => {
|
|
77
|
+
it('registers idempotently under its tag', () => {
|
|
78
|
+
defineCertRevBadge()
|
|
79
|
+
defineCertRevBadge() // second call is a no-op, must not throw
|
|
80
|
+
expect(customElements.get(CERTREV_BADGE_TAG)).toBe(CertRevBadgeElement)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('SSR mode: leaves a server-rendered light-DOM badge child untouched on connect', () => {
|
|
84
|
+
defineCertRevBadge()
|
|
85
|
+
const host = document.createElement(CERTREV_BADGE_TAG)
|
|
86
|
+
host.innerHTML = renderBadgeHtml(payload) // server already rendered the badge inside
|
|
87
|
+
document.body.appendChild(host)
|
|
88
|
+
// connectedCallback saw an existing .certrev-badge child → must not wipe/replace it.
|
|
89
|
+
expect(host.querySelector('.certrev-badge')).not.toBeNull()
|
|
90
|
+
expect(host.innerHTML).toContain('Dr. Jane Doe')
|
|
91
|
+
host.remove()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('client mode FAIL-CLOSED: with no key resolver configured it renders nothing', () => {
|
|
95
|
+
setCertRevKidResolver(null as never) // explicitly unconfigured
|
|
96
|
+
defineCertRevBadge()
|
|
97
|
+
const host = document.createElement(CERTREV_BADGE_TAG)
|
|
98
|
+
host.setAttribute('delivery-api', 'https://portal.certrev.com')
|
|
99
|
+
host.setAttribute('platform', 'shopify')
|
|
100
|
+
host.setAttribute('external-id', 'gid://shopify/Article/1')
|
|
101
|
+
document.body.appendChild(host)
|
|
102
|
+
// No resolver → cannot verify → renders nothing (fail-closed). No badge child appears.
|
|
103
|
+
expect(host.querySelector('.certrev-badge')).toBeNull()
|
|
104
|
+
host.remove()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CertBadge> — the visible "Reviewed by a credentialed expert" badge.
|
|
3
|
+
*
|
|
4
|
+
* SSR-SAFE: a pure render component. No `useState`/`useEffect`/`useRef`, no browser-only
|
|
5
|
+
* calls, no event handlers — so it works as a React Server Component AND as a client
|
|
6
|
+
* component that SSRs identically (the crawlable output is the same either way). All
|
|
7
|
+
* presentation comes from `payload.content.display`; all text is React-escaped; every
|
|
8
|
+
* URL passes through `safeHttpUrl`.
|
|
9
|
+
*
|
|
10
|
+
* ACCESSIBILITY: rendered as a <section> with an accessible name, the accent applied via
|
|
11
|
+
* an inline custom property (no color-only meaning — the credential text carries it), an
|
|
12
|
+
* expert photo with meaningful alt text, and a verify link with a descriptive label.
|
|
13
|
+
*
|
|
14
|
+
* `badgeStyle: 'compact'` collapses to a single line (name + credentials + verify link);
|
|
15
|
+
* `'full'` shows the photo, certified/updated dates, and the memo (per display flags).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CertContent, CertPayload } from '../contract/kernel.js'
|
|
19
|
+
import { safeHttpUrl } from './escape.js'
|
|
20
|
+
import { credentialSuffix, expertNameWithCredentials, formatDate, resolveDisplay } from './format.js'
|
|
21
|
+
|
|
22
|
+
export interface CertBadgeProps {
|
|
23
|
+
/** The cryptographically-verified payload (from `getVerifiedEnvelope`). */
|
|
24
|
+
readonly payload: CertPayload
|
|
25
|
+
/** Optional accent override (else `display.accentColor`, else CertREV default). */
|
|
26
|
+
readonly accentColor?: string
|
|
27
|
+
/** Extra class names appended to the root element's class list. */
|
|
28
|
+
readonly className?: string
|
|
29
|
+
/** Override the badge style independent of the signed display config. */
|
|
30
|
+
readonly badgeStyle?: 'full' | 'compact'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ROOT_CLASS = 'certrev-badge'
|
|
34
|
+
|
|
35
|
+
/** Inline-SVG verified checkmark — no external asset, accent-tinted, decorative. */
|
|
36
|
+
function VerifiedMark({ accent }: { accent: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
className={`${ROOT_CLASS}__mark`}
|
|
40
|
+
width="20"
|
|
41
|
+
height="20"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
focusable="false"
|
|
46
|
+
>
|
|
47
|
+
<title>Verified</title>
|
|
48
|
+
<circle cx="12" cy="12" r="11" fill={accent} />
|
|
49
|
+
<path d="M7 12.5l3.2 3.2L17 9" stroke="#ffffff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
50
|
+
</svg>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ExpertPhoto({ content, show }: { content: CertContent; show: boolean }) {
|
|
55
|
+
const src = show ? safeHttpUrl(content.expert.photoUrl) : null
|
|
56
|
+
if (!src) return null
|
|
57
|
+
return (
|
|
58
|
+
<img
|
|
59
|
+
className={`${ROOT_CLASS}__photo`}
|
|
60
|
+
src={src}
|
|
61
|
+
alt={`Photo of ${content.expert.displayName}`}
|
|
62
|
+
width={40}
|
|
63
|
+
height={40}
|
|
64
|
+
loading="lazy"
|
|
65
|
+
decoding="async"
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function CertBadge(props: CertBadgeProps) {
|
|
71
|
+
const { payload } = props
|
|
72
|
+
const content = payload.content
|
|
73
|
+
const display = resolveDisplay(content.display, props.accentColor)
|
|
74
|
+
const style = props.badgeStyle ?? display.badgeStyle
|
|
75
|
+
const verifyUrl = safeHttpUrl(content.verifyUrl)
|
|
76
|
+
const profileUrl = safeHttpUrl(content.expert.profileUrl)
|
|
77
|
+
const certifiedLabel = formatDate(content.certifiedAt)
|
|
78
|
+
const updatedLabel = formatDate(content.contentModifiedAt)
|
|
79
|
+
const suffix = credentialSuffix(content)
|
|
80
|
+
const accent = display.accentColor
|
|
81
|
+
|
|
82
|
+
const rootClass = `${ROOT_CLASS} ${ROOT_CLASS}--${style}${props.className ? ` ${props.className}` : ''}`
|
|
83
|
+
// Accent is exposed as a custom property so brand themes can read it, and applied
|
|
84
|
+
// directly to the accent border so the component is self-styling without a stylesheet.
|
|
85
|
+
const rootStyle: Record<string, string> = {
|
|
86
|
+
['--certrev-accent']: accent,
|
|
87
|
+
borderInlineStartColor: accent,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const expertName = (
|
|
91
|
+
<span className={`${ROOT_CLASS}__expert-name`}>
|
|
92
|
+
{content.expert.displayName}
|
|
93
|
+
{suffix ? <span className={`${ROOT_CLASS}__credentials`}>, {suffix}</span> : null}
|
|
94
|
+
</span>
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<section
|
|
99
|
+
className={rootClass}
|
|
100
|
+
style={rootStyle}
|
|
101
|
+
data-certrev-cert-id={payload.certId}
|
|
102
|
+
aria-label={`Content reviewed by ${expertNameWithCredentials(content)}`}
|
|
103
|
+
>
|
|
104
|
+
<div className={`${ROOT_CLASS}__header`}>
|
|
105
|
+
<VerifiedMark accent={accent} />
|
|
106
|
+
{style === 'full' ? <ExpertPhoto content={content} show={display.showExpertPhoto} /> : null}
|
|
107
|
+
<div className={`${ROOT_CLASS}__heading`}>
|
|
108
|
+
<span className={`${ROOT_CLASS}__eyebrow`}>Expert reviewed</span>
|
|
109
|
+
<span className={`${ROOT_CLASS}__byline`}>
|
|
110
|
+
Reviewed by{' '}
|
|
111
|
+
{profileUrl ? (
|
|
112
|
+
<a className={`${ROOT_CLASS}__expert-link`} href={profileUrl} rel="noopener">
|
|
113
|
+
{expertName}
|
|
114
|
+
</a>
|
|
115
|
+
) : (
|
|
116
|
+
expertName
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{style === 'full' ? (
|
|
123
|
+
<>
|
|
124
|
+
{display.showMemo && content.memo ? <p className={`${ROOT_CLASS}__memo`}>{content.memo}</p> : null}
|
|
125
|
+
{display.showAuthor && content.author.name && content.author.name !== content.expert.displayName ? (
|
|
126
|
+
<p className={`${ROOT_CLASS}__author`}>
|
|
127
|
+
Written by {content.author.name}
|
|
128
|
+
{content.author.title ? `, ${content.author.title}` : ''}
|
|
129
|
+
</p>
|
|
130
|
+
) : null}
|
|
131
|
+
<dl className={`${ROOT_CLASS}__dates`}>
|
|
132
|
+
{certifiedLabel ? (
|
|
133
|
+
<div className={`${ROOT_CLASS}__date`}>
|
|
134
|
+
<dt>Certified</dt>
|
|
135
|
+
<dd>
|
|
136
|
+
<time dateTime={content.certifiedAt}>{certifiedLabel}</time>
|
|
137
|
+
</dd>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
140
|
+
{updatedLabel ? (
|
|
141
|
+
<div className={`${ROOT_CLASS}__date`}>
|
|
142
|
+
<dt>Last updated</dt>
|
|
143
|
+
<dd>
|
|
144
|
+
<time dateTime={content.contentModifiedAt ?? undefined}>{updatedLabel}</time>
|
|
145
|
+
</dd>
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
</dl>
|
|
149
|
+
</>
|
|
150
|
+
) : null}
|
|
151
|
+
|
|
152
|
+
{verifyUrl ? (
|
|
153
|
+
<a
|
|
154
|
+
className={`${ROOT_CLASS}__verify`}
|
|
155
|
+
href={verifyUrl}
|
|
156
|
+
rel="noopener"
|
|
157
|
+
aria-label="Verify this certification on CertREV"
|
|
158
|
+
>
|
|
159
|
+
Verify on CertREV
|
|
160
|
+
</a>
|
|
161
|
+
) : null}
|
|
162
|
+
</section>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CertJsonLd> — emits the projected schema.org graph as a
|
|
3
|
+
* `<script type="application/ld+json">`.
|
|
4
|
+
*
|
|
5
|
+
* The JSON-LD is PROJECTED from the verified facts (never stored in the envelope — see
|
|
6
|
+
* the projector). SSR-safe: it renders a single <script> whose body is the serialized
|
|
7
|
+
* graph with `<` neutralized so it can't break out of the script tag (standard
|
|
8
|
+
* dangerouslySetInnerHTML-for-JSON-LD pattern; the content is our own deterministic
|
|
9
|
+
* serialization of trusted-shape data, not arbitrary HTML).
|
|
10
|
+
*
|
|
11
|
+
* Place it inside the page <head> (Next: in a layout/head; Hydrogen: in the route
|
|
12
|
+
* component — React 19 hoists <script> in <head>) so crawlers read structured data
|
|
13
|
+
* without client JS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CertPayload } from '../contract/kernel.js'
|
|
17
|
+
import { projectCertJsonLd, type ProjectJsonLdOptions, serializeJsonLdForScript } from '../jsonld/project.js'
|
|
18
|
+
|
|
19
|
+
export interface CertJsonLdProps extends ProjectJsonLdOptions {
|
|
20
|
+
readonly payload: CertPayload
|
|
21
|
+
/** Optional id on the <script> so a page can find/replace it deterministically. */
|
|
22
|
+
readonly scriptId?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CertJsonLd(props: CertJsonLdProps) {
|
|
26
|
+
const { payload, scriptId, ...opts } = props
|
|
27
|
+
const json = serializeJsonLdForScript(projectCertJsonLd(payload, opts))
|
|
28
|
+
return (
|
|
29
|
+
<script
|
|
30
|
+
type="application/ld+json"
|
|
31
|
+
id={scriptId}
|
|
32
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD requires raw script body; the content is our deterministic serialization with `<` neutralized (serializeJsonLdForScript), not arbitrary HTML.
|
|
33
|
+
dangerouslySetInnerHTML={{ __html: json }}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CertRevBacklink> — the live "Verify on CertREV" link.
|
|
3
|
+
*
|
|
4
|
+
* The local badge is a bounded-stale snapshot; `content.verifyUrl` is ALWAYS the live
|
|
5
|
+
* source of truth. This standalone link lets brands place the verification entry point
|
|
6
|
+
* wherever they want, independent of the badge. SSR-safe + pure; URL through
|
|
7
|
+
* `safeHttpUrl`. Renders nothing if the verify URL is unsafe/absent (fail-closed).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CertPayload } from '../contract/kernel.js'
|
|
11
|
+
import { safeHttpUrl } from './escape.js'
|
|
12
|
+
import { resolveDisplay } from './format.js'
|
|
13
|
+
|
|
14
|
+
export interface CertRevBacklinkProps {
|
|
15
|
+
readonly payload: CertPayload
|
|
16
|
+
readonly accentColor?: string
|
|
17
|
+
readonly className?: string
|
|
18
|
+
/** Link text (default "Verify on CertREV"). */
|
|
19
|
+
readonly label?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ROOT_CLASS = 'certrev-backlink'
|
|
23
|
+
|
|
24
|
+
export function CertRevBacklink(props: CertRevBacklinkProps) {
|
|
25
|
+
const verifyUrl = safeHttpUrl(props.payload.content.verifyUrl)
|
|
26
|
+
if (!verifyUrl) return null
|
|
27
|
+
const display = resolveDisplay(props.payload.content.display, props.accentColor)
|
|
28
|
+
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`
|
|
29
|
+
// Typed as a string record (not inline) so the `--certrev-accent` CSS custom property
|
|
30
|
+
// is accepted — React's CSSProperties rejects arbitrary custom props on an inline literal.
|
|
31
|
+
const rootStyle: Record<string, string> = { ['--certrev-accent']: display.accentColor, color: display.accentColor }
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<a
|
|
35
|
+
className={rootClass}
|
|
36
|
+
style={rootStyle}
|
|
37
|
+
href={verifyUrl}
|
|
38
|
+
rel="noopener"
|
|
39
|
+
aria-label="Verify this certification on CertREV"
|
|
40
|
+
>
|
|
41
|
+
<svg
|
|
42
|
+
className={`${ROOT_CLASS}__icon`}
|
|
43
|
+
width="14"
|
|
44
|
+
height="14"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
focusable="false"
|
|
49
|
+
>
|
|
50
|
+
<title>Verified</title>
|
|
51
|
+
<path
|
|
52
|
+
d="M12 2l7 3v6c0 4.4-3 8.3-7 9-4-0.7-7-4.6-7-9V5l7-3z"
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
strokeWidth="1.8"
|
|
56
|
+
strokeLinejoin="round"
|
|
57
|
+
/>
|
|
58
|
+
<path d="M8.5 12l2.3 2.3L15.5 9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
|
59
|
+
</svg>
|
|
60
|
+
<span className={`${ROOT_CLASS}__label`}>{props.label ?? 'Verify on CertREV'}</span>
|
|
61
|
+
</a>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CertReview> — the one-line convenience composite.
|
|
3
|
+
*
|
|
4
|
+
* Most brand integrations want "render the badge + the JSON-LD from a verified
|
|
5
|
+
* envelope, or render nothing". This wraps that: pass a `CertVerdict` (the output of
|
|
6
|
+
* `getVerifiedEnvelope` / the kernel) and it renders the badge + projected JSON-LD on
|
|
7
|
+
* `render`, and NOTHING on `suppress` (fail-closed at the component boundary too). The
|
|
8
|
+
* expert bio + backlink remain separately placeable for brands that want finer control.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CertVerdict } from '../contract/kernel.js'
|
|
12
|
+
import { CertBadge } from './CertBadge.js'
|
|
13
|
+
import { CertJsonLd } from './CertJsonLd.js'
|
|
14
|
+
|
|
15
|
+
export interface CertReviewProps {
|
|
16
|
+
/** The verdict from `getVerifiedEnvelope` (or a direct kernel call). */
|
|
17
|
+
readonly verdict: CertVerdict
|
|
18
|
+
/** Canonical page URL for JSON-LD @id alignment (merge into the host article node). */
|
|
19
|
+
readonly pageUrl?: string
|
|
20
|
+
readonly accentColor?: string
|
|
21
|
+
readonly className?: string
|
|
22
|
+
readonly badgeStyle?: 'full' | 'compact'
|
|
23
|
+
/** Omit the JSON-LD (e.g. the page already emits it elsewhere). Default false. */
|
|
24
|
+
readonly omitJsonLd?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CertReview(props: CertReviewProps) {
|
|
28
|
+
// Fail-closed: a suppressed (or any non-render) verdict renders nothing.
|
|
29
|
+
if (props.verdict.decision !== 'render') return null
|
|
30
|
+
const payload = props.verdict.payload
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<CertBadge
|
|
34
|
+
payload={payload}
|
|
35
|
+
accentColor={props.accentColor}
|
|
36
|
+
className={props.className}
|
|
37
|
+
badgeStyle={props.badgeStyle}
|
|
38
|
+
/>
|
|
39
|
+
{props.omitJsonLd ? null : <CertJsonLd payload={payload} pageUrl={props.pageUrl} />}
|
|
40
|
+
</>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ExpertBio> — the reviewing expert's identity block (photo, name, credentials, link).
|
|
3
|
+
*
|
|
4
|
+
* Renders the E-E-A-T-bearing facts about WHO reviewed the content, separate from the
|
|
5
|
+
* compact badge. Brands place this near the article footer / author box. SSR-safe + pure
|
|
6
|
+
* like the rest; every field React-escaped; URLs through `safeHttpUrl`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CertPayload } from '../contract/kernel.js'
|
|
10
|
+
import { safeHttpUrl } from './escape.js'
|
|
11
|
+
import { resolveDisplay } from './format.js'
|
|
12
|
+
|
|
13
|
+
export interface ExpertBioProps {
|
|
14
|
+
readonly payload: CertPayload
|
|
15
|
+
readonly accentColor?: string
|
|
16
|
+
readonly className?: string
|
|
17
|
+
/** Heading level for the expert name (default 'h3') so it slots into the page outline. */
|
|
18
|
+
readonly headingLevel?: 'h2' | 'h3' | 'h4'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ROOT_CLASS = 'certrev-expert-bio'
|
|
22
|
+
|
|
23
|
+
export function ExpertBio(props: ExpertBioProps) {
|
|
24
|
+
const { payload } = props
|
|
25
|
+
const { expert } = payload.content
|
|
26
|
+
const display = resolveDisplay(payload.content.display, props.accentColor)
|
|
27
|
+
const profileUrl = safeHttpUrl(expert.profileUrl)
|
|
28
|
+
const photoUrl = display.showExpertPhoto ? safeHttpUrl(expert.photoUrl) : null
|
|
29
|
+
const Heading = props.headingLevel ?? 'h3'
|
|
30
|
+
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`
|
|
31
|
+
const rootStyle: Record<string, string> = { ['--certrev-accent']: display.accentColor }
|
|
32
|
+
|
|
33
|
+
const nameNode = (
|
|
34
|
+
<Heading className={`${ROOT_CLASS}__name`}>
|
|
35
|
+
{expert.displayName}
|
|
36
|
+
{expert.credentials.length > 0 ? (
|
|
37
|
+
<span className={`${ROOT_CLASS}__credentials`}>, {expert.credentials.map((c) => c.abbreviation).join(', ')}</span>
|
|
38
|
+
) : null}
|
|
39
|
+
</Heading>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<aside className={rootClass} style={rootStyle} aria-label={`About the reviewing expert, ${expert.displayName}`}>
|
|
44
|
+
{photoUrl ? (
|
|
45
|
+
<img
|
|
46
|
+
className={`${ROOT_CLASS}__photo`}
|
|
47
|
+
src={photoUrl}
|
|
48
|
+
alt={`Photo of ${expert.displayName}`}
|
|
49
|
+
width={64}
|
|
50
|
+
height={64}
|
|
51
|
+
loading="lazy"
|
|
52
|
+
decoding="async"
|
|
53
|
+
/>
|
|
54
|
+
) : null}
|
|
55
|
+
<div className={`${ROOT_CLASS}__body`}>
|
|
56
|
+
{profileUrl ? (
|
|
57
|
+
<a className={`${ROOT_CLASS}__name-link`} href={profileUrl} rel="noopener">
|
|
58
|
+
{nameNode}
|
|
59
|
+
</a>
|
|
60
|
+
) : (
|
|
61
|
+
nameNode
|
|
62
|
+
)}
|
|
63
|
+
{expert.credentials.length > 0 ? (
|
|
64
|
+
<ul className={`${ROOT_CLASS}__credential-list`}>
|
|
65
|
+
{expert.credentials.map((c) => (
|
|
66
|
+
<li key={`${c.abbreviation}:${c.fullName}`} className={`${ROOT_CLASS}__credential`}>
|
|
67
|
+
<abbr title={c.fullName}>{c.abbreviation}</abbr>
|
|
68
|
+
<span className={`${ROOT_CLASS}__credential-full`}> — {c.fullName}</span>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
) : null}
|
|
73
|
+
<p className={`${ROOT_CLASS}__role`}>Verified expert reviewer</p>
|
|
74
|
+
</div>
|
|
75
|
+
</aside>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR-safe escaping + sanitization helpers.
|
|
3
|
+
*
|
|
4
|
+
* The components render certification FACTS that originate from brand / expert input
|
|
5
|
+
* (display names, memos, titles, URLs). React escapes text children + attribute values
|
|
6
|
+
* by default, so the JSX path is safe without extra work — but two surfaces need
|
|
7
|
+
* explicit defense:
|
|
8
|
+
* 1. URLs placed in href/src: a `javascript:` (or `data:`) scheme in a stored URL
|
|
9
|
+
* becomes an XSS vector the moment it's clicked. `safeHttpUrl` allows only http(s).
|
|
10
|
+
* 2. The Web Component + JSON-LD paths build strings by hand (no JSX auto-escaping),
|
|
11
|
+
* so they call `escapeHtml` / `escapeAttribute` directly.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is pure and runtime-agnostic (no DOM, no Node APIs) so it runs in a
|
|
14
|
+
* server component, an edge runtime, and the Web Component identically.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
18
|
+
'&': '&',
|
|
19
|
+
'<': '<',
|
|
20
|
+
'>': '>',
|
|
21
|
+
'"': '"',
|
|
22
|
+
"'": ''',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Escape text for safe inclusion in HTML element content or double-quoted attributes. */
|
|
26
|
+
export function escapeHtml(input: string): string {
|
|
27
|
+
return input.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Alias kept explicit at call sites where the value lands in an attribute. */
|
|
31
|
+
export const escapeAttribute = escapeHtml
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return the URL only if it is a safe absolute http(s) URL (or a protocol-relative or
|
|
35
|
+
* site-relative URL), else null. Anything with a `javascript:`/`data:`/`vbscript:`/etc.
|
|
36
|
+
* scheme — or that fails to parse — is rejected so it can never reach an href/src.
|
|
37
|
+
* Callers treat null as "omit the link".
|
|
38
|
+
*/
|
|
39
|
+
export function safeHttpUrl(input: string | null | undefined): string | null {
|
|
40
|
+
if (!input) return null
|
|
41
|
+
const trimmed = input.trim()
|
|
42
|
+
if (trimmed === '') return null
|
|
43
|
+
// Site-relative or protocol-relative URLs are safe (no scheme to abuse).
|
|
44
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?')) return trimmed
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(trimmed)
|
|
47
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a CSS color token for inline `style`. We accept only a conservative set
|
|
55
|
+
* (`#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa` hex, `rgb()/rgba()/hsl()/hsla()` functions, and
|
|
56
|
+
* a bare CSS keyword) so a stored `accentColor` can't inject `expression(...)`,
|
|
57
|
+
* `url(...)`, or break out of the style attribute. Returns null on anything suspicious.
|
|
58
|
+
*/
|
|
59
|
+
export function safeCssColor(input: string | null | undefined): string | null {
|
|
60
|
+
if (!input) return null
|
|
61
|
+
const v = input.trim()
|
|
62
|
+
if (v === '') return null
|
|
63
|
+
if (/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v)) return v
|
|
64
|
+
if (/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/.test(v)) return v
|
|
65
|
+
if (/^[a-zA-Z]{1,32}$/.test(v)) return v
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Collapse interior whitespace + trim. Used to normalize display text. */
|
|
70
|
+
export function tidyText(input: string | null | undefined): string {
|
|
71
|
+
return (input ?? '').replace(/\s+/g, ' ').trim()
|
|
72
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation helpers shared by the React components + Web Component. Pure, no DOM,
|
|
3
|
+
* no React — so the same formatting drives JSX and the hand-built Web Component string.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CertContent, CertDisplayConfig } from '../contract/kernel.js'
|
|
7
|
+
|
|
8
|
+
/** Default accent if the brand didn't pin one (CertREV teal). */
|
|
9
|
+
export const DEFAULT_ACCENT = '#0f766e'
|
|
10
|
+
|
|
11
|
+
export interface ResolvedDisplay {
|
|
12
|
+
readonly accentColor: string
|
|
13
|
+
readonly showExpertPhoto: boolean
|
|
14
|
+
readonly showAuthor: boolean
|
|
15
|
+
readonly showMemo: boolean
|
|
16
|
+
readonly badgeStyle: 'full' | 'compact'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve the optional display config to concrete presentation flags + accent. */
|
|
20
|
+
export function resolveDisplay(display: CertDisplayConfig | undefined, accentOverride?: string): ResolvedDisplay {
|
|
21
|
+
return {
|
|
22
|
+
accentColor: accentOverride ?? display?.accentColor ?? DEFAULT_ACCENT,
|
|
23
|
+
showExpertPhoto: display?.showExpertPhoto ?? true,
|
|
24
|
+
showAuthor: display?.showAuthor ?? true,
|
|
25
|
+
showMemo: display?.showMemo ?? true,
|
|
26
|
+
badgeStyle: display?.badgeStyle ?? 'full',
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format an ISO-8601 instant to a stable, locale-independent date label ("Jun 21, 2026").
|
|
32
|
+
* We deliberately DON'T use `toLocaleDateString` — its output varies by runtime ICU data,
|
|
33
|
+
* which would make SSR output non-deterministic + cause hydration mismatches. Fixed
|
|
34
|
+
* English month abbreviations keep server + client byte-identical.
|
|
35
|
+
*/
|
|
36
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
37
|
+
|
|
38
|
+
export function formatDate(iso: string | null | undefined): string | null {
|
|
39
|
+
if (!iso) return null
|
|
40
|
+
const ms = Date.parse(iso)
|
|
41
|
+
if (Number.isNaN(ms)) return null
|
|
42
|
+
const d = new Date(ms)
|
|
43
|
+
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "PhD, RD" — the expert's credential abbreviations, comma-joined. */
|
|
47
|
+
export function credentialSuffix(content: CertContent): string {
|
|
48
|
+
return content.expert.credentials.map((c) => c.abbreviation).join(', ')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "Dr. Jane Doe, PhD, RD" — display name with credential suffix appended. */
|
|
52
|
+
export function expertNameWithCredentials(content: CertContent): string {
|
|
53
|
+
const suffix = credentialSuffix(content)
|
|
54
|
+
return suffix ? `${content.expert.displayName}, ${suffix}` : content.expert.displayName
|
|
55
|
+
}
|