@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.
Files changed (99) hide show
  1. package/README.md +154 -0
  2. package/dist/components/CertBadge.d.ts +29 -0
  3. package/dist/components/CertBadge.d.ts.map +1 -0
  4. package/dist/components/CertBadge.js +36 -0
  5. package/dist/components/CertBadge.js.map +1 -0
  6. package/dist/components/CertJsonLd.d.ts +23 -0
  7. package/dist/components/CertJsonLd.d.ts.map +1 -0
  8. package/dist/components/CertJsonLd.js +10 -0
  9. package/dist/components/CertJsonLd.js.map +1 -0
  10. package/dist/components/CertRevBacklink.d.ts +18 -0
  11. package/dist/components/CertRevBacklink.d.ts.map +1 -0
  12. package/dist/components/CertRevBacklink.js +16 -0
  13. package/dist/components/CertRevBacklink.js.map +1 -0
  14. package/dist/components/CertReview.d.ts +23 -0
  15. package/dist/components/CertReview.d.ts.map +1 -0
  16. package/dist/components/CertReview.js +11 -0
  17. package/dist/components/CertReview.js.map +1 -0
  18. package/dist/components/ExpertBio.d.ts +17 -0
  19. package/dist/components/ExpertBio.d.ts.map +1 -0
  20. package/dist/components/ExpertBio.js +17 -0
  21. package/dist/components/ExpertBio.js.map +1 -0
  22. package/dist/components/escape.d.ts +36 -0
  23. package/dist/components/escape.d.ts.map +1 -0
  24. package/dist/components/escape.js +76 -0
  25. package/dist/components/escape.js.map +1 -0
  26. package/dist/components/format.d.ts +22 -0
  27. package/dist/components/format.d.ts.map +1 -0
  28. package/dist/components/format.js +42 -0
  29. package/dist/components/format.js.map +1 -0
  30. package/dist/contract/fixtures.d.ts +36 -0
  31. package/dist/contract/fixtures.d.ts.map +1 -0
  32. package/dist/contract/fixtures.js +87 -0
  33. package/dist/contract/fixtures.js.map +1 -0
  34. package/dist/contract/kernel-contract.d.ts +154 -0
  35. package/dist/contract/kernel-contract.d.ts.map +1 -0
  36. package/dist/contract/kernel-contract.js +35 -0
  37. package/dist/contract/kernel-contract.js.map +1 -0
  38. package/dist/contract/kernel-stub.d.ts +44 -0
  39. package/dist/contract/kernel-stub.d.ts.map +1 -0
  40. package/dist/contract/kernel-stub.js +163 -0
  41. package/dist/contract/kernel-stub.js.map +1 -0
  42. package/dist/contract/kernel.d.ts +20 -0
  43. package/dist/contract/kernel.d.ts.map +1 -0
  44. package/dist/contract/kernel.js +19 -0
  45. package/dist/contract/kernel.js.map +1 -0
  46. package/dist/contract/verdict-kernel.d.ts +34 -0
  47. package/dist/contract/verdict-kernel.d.ts.map +1 -0
  48. package/dist/contract/verdict-kernel.js +13 -0
  49. package/dist/contract/verdict-kernel.js.map +1 -0
  50. package/dist/index.d.ts +31 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +38 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/jsonld/project.d.ts +71 -0
  55. package/dist/jsonld/project.d.ts.map +1 -0
  56. package/dist/jsonld/project.js +183 -0
  57. package/dist/jsonld/project.js.map +1 -0
  58. package/dist/verify/cache.d.ts +56 -0
  59. package/dist/verify/cache.d.ts.map +1 -0
  60. package/dist/verify/cache.js +93 -0
  61. package/dist/verify/cache.js.map +1 -0
  62. package/dist/verify/get-verified-envelope.d.ts +65 -0
  63. package/dist/verify/get-verified-envelope.d.ts.map +1 -0
  64. package/dist/verify/get-verified-envelope.js +104 -0
  65. package/dist/verify/get-verified-envelope.js.map +1 -0
  66. package/dist/verify/resolve-kid.d.ts +38 -0
  67. package/dist/verify/resolve-kid.d.ts.map +1 -0
  68. package/dist/verify/resolve-kid.js +71 -0
  69. package/dist/verify/resolve-kid.js.map +1 -0
  70. package/dist/webcomponent/certrev-badge.d.ts +38 -0
  71. package/dist/webcomponent/certrev-badge.d.ts.map +1 -0
  72. package/dist/webcomponent/certrev-badge.js +98 -0
  73. package/dist/webcomponent/certrev-badge.js.map +1 -0
  74. package/dist/webcomponent/render-badge-html.d.ts +25 -0
  75. package/dist/webcomponent/render-badge-html.d.ts.map +1 -0
  76. package/dist/webcomponent/render-badge-html.js +81 -0
  77. package/dist/webcomponent/render-badge-html.js.map +1 -0
  78. package/package.json +70 -0
  79. package/src/__tests__/components.test.tsx +191 -0
  80. package/src/__tests__/project.test.ts +128 -0
  81. package/src/__tests__/verify.test.ts +203 -0
  82. package/src/__tests__/webcomponent.test.tsx +106 -0
  83. package/src/components/CertBadge.tsx +164 -0
  84. package/src/components/CertJsonLd.tsx +36 -0
  85. package/src/components/CertRevBacklink.tsx +63 -0
  86. package/src/components/CertReview.tsx +42 -0
  87. package/src/components/ExpertBio.tsx +77 -0
  88. package/src/components/escape.ts +72 -0
  89. package/src/components/format.ts +55 -0
  90. package/src/contract/fixtures.ts +107 -0
  91. package/src/contract/kernel.ts +20 -0
  92. package/src/contract/verdict-kernel.ts +47 -0
  93. package/src/index.ts +85 -0
  94. package/src/jsonld/project.ts +206 -0
  95. package/src/verify/cache.ts +116 -0
  96. package/src/verify/get-verified-envelope.ts +156 -0
  97. package/src/verify/resolve-kid.ts +103 -0
  98. package/src/webcomponent/certrev-badge.ts +100 -0
  99. 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('&lt;img src=x onerror=alert(1)&gt;')
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
+ '&': '&amp;',
19
+ '<': '&lt;',
20
+ '>': '&gt;',
21
+ '"': '&quot;',
22
+ "'": '&#39;',
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
+ }