@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
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# @certrev/cert-block
|
|
2
|
+
|
|
3
|
+
The headless / `crypto_verify` render edge for the CertREV `CertDeliveryEnvelope`. Sign once in the portal, render everywhere: SSR-safe React components, a deterministic schema.org JSON-LD projector, a fail-closed verify layer over the shared `VerdictKernel`, and a framework-agnostic `<certrev-badge>` Web Component.
|
|
4
|
+
|
|
5
|
+
This is the SDK the brand SSRs into a Hydrogen loader, a Next server component, a Builder client component, or a universal server-side include. It is the `headless_react` + `universal_embed` surface classes from the cross-platform delivery design.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
CertREV's durable asset is a portable, forgery-proof "this content was reviewed by a credentialed expert" credential — a single Ed25519-signed `CertDeliveryEnvelope`. Each delivery surface renders the visible UI + JSON-LD from that envelope and enforces its validity. The WordPress plugin does it in PHP; the Shopify Liquid extension does it in Liquid; **this package does it in TS/JS for any React or vanilla-JS surface that can run Node/WebCrypto.**
|
|
10
|
+
|
|
11
|
+
The package gives a brand four things:
|
|
12
|
+
|
|
13
|
+
1. **React components** — `<CertBadge>`, `<ExpertBio>`, `<CertRevBacklink>`, `<CertJsonLd>`, and the `<CertReview>` composite. SSR-safe (render-pure; no `useState`/`useEffect`/browser globals), theme-light, accessible, and they escape every field. Presentation is driven by the signed `content.display` config (`badgeStyle`, `accentColor`, `showExpertPhoto`, `showMemo`, `showAuthor`).
|
|
14
|
+
2. **A deterministic JSON-LD projector** — `projectCertJsonLd(facts)` → a schema.org `Article` + `reviewedBy(Person)` + `Review` + `Organization` `@graph`, designed to **merge by `@id`** into the host page's existing Article graph (won't collide with Yoast / the theme's structured data). The JSON-LD is **projected from the facts at render time, never stored in the envelope.**
|
|
15
|
+
3. **A thin verify layer** — `getVerifiedEnvelope(source)` fetches the signed envelope from **either a Shopify metafield OR the Delivery API** (`GET /api/cert/v1/delivery/{platform}/{externalId}`), runs the fail-closed `VerdictKernel`, and caches the verdict (TTL + single-flight) so concurrent SSR renders don't stampede the origin.
|
|
16
|
+
4. **A Web Component** — `<certrev-badge>`, the `universal_embed` surface for sites with no native integration, wrapping the same render as `<CertBadge>` via a shared string renderer.
|
|
17
|
+
|
|
18
|
+
## The contract shape (settled)
|
|
19
|
+
|
|
20
|
+
A `CertDeliveryEnvelope = { payload, signature }`. The detached `signature` is **Ed25519 over RFC-8785 JCS(payload)**.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
payload = {
|
|
24
|
+
contractVersion: 1,
|
|
25
|
+
certId,
|
|
26
|
+
subject: { platform, externalId, logicalArticleId, canonicalUrls[], installationId, contentDigest },
|
|
27
|
+
content: { expert{displayName,credentials[],profileUrl,photoUrl}, author{name,title},
|
|
28
|
+
memo, certifiedAt, contentModifiedAt, verifyUrl,
|
|
29
|
+
display{accentColor,showExpertPhoto,showAuthor,showMemo,badgeStyle} },
|
|
30
|
+
lifecycle: { issuedAt, expiresAt, revokedAt|null, revision },
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`content` is **structured FACTS**, not rendered JSON-LD — the JSON-LD is projected from these facts at render, never stored. The `VerdictKernel` (shared) verifies the signature, then checks subject (`platform`/`externalId`), lifecycle (`revokedAt`/`expiresAt`), and content drift (`contentDigest` vs the live hash), failing closed at every step.
|
|
35
|
+
|
|
36
|
+
## Public API
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// React components (SSR-safe)
|
|
40
|
+
import { CertBadge, ExpertBio, CertRevBacklink, CertJsonLd, CertReview } from '@certrev/cert-block'
|
|
41
|
+
|
|
42
|
+
// JSON-LD projector
|
|
43
|
+
import { projectCertJsonLd, projectCertJsonLdString, serializeJsonLdForScript } from '@certrev/cert-block'
|
|
44
|
+
|
|
45
|
+
// Verify layer
|
|
46
|
+
import { getVerifiedEnvelope, invalidateVerdict, sharedVerdictCache, TtlCache } from '@certrev/cert-block'
|
|
47
|
+
import { staticKidResolver, fetchingKidResolver } from '@certrev/cert-block'
|
|
48
|
+
|
|
49
|
+
// Contract surface (re-exported from the binding point; → @certrev/cert-contract on publish)
|
|
50
|
+
import type { CertDeliveryEnvelope, CertPayload, CertVerdict, RenderContext } from '@certrev/cert-block'
|
|
51
|
+
import { verifyEnvelope, renderVerdict, verifySignatureOnly } from '@certrev/cert-block'
|
|
52
|
+
|
|
53
|
+
// Web Component (separate subpath — does NOT touch customElements on import)
|
|
54
|
+
import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage — headless React (Hydrogen / Next server component)
|
|
58
|
+
|
|
59
|
+
Fetch + verify in the **server** runtime (it can run Ed25519 over JCS — full `crypto_verify` parity), then render the badge + JSON-LD only on a `render` verdict.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { getVerifiedEnvelope, staticKidResolver, CertReview } from '@certrev/cert-block'
|
|
63
|
+
|
|
64
|
+
const resolveKid = staticKidResolver({ 'certrev-2026-1': process.env.CERTREV_PUBKEY_PEM! })
|
|
65
|
+
|
|
66
|
+
// In a Hydrogen loader / Next server component:
|
|
67
|
+
const verdict = await getVerifiedEnvelope({
|
|
68
|
+
// PULL from the Delivery API (or { kind: 'metafield', value } for a Shopify metafield)
|
|
69
|
+
source: { kind: 'delivery_api', baseUrl: 'https://portal.certrev.com', platform: 'shopify', externalId: articleGid },
|
|
70
|
+
resolveKid,
|
|
71
|
+
context: { platform: 'shopify', externalId: articleGid, liveContentHash },
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// <CertReview> is fail-closed: renders the badge + projected JSON-LD on `render`, NOTHING on `suppress`.
|
|
75
|
+
return <CertReview verdict={verdict} pageUrl={canonicalUrl} />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For finer control, render the pieces independently from `verdict.payload`:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
{verdict.decision === 'render' && (
|
|
82
|
+
<>
|
|
83
|
+
<CertBadge payload={verdict.payload} badgeStyle="compact" />
|
|
84
|
+
<ExpertBio payload={verdict.payload} headingLevel="h2" />
|
|
85
|
+
<CertRevBacklink payload={verdict.payload} />
|
|
86
|
+
<CertJsonLd payload={verdict.payload} pageUrl={canonicalUrl} />
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Usage — JSON-LD merge (don't collide with Yoast)
|
|
92
|
+
|
|
93
|
+
The Article node carries the SAME `@id` the host page's primary Article uses (`{pageUrl}#article`, query + fragment stripped), so a consumer **merges** our `reviewedBy` / `dateModified` into the existing node instead of emitting a competing primary entity. Pass `wrapGraph: false` to get a bare node array to splice into a `@graph` you already own.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const graph = projectCertJsonLd(payload, { pageUrl: canonicalUrl }) // { '@context', '@graph' }
|
|
97
|
+
const nodes = projectCertJsonLd(payload, { pageUrl: canonicalUrl, wrapGraph: false }) // bare node[]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Usage — universal embed (Web Component)
|
|
101
|
+
|
|
102
|
+
Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the badge HTML (via `renderBadgeHtml`) inside the element; the component leaves the server-rendered child alone. Client-fetch mode (not crawlable) is a documented fallback and is **fail-closed** — it renders nothing without a configured key resolver.
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<!-- SSR: server emits the badge markup inside the tag; crawler reads it -->
|
|
106
|
+
<certrev-badge>{{ renderBadgeHtml(payload) }}</certrev-badge>
|
|
107
|
+
|
|
108
|
+
<script type="module">
|
|
109
|
+
import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'
|
|
110
|
+
setCertRevKidResolver(myResolver) // only needed for the client-fetch fallback
|
|
111
|
+
defineCertRevBadge()
|
|
112
|
+
</script>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Fail-closed, everywhere
|
|
116
|
+
|
|
117
|
+
The package never renders an unverified credential. Every error path — bad signature, unknown kid, wrong post, revoked, expired, content drift, network failure, malformed JSON, a throwing resolver — collapses to a `suppress` verdict (or a `null` render), never an exception that a caller could swallow into a render. `<CertReview>` and `<CertRevBacklink>` also fail closed at the component boundary (suppress / unsafe URL → render nothing). URLs pass through `safeHttpUrl` (drops `javascript:`/`data:`); accent colors through `safeCssColor`; the JSON-LD body neutralizes `<`/`>` so a hostile memo can't break out of the `<script>` tag.
|
|
118
|
+
|
|
119
|
+
## Server-safe guarantee
|
|
120
|
+
|
|
121
|
+
- The main entry never touches `customElements` / `window` — the Web Component ships from the `./webcomponent` subpath so importing `@certrev/cert-block` in an RSC/Node loader is side-effect-free. Registration only happens when you call `defineCertRevBadge()` in a browser.
|
|
122
|
+
- The React components are render-pure (verified by `react-dom/server` SSR tests) so they work as Server Components and as client components that SSR identically — same crawlable output either way.
|
|
123
|
+
- Date formatting is deterministic UTC (fixed English month abbreviations, not `toLocaleDateString`) so SSR output is byte-stable and never causes a hydration mismatch.
|
|
124
|
+
|
|
125
|
+
## Integration TODOs (wire the real `@certrev/cert-contract`)
|
|
126
|
+
|
|
127
|
+
The shared contract types + the `VerdictKernel` live in the published `@certrev/cert-contract` package, which is **not yet on a registry this workspace installs from**. Until it publishes, the SDK binds the contract surface through a **local stub** so everything builds + tests now. To cut over on publish:
|
|
128
|
+
|
|
129
|
+
1. **Delete** `src/contract/kernel-contract.ts` (the type mirror) and `src/contract/kernel-stub.ts` (the faithful kernel impl).
|
|
130
|
+
2. In `src/contract/kernel.ts` (the single binding point), **replace both re-export blocks** with one line: `export * from '@certrev/cert-contract'`. The exported names are deliberately identical, so nothing else in the SDK changes.
|
|
131
|
+
3. In `package.json`, flip the `@certrev/cert-contract` peer dependency back to **`optional: false`** and **remove the root `.npmrc`** `auto-install-peers=false` line (it exists only so the workspace installs against the stub).
|
|
132
|
+
4. Point `src/contract/fixtures.ts` at cert-contract's real `canonicalPayloadBytes` (RFC 8785) so the test fixtures sign bytes the published kernel verifies. (Today the stub + fixtures share a minimal JCS sufficient for the all-string/number/bool envelope shape.)
|
|
133
|
+
5. Confirm `verifyEnvelope` / `renderVerdict` / `verifySignatureOnly` / `toEd25519PublicKey` resolve from the published package, then re-run `pnpm typecheck && pnpm test && pnpm build`.
|
|
134
|
+
|
|
135
|
+
A WebCrypto verify path for the Web Component's client-fetch fallback lives in cert-contract; once published, wire it into `certrev-badge.ts` so the universal embed can verify in the browser without a pre-supplied resolver.
|
|
136
|
+
|
|
137
|
+
## Current consumers
|
|
138
|
+
|
|
139
|
+
- _None yet._ Target consumers: brand-owned Hydrogen / Next / Remix storefronts (`headless_react`), Builder.io spaces (`headless_visual_cms`), and any site dropping in `<certrev-badge>` (`universal_embed`). Add each here as it adopts the SDK.
|
|
140
|
+
|
|
141
|
+
## Tests
|
|
142
|
+
|
|
143
|
+
`pnpm test` (Vitest). Coverage:
|
|
144
|
+
|
|
145
|
+
- **`__tests__/verify.test.ts`** — the kernel via the SDK binding with **real Ed25519 over JCS** (freshly-signed fixture envelopes, no mocked crypto): render, tamper → `invalid_signature`, unknown kid, platform/subject mismatch, revoked, expired, content drift; `getVerifiedEnvelope` over metafield (object + JSON-string) and Delivery API sources, 404/410 fail-closed, and the **single-flight** thundering-herd guard (N concurrent renders → one fetch).
|
|
146
|
+
- **`__tests__/project.test.ts`** — the JSON-LD projector: `@graph` shape, Article `@id` host-merge alignment (query + fragment stripped), expert-as-`reviewedBy`, credentials → `hasCredential` + `honorificSuffix`, CertREV-namespaced node `@id`s, determinism, and `</script>` / HTML-comment neutralization.
|
|
147
|
+
- **`__tests__/components.test.tsx`** — every React component rendered through `react-dom/server` with mock facts: structure, accessibility (`aria-label`, heading levels), accent theming, display-flag honoring, hostile-input escaping, `javascript:`-URL dropping, and fail-closed (`suppress` verdict / unsafe URL → nothing).
|
|
148
|
+
- **`__tests__/webcomponent.test.tsx`** — the shared `renderBadgeHtml` string renderer (hand-escaping, unsafe-URL/color dropping, compact style) and the `<certrev-badge>` custom element (idempotent registration, SSR light-DOM preservation, client-mode fail-closed without a resolver).
|
|
149
|
+
|
|
150
|
+
Facts are mocked via `makeMockPayload` / `makeSignedEnvelope` (`src/contract/fixtures.ts`).
|
|
151
|
+
|
|
152
|
+
## Version
|
|
153
|
+
|
|
154
|
+
`0.1.0` — initial scaffold against the settled contract shape, bound to the local contract stub. Publishes **publicly** to npm as `@certrev/cert-block` (`publishConfig.access: public`).
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
import type { CertPayload } from '../contract/kernel.js';
|
|
18
|
+
export interface CertBadgeProps {
|
|
19
|
+
/** The cryptographically-verified payload (from `getVerifiedEnvelope`). */
|
|
20
|
+
readonly payload: CertPayload;
|
|
21
|
+
/** Optional accent override (else `display.accentColor`, else CertREV default). */
|
|
22
|
+
readonly accentColor?: string;
|
|
23
|
+
/** Extra class names appended to the root element's class list. */
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
/** Override the badge style independent of the signed display config. */
|
|
26
|
+
readonly badgeStyle?: 'full' | 'compact';
|
|
27
|
+
}
|
|
28
|
+
export declare function CertBadge(props: CertBadgeProps): import("react").JSX.Element;
|
|
29
|
+
//# sourceMappingURL=CertBadge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertBadge.d.ts","sourceRoot":"","sources":["../../src/components/CertBadge.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAe,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIrE,MAAM,WAAW,cAAc;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,mFAAmF;IACnF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,mEAAmE;IACnE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACxC;AAuCD,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,+BA8F9C"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { safeHttpUrl } from './escape.js';
|
|
3
|
+
import { credentialSuffix, expertNameWithCredentials, formatDate, resolveDisplay } from './format.js';
|
|
4
|
+
const ROOT_CLASS = 'certrev-badge';
|
|
5
|
+
/** Inline-SVG verified checkmark — no external asset, accent-tinted, decorative. */
|
|
6
|
+
function VerifiedMark({ accent }) {
|
|
7
|
+
return (_jsxs("svg", { className: `${ROOT_CLASS}__mark`, width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", focusable: "false", children: [_jsx("title", { children: "Verified" }), _jsx("circle", { cx: "12", cy: "12", r: "11", fill: accent }), _jsx("path", { d: "M7 12.5l3.2 3.2L17 9", stroke: "#ffffff", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round" })] }));
|
|
8
|
+
}
|
|
9
|
+
function ExpertPhoto({ content, show }) {
|
|
10
|
+
const src = show ? safeHttpUrl(content.expert.photoUrl) : null;
|
|
11
|
+
if (!src)
|
|
12
|
+
return null;
|
|
13
|
+
return (_jsx("img", { className: `${ROOT_CLASS}__photo`, src: src, alt: `Photo of ${content.expert.displayName}`, width: 40, height: 40, loading: "lazy", decoding: "async" }));
|
|
14
|
+
}
|
|
15
|
+
export function CertBadge(props) {
|
|
16
|
+
const { payload } = props;
|
|
17
|
+
const content = payload.content;
|
|
18
|
+
const display = resolveDisplay(content.display, props.accentColor);
|
|
19
|
+
const style = props.badgeStyle ?? display.badgeStyle;
|
|
20
|
+
const verifyUrl = safeHttpUrl(content.verifyUrl);
|
|
21
|
+
const profileUrl = safeHttpUrl(content.expert.profileUrl);
|
|
22
|
+
const certifiedLabel = formatDate(content.certifiedAt);
|
|
23
|
+
const updatedLabel = formatDate(content.contentModifiedAt);
|
|
24
|
+
const suffix = credentialSuffix(content);
|
|
25
|
+
const accent = display.accentColor;
|
|
26
|
+
const rootClass = `${ROOT_CLASS} ${ROOT_CLASS}--${style}${props.className ? ` ${props.className}` : ''}`;
|
|
27
|
+
// Accent is exposed as a custom property so brand themes can read it, and applied
|
|
28
|
+
// directly to the accent border so the component is self-styling without a stylesheet.
|
|
29
|
+
const rootStyle = {
|
|
30
|
+
['--certrev-accent']: accent,
|
|
31
|
+
borderInlineStartColor: accent,
|
|
32
|
+
};
|
|
33
|
+
const expertName = (_jsxs("span", { className: `${ROOT_CLASS}__expert-name`, children: [content.expert.displayName, suffix ? _jsxs("span", { className: `${ROOT_CLASS}__credentials`, children: [", ", suffix] }) : null] }));
|
|
34
|
+
return (_jsxs("section", { className: rootClass, style: rootStyle, "data-certrev-cert-id": payload.certId, "aria-label": `Content reviewed by ${expertNameWithCredentials(content)}`, children: [_jsxs("div", { className: `${ROOT_CLASS}__header`, children: [_jsx(VerifiedMark, { accent: accent }), style === 'full' ? _jsx(ExpertPhoto, { content: content, show: display.showExpertPhoto }) : null, _jsxs("div", { className: `${ROOT_CLASS}__heading`, children: [_jsx("span", { className: `${ROOT_CLASS}__eyebrow`, children: "Expert reviewed" }), _jsxs("span", { className: `${ROOT_CLASS}__byline`, children: ["Reviewed by", ' ', profileUrl ? (_jsx("a", { className: `${ROOT_CLASS}__expert-link`, href: profileUrl, rel: "noopener", children: expertName })) : (expertName)] })] })] }), style === 'full' ? (_jsxs(_Fragment, { children: [display.showMemo && content.memo ? _jsx("p", { className: `${ROOT_CLASS}__memo`, children: content.memo }) : null, display.showAuthor && content.author.name && content.author.name !== content.expert.displayName ? (_jsxs("p", { className: `${ROOT_CLASS}__author`, children: ["Written by ", content.author.name, content.author.title ? `, ${content.author.title}` : ''] })) : null, _jsxs("dl", { className: `${ROOT_CLASS}__dates`, children: [certifiedLabel ? (_jsxs("div", { className: `${ROOT_CLASS}__date`, children: [_jsx("dt", { children: "Certified" }), _jsx("dd", { children: _jsx("time", { dateTime: content.certifiedAt, children: certifiedLabel }) })] })) : null, updatedLabel ? (_jsxs("div", { className: `${ROOT_CLASS}__date`, children: [_jsx("dt", { children: "Last updated" }), _jsx("dd", { children: _jsx("time", { dateTime: content.contentModifiedAt ?? undefined, children: updatedLabel }) })] })) : null] })] })) : null, verifyUrl ? (_jsx("a", { className: `${ROOT_CLASS}__verify`, href: verifyUrl, rel: "noopener", "aria-label": "Verify this certification on CertREV", children: "Verify on CertREV" })) : null] }));
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=CertBadge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertBadge.js","sourceRoot":"","sources":["../../src/components/CertBadge.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
import type { CertPayload } from '../contract/kernel.js';
|
|
16
|
+
import { type ProjectJsonLdOptions } from '../jsonld/project.js';
|
|
17
|
+
export interface CertJsonLdProps extends ProjectJsonLdOptions {
|
|
18
|
+
readonly payload: CertPayload;
|
|
19
|
+
/** Optional id on the <script> so a page can find/replace it deterministically. */
|
|
20
|
+
readonly scriptId?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function CertJsonLd(props: CertJsonLdProps): import("react").JSX.Element;
|
|
23
|
+
//# sourceMappingURL=CertJsonLd.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertJsonLd.d.ts","sourceRoot":"","sources":["../../src/components/CertJsonLd.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAqB,KAAK,oBAAoB,EAA4B,MAAM,sBAAsB,CAAA;AAE7G,MAAM,WAAW,eAAgB,SAAQ,oBAAoB;IAC5D,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,+BAWhD"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { projectCertJsonLd, serializeJsonLdForScript } from '../jsonld/project.js';
|
|
3
|
+
export function CertJsonLd(props) {
|
|
4
|
+
const { payload, scriptId, ...opts } = props;
|
|
5
|
+
const json = serializeJsonLdForScript(projectCertJsonLd(payload, opts));
|
|
6
|
+
return (_jsx("script", { type: "application/ld+json", id: scriptId,
|
|
7
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD requires raw script body; the content is our deterministic serialization with `<` neutralized (serializeJsonLdForScript), not arbitrary HTML.
|
|
8
|
+
dangerouslySetInnerHTML: { __html: json } }));
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=CertJsonLd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertJsonLd.js","sourceRoot":"","sources":["../../src/components/CertJsonLd.tsx"],"names":[],"mappings":";AAgBA,OAAO,EAAE,iBAAiB,EAA6B,wBAAwB,EAAE,MAAM,sBAAsB,CAAA;AAQ7G,MAAM,UAAU,UAAU,CAAC,KAAsB;IAChD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,KAAK,CAAA;IAC5C,MAAM,IAAI,GAAG,wBAAwB,CAAC,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAA;IACvE,OAAO,CACN,iBACC,IAAI,EAAC,qBAAqB,EAC1B,EAAE,EAAE,QAAQ;QACZ,8MAA8M;QAC9M,uBAAuB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,GACxC,CACF,CAAA;AACF,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
import type { CertPayload } from '../contract/kernel.js';
|
|
10
|
+
export interface CertRevBacklinkProps {
|
|
11
|
+
readonly payload: CertPayload;
|
|
12
|
+
readonly accentColor?: string;
|
|
13
|
+
readonly className?: string;
|
|
14
|
+
/** Link text (default "Verify on CertREV"). */
|
|
15
|
+
readonly label?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function CertRevBacklink(props: CertRevBacklinkProps): import("react").JSX.Element | null;
|
|
18
|
+
//# sourceMappingURL=CertRevBacklink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertRevBacklink.d.ts","sourceRoot":"","sources":["../../src/components/CertRevBacklink.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,+CAA+C;IAC/C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACvB;AAID,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,sCAuC1D"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { safeHttpUrl } from './escape.js';
|
|
3
|
+
import { resolveDisplay } from './format.js';
|
|
4
|
+
const ROOT_CLASS = 'certrev-backlink';
|
|
5
|
+
export function CertRevBacklink(props) {
|
|
6
|
+
const verifyUrl = safeHttpUrl(props.payload.content.verifyUrl);
|
|
7
|
+
if (!verifyUrl)
|
|
8
|
+
return null;
|
|
9
|
+
const display = resolveDisplay(props.payload.content.display, props.accentColor);
|
|
10
|
+
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`;
|
|
11
|
+
// Typed as a string record (not inline) so the `--certrev-accent` CSS custom property
|
|
12
|
+
// is accepted — React's CSSProperties rejects arbitrary custom props on an inline literal.
|
|
13
|
+
const rootStyle = { ['--certrev-accent']: display.accentColor, color: display.accentColor };
|
|
14
|
+
return (_jsxs("a", { className: rootClass, style: rootStyle, href: verifyUrl, rel: "noopener", "aria-label": "Verify this certification on CertREV", children: [_jsxs("svg", { className: `${ROOT_CLASS}__icon`, width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", focusable: "false", children: [_jsx("title", { children: "Verified" }), _jsx("path", { d: "M12 2l7 3v6c0 4.4-3 8.3-7 9-4-0.7-7-4.6-7-9V5l7-3z", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinejoin: "round" }), _jsx("path", { d: "M8.5 12l2.3 2.3L15.5 9", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round" })] }), _jsx("span", { className: `${ROOT_CLASS}__label`, children: props.label ?? 'Verify on CertREV' })] }));
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=CertRevBacklink.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertRevBacklink.js","sourceRoot":"","sources":["../../src/components/CertRevBacklink.tsx"],"names":[],"mappings":";AAUA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAU5C,MAAM,UAAU,GAAG,kBAAkB,CAAA;AAErC,MAAM,UAAU,eAAe,CAAC,KAA2B;IAC1D,MAAM,SAAS,GAAG,WAAW,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IAC9D,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAA;IAC3B,MAAM,OAAO,GAAG,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,WAAW,CAAC,CAAA;IAChF,MAAM,SAAS,GAAG,GAAG,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;IAChF,sFAAsF;IACtF,2FAA2F;IAC3F,MAAM,SAAS,GAA2B,EAAE,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC,WAAW,EAAE,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAA;IAEnH,OAAO,CACN,aACC,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,SAAS,EACf,GAAG,EAAC,UAAU,gBACH,sCAAsC,aAEjD,eACC,SAAS,EAAE,GAAG,UAAU,QAAQ,EAChC,KAAK,EAAC,IAAI,EACV,MAAM,EAAC,IAAI,EACX,OAAO,EAAC,WAAW,EACnB,IAAI,EAAC,MAAM,iBACC,MAAM,EAClB,SAAS,EAAC,OAAO,aAEjB,uCAAuB,EACvB,eACC,CAAC,EAAC,oDAAoD,EACtD,IAAI,EAAC,MAAM,EACX,MAAM,EAAC,cAAc,EACrB,WAAW,EAAC,KAAK,EACjB,cAAc,EAAC,OAAO,GACrB,EACF,eAAM,CAAC,EAAC,wBAAwB,EAAC,MAAM,EAAC,cAAc,EAAC,WAAW,EAAC,KAAK,EAAC,aAAa,EAAC,OAAO,EAAC,cAAc,EAAC,OAAO,GAAG,IACnH,EACN,eAAM,SAAS,EAAE,GAAG,UAAU,SAAS,YAAG,KAAK,CAAC,KAAK,IAAI,mBAAmB,GAAQ,IACjF,CACJ,CAAA;AACF,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
import type { CertVerdict } from '../contract/kernel.js';
|
|
11
|
+
export interface CertReviewProps {
|
|
12
|
+
/** The verdict from `getVerifiedEnvelope` (or a direct kernel call). */
|
|
13
|
+
readonly verdict: CertVerdict;
|
|
14
|
+
/** Canonical page URL for JSON-LD @id alignment (merge into the host article node). */
|
|
15
|
+
readonly pageUrl?: string;
|
|
16
|
+
readonly accentColor?: string;
|
|
17
|
+
readonly className?: string;
|
|
18
|
+
readonly badgeStyle?: 'full' | 'compact';
|
|
19
|
+
/** Omit the JSON-LD (e.g. the page already emits it elsewhere). Default false. */
|
|
20
|
+
readonly omitJsonLd?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function CertReview(props: CertReviewProps): import("react").JSX.Element | null;
|
|
23
|
+
//# sourceMappingURL=CertReview.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertReview.d.ts","sourceRoot":"","sources":["../../src/components/CertReview.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,MAAM,WAAW,eAAe;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,uFAAuF;IACvF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAC7B;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,sCAehD"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CertBadge } from './CertBadge.js';
|
|
3
|
+
import { CertJsonLd } from './CertJsonLd.js';
|
|
4
|
+
export function CertReview(props) {
|
|
5
|
+
// Fail-closed: a suppressed (or any non-render) verdict renders nothing.
|
|
6
|
+
if (props.verdict.decision !== 'render')
|
|
7
|
+
return null;
|
|
8
|
+
const payload = props.verdict.payload;
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsx(CertBadge, { payload: payload, accentColor: props.accentColor, className: props.className, badgeStyle: props.badgeStyle }), props.omitJsonLd ? null : _jsx(CertJsonLd, { payload: payload, pageUrl: props.pageUrl })] }));
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=CertReview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertReview.js","sourceRoot":"","sources":["../../src/components/CertReview.tsx"],"names":[],"mappings":";AAWA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAc5C,MAAM,UAAU,UAAU,CAAC,KAAsB;IAChD,yEAAyE;IACzE,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IACpD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAA;IACrC,OAAO,CACN,8BACC,KAAC,SAAS,IACT,OAAO,EAAE,OAAO,EAChB,WAAW,EAAE,KAAK,CAAC,WAAW,EAC9B,SAAS,EAAE,KAAK,CAAC,SAAS,EAC1B,UAAU,EAAE,KAAK,CAAC,UAAU,GAC3B,EACD,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAC,UAAU,IAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,GAAI,IACjF,CACH,CAAA;AACF,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
import type { CertPayload } from '../contract/kernel.js';
|
|
9
|
+
export interface ExpertBioProps {
|
|
10
|
+
readonly payload: CertPayload;
|
|
11
|
+
readonly accentColor?: string;
|
|
12
|
+
readonly className?: string;
|
|
13
|
+
/** Heading level for the expert name (default 'h3') so it slots into the page outline. */
|
|
14
|
+
readonly headingLevel?: 'h2' | 'h3' | 'h4';
|
|
15
|
+
}
|
|
16
|
+
export declare function ExpertBio(props: ExpertBioProps): import("react").JSX.Element;
|
|
17
|
+
//# sourceMappingURL=ExpertBio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpertBio.d.ts","sourceRoot":"","sources":["../../src/components/ExpertBio.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,0FAA0F;IAC1F,QAAQ,CAAC,YAAY,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;CAC1C;AAID,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,+BAsD9C"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { safeHttpUrl } from './escape.js';
|
|
3
|
+
import { resolveDisplay } from './format.js';
|
|
4
|
+
const ROOT_CLASS = 'certrev-expert-bio';
|
|
5
|
+
export function ExpertBio(props) {
|
|
6
|
+
const { payload } = props;
|
|
7
|
+
const { expert } = payload.content;
|
|
8
|
+
const display = resolveDisplay(payload.content.display, props.accentColor);
|
|
9
|
+
const profileUrl = safeHttpUrl(expert.profileUrl);
|
|
10
|
+
const photoUrl = display.showExpertPhoto ? safeHttpUrl(expert.photoUrl) : null;
|
|
11
|
+
const Heading = props.headingLevel ?? 'h3';
|
|
12
|
+
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`;
|
|
13
|
+
const rootStyle = { ['--certrev-accent']: display.accentColor };
|
|
14
|
+
const nameNode = (_jsxs(Heading, { className: `${ROOT_CLASS}__name`, children: [expert.displayName, expert.credentials.length > 0 ? (_jsxs("span", { className: `${ROOT_CLASS}__credentials`, children: [", ", expert.credentials.map((c) => c.abbreviation).join(', ')] })) : null] }));
|
|
15
|
+
return (_jsxs("aside", { className: rootClass, style: rootStyle, "aria-label": `About the reviewing expert, ${expert.displayName}`, children: [photoUrl ? (_jsx("img", { className: `${ROOT_CLASS}__photo`, src: photoUrl, alt: `Photo of ${expert.displayName}`, width: 64, height: 64, loading: "lazy", decoding: "async" })) : null, _jsxs("div", { className: `${ROOT_CLASS}__body`, children: [profileUrl ? (_jsx("a", { className: `${ROOT_CLASS}__name-link`, href: profileUrl, rel: "noopener", children: nameNode })) : (nameNode), expert.credentials.length > 0 ? (_jsx("ul", { className: `${ROOT_CLASS}__credential-list`, children: expert.credentials.map((c) => (_jsxs("li", { className: `${ROOT_CLASS}__credential`, children: [_jsx("abbr", { title: c.fullName, children: c.abbreviation }), _jsxs("span", { className: `${ROOT_CLASS}__credential-full`, children: [" \u2014 ", c.fullName] })] }, `${c.abbreviation}:${c.fullName}`))) })) : null, _jsx("p", { className: `${ROOT_CLASS}__role`, children: "Verified expert reviewer" })] })] }));
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=ExpertBio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpertBio.js","sourceRoot":"","sources":["../../src/components/ExpertBio.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/** Escape text for safe inclusion in HTML element content or double-quoted attributes. */
|
|
17
|
+
export declare function escapeHtml(input: string): string;
|
|
18
|
+
/** Alias kept explicit at call sites where the value lands in an attribute. */
|
|
19
|
+
export declare const escapeAttribute: typeof escapeHtml;
|
|
20
|
+
/**
|
|
21
|
+
* Return the URL only if it is a safe absolute http(s) URL (or a protocol-relative or
|
|
22
|
+
* site-relative URL), else null. Anything with a `javascript:`/`data:`/`vbscript:`/etc.
|
|
23
|
+
* scheme — or that fails to parse — is rejected so it can never reach an href/src.
|
|
24
|
+
* Callers treat null as "omit the link".
|
|
25
|
+
*/
|
|
26
|
+
export declare function safeHttpUrl(input: string | null | undefined): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Validate a CSS color token for inline `style`. We accept only a conservative set
|
|
29
|
+
* (`#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa` hex, `rgb()/rgba()/hsl()/hsla()` functions, and
|
|
30
|
+
* a bare CSS keyword) so a stored `accentColor` can't inject `expression(...)`,
|
|
31
|
+
* `url(...)`, or break out of the style attribute. Returns null on anything suspicious.
|
|
32
|
+
*/
|
|
33
|
+
export declare function safeCssColor(input: string | null | undefined): string | null;
|
|
34
|
+
/** Collapse interior whitespace + trim. Used to normalize display text. */
|
|
35
|
+
export declare function tidyText(input: string | null | undefined): string;
|
|
36
|
+
//# sourceMappingURL=escape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../../src/components/escape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAUH,0FAA0F;AAC1F,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,+EAA+E;AAC/E,eAAO,MAAM,eAAe,mBAAa,CAAA;AAEzC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAY3E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAQ5E;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAEjE"}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
const HTML_ESCAPES = {
|
|
17
|
+
'&': '&',
|
|
18
|
+
'<': '<',
|
|
19
|
+
'>': '>',
|
|
20
|
+
'"': '"',
|
|
21
|
+
"'": ''',
|
|
22
|
+
};
|
|
23
|
+
/** Escape text for safe inclusion in HTML element content or double-quoted attributes. */
|
|
24
|
+
export function escapeHtml(input) {
|
|
25
|
+
return input.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
|
|
26
|
+
}
|
|
27
|
+
/** Alias kept explicit at call sites where the value lands in an attribute. */
|
|
28
|
+
export const escapeAttribute = escapeHtml;
|
|
29
|
+
/**
|
|
30
|
+
* Return the URL only if it is a safe absolute http(s) URL (or a protocol-relative or
|
|
31
|
+
* site-relative URL), else null. Anything with a `javascript:`/`data:`/`vbscript:`/etc.
|
|
32
|
+
* scheme — or that fails to parse — is rejected so it can never reach an href/src.
|
|
33
|
+
* Callers treat null as "omit the link".
|
|
34
|
+
*/
|
|
35
|
+
export function safeHttpUrl(input) {
|
|
36
|
+
if (!input)
|
|
37
|
+
return null;
|
|
38
|
+
const trimmed = input.trim();
|
|
39
|
+
if (trimmed === '')
|
|
40
|
+
return null;
|
|
41
|
+
// Site-relative or protocol-relative URLs are safe (no scheme to abuse).
|
|
42
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?'))
|
|
43
|
+
return trimmed;
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(trimmed);
|
|
46
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate a CSS color token for inline `style`. We accept only a conservative set
|
|
54
|
+
* (`#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa` hex, `rgb()/rgba()/hsl()/hsla()` functions, and
|
|
55
|
+
* a bare CSS keyword) so a stored `accentColor` can't inject `expression(...)`,
|
|
56
|
+
* `url(...)`, or break out of the style attribute. Returns null on anything suspicious.
|
|
57
|
+
*/
|
|
58
|
+
export function safeCssColor(input) {
|
|
59
|
+
if (!input)
|
|
60
|
+
return null;
|
|
61
|
+
const v = input.trim();
|
|
62
|
+
if (v === '')
|
|
63
|
+
return null;
|
|
64
|
+
if (/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v))
|
|
65
|
+
return v;
|
|
66
|
+
if (/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/.test(v))
|
|
67
|
+
return v;
|
|
68
|
+
if (/^[a-zA-Z]{1,32}$/.test(v))
|
|
69
|
+
return v;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Collapse interior whitespace + trim. Used to normalize display text. */
|
|
73
|
+
export function tidyText(input) {
|
|
74
|
+
return (input ?? '').replace(/\s+/g, ' ').trim();
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=escape.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape.js","sourceRoot":"","sources":["../../src/components/escape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,YAAY,GAA2B;IAC5C,GAAG,EAAE,OAAO;IACZ,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,QAAQ;IACb,GAAG,EAAE,OAAO;CACZ,CAAA;AAED,0FAA0F;AAC1F,MAAM,UAAU,UAAU,CAAC,KAAa;IACvC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;AAC9D,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,MAAM,eAAe,GAAG,UAAU,CAAA;AAEzC;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAgC;IAC3D,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,IAAI,CAAA;IAC/B,yEAAyE;IACzE,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,OAAO,CAAA;IACjG,IAAI,CAAC;QACJ,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAA;QAC1B,OAAO,CAAC,CAAC,QAAQ,KAAK,OAAO,IAAI,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;IAC/E,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,KAAgC;IAC5D,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IACtB,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,IAAI,CAAA;IACzB,IAAI,uDAAuD,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAC7E,IAAI,4CAA4C,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAClE,IAAI,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IACxC,OAAO,IAAI,CAAA;AACZ,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,QAAQ,CAAC,KAAgC;IACxD,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;AACjD,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
import type { CertContent, CertDisplayConfig } from '../contract/kernel.js';
|
|
6
|
+
/** Default accent if the brand didn't pin one (CertREV teal). */
|
|
7
|
+
export declare const DEFAULT_ACCENT = "#0f766e";
|
|
8
|
+
export interface ResolvedDisplay {
|
|
9
|
+
readonly accentColor: string;
|
|
10
|
+
readonly showExpertPhoto: boolean;
|
|
11
|
+
readonly showAuthor: boolean;
|
|
12
|
+
readonly showMemo: boolean;
|
|
13
|
+
readonly badgeStyle: 'full' | 'compact';
|
|
14
|
+
}
|
|
15
|
+
/** Resolve the optional display config to concrete presentation flags + accent. */
|
|
16
|
+
export declare function resolveDisplay(display: CertDisplayConfig | undefined, accentOverride?: string): ResolvedDisplay;
|
|
17
|
+
export declare function formatDate(iso: string | null | undefined): string | null;
|
|
18
|
+
/** "PhD, RD" — the expert's credential abbreviations, comma-joined. */
|
|
19
|
+
export declare function credentialSuffix(content: CertContent): string;
|
|
20
|
+
/** "Dr. Jane Doe, PhD, RD" — display name with credential suffix appended. */
|
|
21
|
+
export declare function expertNameWithCredentials(content: CertContent): string;
|
|
22
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/components/format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAE3E,iEAAiE;AACjE,eAAO,MAAM,cAAc,YAAY,CAAA;AAEvC,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;CACvC;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,OAAO,EAAE,iBAAiB,GAAG,SAAS,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,eAAe,CAQ/G;AAUD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAMxE;AAED,uEAAuE;AACvE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE7D;AAED,8EAA8E;AAC9E,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAGtE"}
|