@certrev/cert-block 0.5.5 → 1.0.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/CHANGELOG.md +164 -26
- package/LICENSE +21 -0
- package/README.md +415 -54
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +5 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/readme-version.test.ts +0 -51
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
package/src/jsonld/project.ts
CHANGED
|
@@ -9,21 +9,33 @@
|
|
|
9
9
|
* server pre-render) produce the same structured data, and a snapshot diff is meaningful.
|
|
10
10
|
*
|
|
11
11
|
* DESIGN GOAL: MERGE, don't collide.
|
|
12
|
-
* A brand page usually already emits
|
|
13
|
-
* WordPress, the theme on Shopify, the framework on headless. If we emit a
|
|
14
|
-
* top-level `Article`, crawlers see two competing primary entities. Instead we
|
|
15
|
-
* `@graph` whose Article
|
|
16
|
-
* (the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* A brand page usually already emits a `WebPage` + `Article` (or `BlogPosting`) graph —
|
|
13
|
+
* Yoast on WordPress, the theme on Shopify, the framework on headless. If we emit a
|
|
14
|
+
* SECOND top-level `Article`, crawlers see two competing primary entities. Instead we
|
|
15
|
+
* emit a `@graph` whose WebPage / Article nodes carry the SAME `@id`s the page's own
|
|
16
|
+
* nodes use (the unmodified canonical URL for the page, the page URL + `#article` for
|
|
17
|
+
* the article — the Yoast convention) so a consumer MERGES our `reviewedBy` /
|
|
18
|
+
* `lastReviewed` / `dateModified` into the existing nodes by `@id` rather than
|
|
19
|
+
* duplicating them. Our own nodes are keyed to the CERTIFICATION instead (the verify
|
|
20
|
+
* URL, the expert's profile, the fixed CertREV organization id) — never under the host
|
|
21
|
+
* page's `@id` namespace — so they cannot clash with the host page's nodes.
|
|
20
22
|
*
|
|
21
23
|
* The Person we attach is the reviewing EXPERT (schema.org `reviewedBy`), which is the
|
|
22
24
|
* E-E-A-T signal CertREV exists to add — not the author. The author (content byline) is
|
|
23
25
|
* attached as `author` only when present + distinct.
|
|
26
|
+
*
|
|
27
|
+
* VISIBILITY: "Don't mark up content that is not visible to readers" (Google's
|
|
28
|
+
* structured-data guidelines) is a HARD rule here, not a nicety. Every field on this
|
|
29
|
+
* graph that mirrors something the card paints behind a visibility flag — the memo, the
|
|
30
|
+
* author byline, the reviewer photo — is gated by the SAME `resolveDisplay` the card
|
|
31
|
+
* uses (which folds in the render-def's subtractive hide-set), so the graph can never
|
|
32
|
+
* assert to a crawler what the reader cannot see. Re-deriving those flags here instead
|
|
33
|
+
* of importing the card's resolver is exactly how the two would silently drift.
|
|
24
34
|
*/
|
|
25
35
|
|
|
26
|
-
import type
|
|
36
|
+
import { type ResolvedDisplay, resolveDisplay } from '../components/format.js'
|
|
37
|
+
import type { CertRenderDef } from '../components/render-def.js'
|
|
38
|
+
import type { CertPayload } from '../contract/kernel.js'
|
|
27
39
|
|
|
28
40
|
/** A JSON-LD node/graph value. Loose by design — this is wire JSON, not a TS model. */
|
|
29
41
|
export type JsonLdValue = Record<string, unknown>
|
|
@@ -31,12 +43,19 @@ export type JsonLdValue = Record<string, unknown>
|
|
|
31
43
|
export interface ProjectJsonLdOptions {
|
|
32
44
|
/**
|
|
33
45
|
* The canonical page URL the article renders on. Used to derive the primary
|
|
34
|
-
* Article node `@id` (`${pageUrl}#article`) so we merge
|
|
35
|
-
* instead of colliding. When omitted, the first
|
|
36
|
-
* used; when there is none either,
|
|
37
|
-
*
|
|
46
|
+
* WebPage / Article node `@id`s (`${pageUrl}` and `${pageUrl}#article`) so we merge
|
|
47
|
+
* into the host page's graph instead of colliding. When omitted, the first
|
|
48
|
+
* `subject.canonicalUrls` entry is used; when there is none either, the WebPage node
|
|
49
|
+
* is DROPPED and a CertREV-scoped article `@id` is derived from `verifyUrl` (still
|
|
50
|
+
* mergeable, just not aligned to a host node).
|
|
38
51
|
*/
|
|
39
52
|
readonly pageUrl?: string
|
|
53
|
+
/**
|
|
54
|
+
* The brand render-def in force for THIS face. Read for its SUBTRACTIVE hide-set only
|
|
55
|
+
* (its theming half means nothing to structured data): a brand that hides the memo or
|
|
56
|
+
* the reviewer photo on the card must not have it published in the graph either.
|
|
57
|
+
*/
|
|
58
|
+
readonly renderDef?: CertRenderDef
|
|
40
59
|
/**
|
|
41
60
|
* When true (default), emit the wrapping `{ "@context": "...", "@graph": [...] }`.
|
|
42
61
|
* When false, return just the array of nodes — for a caller that merges them into an
|
|
@@ -47,6 +66,22 @@ export interface ProjectJsonLdOptions {
|
|
|
47
66
|
|
|
48
67
|
const SCHEMA_CONTEXT = 'https://schema.org'
|
|
49
68
|
|
|
69
|
+
/**
|
|
70
|
+
* THE CertREV organization node `@id` — a hard CONSTANT, not a per-payload derivation.
|
|
71
|
+
*
|
|
72
|
+
* It used to be `${new URL(verifyUrl).origin}/#certrev-organization` under a comment
|
|
73
|
+
* claiming "one stable CertREV organization node across all pages", which it was not: a
|
|
74
|
+
* cert served from a second verify origin (a staging/preview host, a vanity verify
|
|
75
|
+
* domain, a future regional origin) minted a SECOND CertREV org node that would never
|
|
76
|
+
* merge with the first. CertREV is ONE real-world organization; its node id must not
|
|
77
|
+
* move because of which host happened to serve a verify link. Portal must emit this
|
|
78
|
+
* exact literal for the two halves to merge — see the package README.
|
|
79
|
+
*/
|
|
80
|
+
export const CERTREV_ORGANIZATION_ID = 'https://certrev.com/#certrev-organization'
|
|
81
|
+
|
|
82
|
+
/** CertREV's canonical homepage — the `url` of the organization node above, same reasoning. */
|
|
83
|
+
export const CERTREV_ORGANIZATION_URL = 'https://certrev.com'
|
|
84
|
+
|
|
50
85
|
/**
|
|
51
86
|
* Strip the query AND fragment from a URL for a stable, mergeable @id. The Yoast / host
|
|
52
87
|
* convention keys the primary Article node on `{path}#article` with no query — so a page
|
|
@@ -58,48 +93,73 @@ function baseUrl(url: string): string {
|
|
|
58
93
|
return url.slice(0, cut)
|
|
59
94
|
}
|
|
60
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The page this face renders on — the caller's `pageUrl` first (it knows the actual
|
|
98
|
+
* request), else the payload's primary canonical. Null when neither is known, which is
|
|
99
|
+
* the ONLY honest answer: we then assert nothing about any page.
|
|
100
|
+
*/
|
|
101
|
+
function pageBaseUrl(payload: CertPayload, opts: ProjectJsonLdOptions): string | null {
|
|
102
|
+
const page = opts.pageUrl ?? payload.subject.canonicalUrls[0]
|
|
103
|
+
return page ? baseUrl(page) : null
|
|
104
|
+
}
|
|
105
|
+
|
|
61
106
|
/** Derive the primary Article `@id`, aligned to the Yoast/host convention when we can. */
|
|
62
107
|
function articleId(payload: CertPayload, opts: ProjectJsonLdOptions): string {
|
|
63
|
-
const page = opts
|
|
64
|
-
if (page) return `${
|
|
108
|
+
const page = pageBaseUrl(payload, opts)
|
|
109
|
+
if (page) return `${page}#article`
|
|
65
110
|
// No page URL known — derive a stable, mergeable id from the verify URL.
|
|
66
111
|
return `${baseUrl(payload.content.verifyUrl)}#article`
|
|
67
112
|
}
|
|
68
113
|
|
|
69
|
-
/** CertREV-scoped node ids — namespaced under the
|
|
70
|
-
function reviewId(
|
|
71
|
-
return `${content.verifyUrl}#certrev-review`
|
|
114
|
+
/** CertREV-scoped node ids — namespaced under the certification, so they never clash. */
|
|
115
|
+
function reviewId(payload: CertPayload): string {
|
|
116
|
+
return `${payload.content.verifyUrl}#certrev-review`
|
|
72
117
|
}
|
|
73
|
-
function expertId(
|
|
74
|
-
|
|
118
|
+
function expertId(payload: CertPayload): string {
|
|
119
|
+
const { expert, verifyUrl } = payload.content
|
|
120
|
+
return expert.profileUrl ? `${expert.profileUrl}#person` : `${verifyUrl}#expert`
|
|
75
121
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `YYYY-MM-DD` head of an ISO-8601 instant, or null. `lastReviewed`'s schema.org
|
|
125
|
+
* range is `Date` — not `DateTime` — so the certified instant is truncated to its
|
|
126
|
+
* calendar day. String-sliced rather than `Date`-parsed on purpose: parsing would
|
|
127
|
+
* re-render the day in the runtime's timezone and break the determinism invariant
|
|
128
|
+
* (server and edge could disagree by a day).
|
|
129
|
+
*/
|
|
130
|
+
function isoDate(value: unknown): string | null {
|
|
131
|
+
if (typeof value !== 'string') return null
|
|
132
|
+
const match = /^\d{4}-\d{2}-\d{2}/.exec(value)
|
|
133
|
+
return match ? match[0] : null
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
/** Build the reviewing-expert Person node (the E-E-A-T signal). */
|
|
87
|
-
function expertPersonNode(payload: CertPayload): JsonLdValue {
|
|
137
|
+
function expertPersonNode(payload: CertPayload, display: ResolvedDisplay): JsonLdValue {
|
|
88
138
|
const { expert } = payload.content
|
|
89
139
|
const node: JsonLdValue = {
|
|
90
140
|
'@type': 'Person',
|
|
91
|
-
'@id': expertId(payload
|
|
141
|
+
'@id': expertId(payload),
|
|
92
142
|
name: expert.displayName,
|
|
93
143
|
}
|
|
94
144
|
if (expert.profileUrl) node.url = expert.profileUrl
|
|
95
|
-
|
|
145
|
+
// The photo is a brand-toggleable (`free`) field: a face that hides the reviewer photo
|
|
146
|
+
// must not publish one either. `showExpertPhoto` already folds in the render-def's
|
|
147
|
+
// `reviewerPhoto` hide-set. Deliberately NOT gated on `badgeStyle` — a compact card
|
|
148
|
+
// omits the photo as a LAYOUT choice, and the same payload may render full elsewhere on
|
|
149
|
+
// the page, so keying the graph to it would make the projection slot-dependent.
|
|
150
|
+
if (display.showExpertPhoto && expert.photoUrl) node.image = expert.photoUrl
|
|
96
151
|
if (expert.credentials.length > 0) {
|
|
97
152
|
// hasCredential → EducationalOccupationalCredential per credential; also a flat
|
|
98
|
-
// honorificSuffix list for consumers that don't read hasCredential.
|
|
153
|
+
// honorificSuffix list for consumers that don't read hasCredential. `recognizedBy`
|
|
154
|
+
// (a Credential property whose expected type is Organization) names CertREV as the
|
|
155
|
+
// body that verified it — the structured-data form of the face's locked
|
|
156
|
+
// "Credential verified by CertREV" attribution, and what the reviewedby-schema
|
|
157
|
+
// validator looks for.
|
|
99
158
|
node.hasCredential = expert.credentials.map((c) => ({
|
|
100
159
|
'@type': 'EducationalOccupationalCredential',
|
|
101
160
|
name: c.fullName,
|
|
102
161
|
alternateName: c.abbreviation,
|
|
162
|
+
recognizedBy: { '@id': CERTREV_ORGANIZATION_ID },
|
|
103
163
|
}))
|
|
104
164
|
node.honorificSuffix = expert.credentials.map((c) => c.abbreviation).join(', ')
|
|
105
165
|
}
|
|
@@ -107,54 +167,83 @@ function expertPersonNode(payload: CertPayload): JsonLdValue {
|
|
|
107
167
|
}
|
|
108
168
|
|
|
109
169
|
/** Build the CertREV organization node (publisher of the review credential). */
|
|
110
|
-
function certRevOrgNode(
|
|
111
|
-
let url = 'https://certrev.com'
|
|
112
|
-
try {
|
|
113
|
-
url = new URL(payload.content.verifyUrl).origin
|
|
114
|
-
} catch {
|
|
115
|
-
/* keep default */
|
|
116
|
-
}
|
|
170
|
+
function certRevOrgNode(): JsonLdValue {
|
|
117
171
|
return {
|
|
118
172
|
'@type': 'Organization',
|
|
119
|
-
'@id':
|
|
173
|
+
'@id': CERTREV_ORGANIZATION_ID,
|
|
120
174
|
name: 'CertREV',
|
|
121
|
-
url,
|
|
175
|
+
url: CERTREV_ORGANIZATION_URL,
|
|
122
176
|
}
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
/** Build the Review node binding the article to its expert review. */
|
|
126
|
-
function reviewNode(payload: CertPayload): JsonLdValue {
|
|
180
|
+
function reviewNode(payload: CertPayload, opts: ProjectJsonLdOptions, display: ResolvedDisplay): JsonLdValue {
|
|
127
181
|
const node: JsonLdValue = {
|
|
128
182
|
'@type': 'Review',
|
|
129
|
-
'@id': reviewId(payload
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
'@id': reviewId(payload),
|
|
184
|
+
// The SAME opts the Article node is built from. Passing `{}` here (as this did)
|
|
185
|
+
// pointed `itemReviewed` at the payload's canonical while the Article node carried
|
|
186
|
+
// the caller's `pageUrl` — an orphaned reference on every page whose URL differs
|
|
187
|
+
// from `canonicalUrls[0]` (locale prefix, `www`, trailing slash, a syndicated copy).
|
|
188
|
+
itemReviewed: { '@id': articleId(payload, opts) },
|
|
189
|
+
author: { '@id': expertId(payload) },
|
|
190
|
+
publisher: { '@id': CERTREV_ORGANIZATION_ID },
|
|
133
191
|
datePublished: payload.content.certifiedAt,
|
|
134
192
|
url: payload.content.verifyUrl,
|
|
135
193
|
}
|
|
136
|
-
|
|
194
|
+
// The memo is the one piece of REVIEW PROSE on the face, and it is brand-toggleable.
|
|
195
|
+
// Publishing `reviewBody` off a hidden memo marks up text no reader can see.
|
|
196
|
+
if (display.showMemo && payload.content.memo) node.reviewBody = payload.content.memo
|
|
137
197
|
return node
|
|
138
198
|
}
|
|
139
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The host page node — where `lastReviewed` and `reviewedBy` actually belong: BOTH are
|
|
202
|
+
* schema.org `WebPage` properties (`lastReviewed`: "Date on which the content on this web
|
|
203
|
+
* page was last reviewed for accuracy and/or completeness", range `Date`; `reviewedBy`:
|
|
204
|
+
* "People or organizations that have reviewed the content on this web page", range
|
|
205
|
+
* `Organization`/`Person`). Keyed on the UNMODIFIED canonical URL, the Yoast WebPage
|
|
206
|
+
* convention, so this merges into the host's existing WebPage node instead of adding a
|
|
207
|
+
* competing one — and, like the Article node, it carries ONLY the certification-relevant
|
|
208
|
+
* properties so a merge never overwrites the host's richer page fields.
|
|
209
|
+
*
|
|
210
|
+
* Returns null when no page URL is known: `lastReviewed` on a page we cannot name would
|
|
211
|
+
* either be a dangling node or — worse — an assertion about the CertREV verify page,
|
|
212
|
+
* which is not the reviewed content.
|
|
213
|
+
*/
|
|
214
|
+
function webPageNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdValue | null {
|
|
215
|
+
const id = pageBaseUrl(payload, opts)
|
|
216
|
+
if (!id) return null
|
|
217
|
+
const lastReviewed = isoDate(payload.content.certifiedAt)
|
|
218
|
+
return {
|
|
219
|
+
'@type': 'WebPage',
|
|
220
|
+
'@id': id,
|
|
221
|
+
url: id,
|
|
222
|
+
...(lastReviewed ? { lastReviewed } : {}),
|
|
223
|
+
reviewedBy: { '@id': expertId(payload) },
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
140
227
|
/**
|
|
141
228
|
* The primary Article node — carries the SAME `@id` the host page's article uses so a
|
|
142
229
|
* consumer merges our `reviewedBy` / `dateModified` into the existing node rather than
|
|
143
230
|
* duplicating the primary entity. We deliberately emit ONLY the certification-relevant
|
|
144
|
-
* properties (reviewedBy, dateModified, plus author when distinct) — not
|
|
145
|
-
* image, etc. — so we never overwrite the host's richer Article fields
|
|
231
|
+
* properties (reviewedBy, dateModified, plus author when distinct AND shown) — not
|
|
232
|
+
* headline, body, image, etc. — so we never overwrite the host's richer Article fields
|
|
233
|
+
* on merge.
|
|
146
234
|
*/
|
|
147
|
-
function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdValue {
|
|
235
|
+
function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions, display: ResolvedDisplay): JsonLdValue {
|
|
148
236
|
const { content } = payload
|
|
149
237
|
const node: JsonLdValue = {
|
|
150
238
|
'@type': 'Article',
|
|
151
239
|
'@id': articleId(payload, opts),
|
|
152
|
-
reviewedBy: { '@id': expertId(
|
|
153
|
-
review: { '@id': reviewId(
|
|
240
|
+
reviewedBy: { '@id': expertId(payload) },
|
|
241
|
+
review: { '@id': reviewId(payload) },
|
|
154
242
|
}
|
|
155
243
|
if (content.contentModifiedAt) node.dateModified = content.contentModifiedAt
|
|
156
|
-
// Author only when
|
|
157
|
-
|
|
244
|
+
// Author only when the face SHOWS the byline (a `free`, brand-toggleable field) and it
|
|
245
|
+
// is present + distinct from the expert (don't double-attribute).
|
|
246
|
+
if (display.showAuthor && content.author.name && content.author.name !== content.expert.displayName) {
|
|
158
247
|
const author: JsonLdValue = { '@type': 'Person', name: content.author.name }
|
|
159
248
|
if (content.author.title) author.jobTitle = content.author.title
|
|
160
249
|
node.author = author
|
|
@@ -166,16 +255,23 @@ function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdVa
|
|
|
166
255
|
* Project the verified certification facts into a schema.org `@graph`.
|
|
167
256
|
*
|
|
168
257
|
* @param payload The cryptographically-verified payload (the same facts the badge renders).
|
|
169
|
-
* @param opts Page URL for @id alignment
|
|
258
|
+
* @param opts Page URL for @id alignment, the render-def whose hide-set gates
|
|
259
|
+
* visibility-bound fields, + graph-wrapping control.
|
|
170
260
|
* @returns A `{ "@context", "@graph" }` object (wrapGraph !== false) OR a bare
|
|
171
261
|
* array of nodes (wrapGraph === false) for merge-into-existing-graph callers.
|
|
172
262
|
*/
|
|
173
263
|
export function projectCertJsonLd(payload: CertPayload, opts: ProjectJsonLdOptions = {}): JsonLdValue | JsonLdValue[] {
|
|
264
|
+
// ONE resolution of the visibility flags, shared by every node — the same resolver
|
|
265
|
+
// (and therefore the same answer) the rendered card uses. No accent override: theming
|
|
266
|
+
// is meaningless to structured data.
|
|
267
|
+
const display = resolveDisplay(payload.content.display, undefined, opts.renderDef)
|
|
268
|
+
const page = webPageNode(payload, opts)
|
|
174
269
|
const nodes: JsonLdValue[] = [
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
270
|
+
...(page ? [page] : []),
|
|
271
|
+
articleNode(payload, opts, display),
|
|
272
|
+
reviewNode(payload, opts, display),
|
|
273
|
+
expertPersonNode(payload, display),
|
|
274
|
+
certRevOrgNode(),
|
|
179
275
|
]
|
|
180
276
|
if (opts.wrapGraph === false) return nodes
|
|
181
277
|
return { '@context': SCHEMA_CONTEXT, '@graph': nodes }
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// ⚠ VENDORED, BYTE-FAITHFUL relocation of portal `
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// ⚠ VENDORED, BYTE-FAITHFUL relocation of the portal's `avatar-resize` module. The body is
|
|
2
|
+
// byte-identical to the source the deployed `certrev-cert.js` was bundled from — keep it that
|
|
3
|
+
// way. Configurable asset origins for other headless brands are a deliberate post-relocation
|
|
4
|
+
// follow-up (byte-parity takes precedence now).
|
|
5
5
|
/**
|
|
6
|
-
* Avatar right-sizing
|
|
6
|
+
* Avatar right-sizing — the SINGLE source of truth for how a cert contributor/expert
|
|
7
7
|
* headshot URL is rewritten to a display-sized derivative. Referenced by the cert MODAL (TS,
|
|
8
8
|
* cert-modal-view.ts), mirrored by the Liquid snippet (certrev-avatar-url.liquid), and by the
|
|
9
|
-
* Cloudflare size-allowlist WAF rule
|
|
9
|
+
* Cloudflare size-allowlist WAF rule. Keep the three in lockstep — the
|
|
10
10
|
* `avatar-resize.test.ts` `SIZES`/param assertions are the tripwire.
|
|
11
11
|
*
|
|
12
|
-
* Architecture
|
|
12
|
+
* Architecture: the signed cert envelope carries ONE canonical full-res photoUrl
|
|
13
13
|
* (also the JSON-LD Person.image — never shrunk). Display sizing is a PRESENTATION concern, applied
|
|
14
14
|
* here via Cloudflare Image Resizing on the same zone as assets.certrev.com:
|
|
15
15
|
* https://assets.certrev.com/cdn-cgi/image/width=W,height=W,fit=cover,format=auto/<path>
|
|
@@ -17,20 +17,20 @@
|
|
|
17
17
|
* allowlist is needed; format=auto serves webp/avif; the transform is cached + billed per-unique.
|
|
18
18
|
*
|
|
19
19
|
* ENABLEMENT GATE (`AVATAR_RESIZE_ENABLED`): OFF until Cloudflare Image Resizing is enabled on the
|
|
20
|
-
* certrev.com zone AND a real photo is verified to resize
|
|
20
|
+
* certrev.com zone AND a real photo is verified to resize. While OFF, avatars use
|
|
21
21
|
* the raw canonical URL — byte-identical to today's behavior — so the extension can deploy for the
|
|
22
22
|
* font de-block with ZERO avatar regression (a cdn-cgi/image URL 404s → initials fallback if the
|
|
23
23
|
* feature isn't live). Going live is a one-line flip here + in certrev-avatar-url.liquid, then the
|
|
24
|
-
* raw branch is removed (flag-debt:
|
|
24
|
+
* raw branch is removed (flag-debt: the avatar go-live follow-up).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Enablement gate — LIVE as of 2026-07-20
|
|
29
|
-
* is enabled on the certrev.com zone (`image_resizing = on`), and the size-allowlist WAF rule
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Enablement gate — LIVE as of 2026-07-20. Cloudflare Image Resizing (Transformations)
|
|
29
|
+
* is enabled on the certrev.com zone (`image_resizing = on`), and the size-allowlist WAF rule is
|
|
30
|
+
* verified enforcing on `/cdn-cgi/image/` paths — a real photo resizes (`width=84` → 200
|
|
31
|
+
* image/jpeg) while a disallowed size (`width=999`) is 403-blocked.
|
|
32
32
|
* Kept as a one-line kill-switch: flip back to `false` + redeploy to instantly revert to raw URLs if
|
|
33
|
-
* resizing ever misbehaves (removing the raw branch entirely is the flag-debt follow-up
|
|
33
|
+
* resizing ever misbehaves (removing the raw branch entirely is the flag-debt follow-up).
|
|
34
34
|
*/
|
|
35
35
|
export const AVATAR_RESIZE_ENABLED = true;
|
|
36
36
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure envelope → modal-HTML mapping for `<certrev-cert-modal
|
|
2
|
+
* Pure envelope → modal-HTML mapping for `<certrev-cert-modal>`.
|
|
3
3
|
*
|
|
4
4
|
* DOM-free and side-effect-free so it unit-tests without a browser: given the
|
|
5
5
|
* signed envelope's `payload.content`, it returns the inner HTML of the certificate
|
|
@@ -11,19 +11,23 @@
|
|
|
11
11
|
* - Every dynamic value is `escapeHtml`'d (the envelope is signed, but a reviewer
|
|
12
12
|
* name / memo is still attacker-adjacent content).
|
|
13
13
|
* - Only `http(s)` hrefs render (mirrors the Liquid `certrev-safe-url` guard).
|
|
14
|
-
* - Verified-only fields
|
|
14
|
+
* - Verified-only fields: the bio binds the envelope's verified
|
|
15
15
|
* `background`/`bio`; this module never reaches for a raw column.
|
|
16
16
|
* - Graceful degradation: a missing article title drops the "Certifies the article"
|
|
17
17
|
* block; a missing display cert id drops that meta column; no credentials → no
|
|
18
18
|
* chips / no creds line; no bio → no bio paragraph. Nothing renders an empty gap.
|
|
19
|
-
* -
|
|
19
|
+
* - Display-eviction accept-on-read: an ABSENT `display` block resolves to the
|
|
20
20
|
* preset default (show-all) — every read below is `display?.x !== false` / `=== false`,
|
|
21
21
|
* so `undefined` never suppresses a field. The modal is the LOCKED navy trust chrome and
|
|
22
22
|
* is deliberately NOT themeable by a brand render-def; it reads visibility off the signed
|
|
23
23
|
* envelope only, so no render-def wiring belongs here.
|
|
24
|
+
* - Type tolerance, not just shape tolerance: `content` is a `JSON.parse(...) as` cast off an
|
|
25
|
+
* envelope whose SIGNATURE was checked and whose SHAPE never was, so every read below goes
|
|
26
|
+
* through `asText` before it touches a string method. `(x || '').trim()` looks defensive but
|
|
27
|
+
* throws on a number, and a throw here is the dialog the reader just clicked failing to open.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
import { escapeHtml } from './escape.js'
|
|
30
|
+
import { asText, escapeHtml } from './escape.js'
|
|
27
31
|
import { resizeAvatarUrl } from './avatar-resize.js'
|
|
28
32
|
import { CERT_SCOPE_LINE, formatCertDisplayDate } from './display-strings.js'
|
|
29
33
|
import { checkIcon, chevronMark, verifiedStamp } from './certrev-cert-modal.styles.js'
|
|
@@ -41,7 +45,7 @@ export interface CertModalExpert {
|
|
|
41
45
|
photoUrl?: string | null
|
|
42
46
|
bio?: string | null
|
|
43
47
|
background?: string | null
|
|
44
|
-
/**
|
|
48
|
+
/** The pre-composed material-connection label ('Compensated expert') or null/absent for
|
|
45
49
|
* a pro-bono reviewer. Present → the disclosure footer renders the label; null/absent → label omitted. */
|
|
46
50
|
compensationCue?: string | null
|
|
47
51
|
}
|
|
@@ -56,12 +60,29 @@ export interface CertModalContent {
|
|
|
56
60
|
display?: { showExpertPhoto?: boolean | null; showBio?: boolean | null } | null
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The `rel` every CertREV anchor in this module carries.
|
|
65
|
+
*
|
|
66
|
+
* `../components/rel.ts` (`CERTREV_LINK_REL`) is the SOURCE OF TRUTH for the qualification
|
|
67
|
+
* tokens and the policy reasoning behind them — read it there. The value is duplicated rather
|
|
68
|
+
* than imported because `src/modal/` is bundled standalone into the dependency-free
|
|
69
|
+
* `certrev-cert.js` IIFE (`build:modal-bundle`) and imports NOTHING from `src/components/`;
|
|
70
|
+
* crossing that boundary for a string constant would pull the SSR module graph into a
|
|
71
|
+
* storefront asset. This copy adds `noreferrer` on top of the shared tokens: both anchors here
|
|
72
|
+
* are `target="_blank"`, where it still earns its place.
|
|
73
|
+
*
|
|
74
|
+
* Keep in lockstep with `CERTREV_LINK_REL` — `nofollow` + `sponsored` are the qualification the
|
|
75
|
+
* link-spam policy names, and dropping either from one copy silently un-qualifies half the
|
|
76
|
+
* package's links.
|
|
77
|
+
*/
|
|
78
|
+
export const CERTREV_MODAL_LINK_REL = 'nofollow sponsored noopener noreferrer'
|
|
79
|
+
|
|
59
80
|
const HONORIFIC_RE = /^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i
|
|
60
81
|
|
|
61
82
|
/** The bare given+family name: strip the leading honorific AND the trailing post-nominal
|
|
62
83
|
* (`, MD`), so "Dr. Erik Schraga, MD" → "Erik Schraga". */
|
|
63
84
|
function coreName(displayName: string): string {
|
|
64
|
-
const withoutHonorific = displayName.replace(HONORIFIC_RE, '').trim()
|
|
85
|
+
const withoutHonorific = asText(displayName).replace(HONORIFIC_RE, '').trim()
|
|
65
86
|
const comma = withoutHonorific.indexOf(',')
|
|
66
87
|
return (comma === -1 ? withoutHonorific : withoutHonorific.slice(0, comma)).trim()
|
|
67
88
|
}
|
|
@@ -82,24 +103,28 @@ export function signatureName(displayName: string): string {
|
|
|
82
103
|
|
|
83
104
|
/** `https://certrev.com/verify/x` → `certrev.com/verify/x` (footer URL line). */
|
|
84
105
|
export function stripProtocol(url: string): string {
|
|
85
|
-
return url.replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
|
106
|
+
return asText(url).replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
/** Return the url only if it is an absolute http(s) URL, else `#` (safe-url guard). */
|
|
89
110
|
export function safeHref(url: string | null | undefined): string {
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
const href = asText(url).trim()
|
|
112
|
+
if (!href) return '#'
|
|
113
|
+
return /^https?:\/\//i.test(href) ? href : '#'
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
/** A single credential's display label (cardLabel ?? fullName ?? abbreviation). */
|
|
95
|
-
function credLabel(c: CertModalCredential): string {
|
|
96
|
-
return (c
|
|
117
|
+
function credLabel(c: CertModalCredential | null | undefined): string {
|
|
118
|
+
return (asText(c?.cardLabel) || asText(c?.fullName) || asText(c?.abbreviation)).trim()
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
/** The verified credential labels, in order, de-duped + non-empty. */
|
|
100
122
|
function credLabels(expert: CertModalExpert | null | undefined): string[] {
|
|
101
123
|
const out: string[] = []
|
|
102
|
-
|
|
124
|
+
// `Array.isArray`, not `?? []`: a `credentials` that parsed as an object is not iterable, and
|
|
125
|
+
// `for…of` over one throws before any of the per-entry guards below get a chance to run.
|
|
126
|
+
const credentials = Array.isArray(expert?.credentials) ? expert.credentials : []
|
|
127
|
+
for (const c of credentials) {
|
|
103
128
|
const label = credLabel(c)
|
|
104
129
|
if (label && !out.includes(label)) out.push(label)
|
|
105
130
|
}
|
|
@@ -109,7 +134,7 @@ function credLabels(expert: CertModalExpert | null | undefined): string[] {
|
|
|
109
134
|
/** The reviewer bio — verified background preferred, then verified bio. */
|
|
110
135
|
function bioText(expert: CertModalExpert | null | undefined, display: CertModalContent['display']): string {
|
|
111
136
|
if (display?.showBio === false) return ''
|
|
112
|
-
return (expert?.background || expert?.bio
|
|
137
|
+
return (asText(expert?.background) || asText(expert?.bio)).trim()
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
/** An avatar cell — the cert-frozen photo (when shown) or the initials chip. */
|
|
@@ -119,9 +144,9 @@ function avatarHtml(
|
|
|
119
144
|
sizeClass: string,
|
|
120
145
|
showPhoto: boolean,
|
|
121
146
|
): string {
|
|
122
|
-
const photo = (expert?.photoUrl
|
|
147
|
+
const photo = asText(expert?.photoUrl).trim()
|
|
123
148
|
if (showPhoto && /^https?:\/\//i.test(photo)) {
|
|
124
|
-
// Display px from the size class; request the @2x derivative for retina crispness
|
|
149
|
+
// Display px from the size class; request the @2x derivative for retina crispness.
|
|
125
150
|
// resizeAvatarUrl is host-guarded + enablement-gated: until Cloudflare Image Resizing is live it
|
|
126
151
|
// returns the raw canonical URL, so the modal never 404s an unresized host.
|
|
127
152
|
const displayPx = sizeClass.includes('--64') ? 64 : 56
|
|
@@ -142,14 +167,14 @@ function certifiedChip(cls: string): string {
|
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
/**
|
|
145
|
-
* The FTC disclosure footer
|
|
170
|
+
* The FTC disclosure footer, rendered on both dialogs. The material-connection cue
|
|
146
171
|
* (`expert.compensationCue`, a FINISHED string off the signed envelope) leads ONLY when the reviewer
|
|
147
172
|
* was compensated; a pro-bono reviewer (null/absent cue) drops the label but keeps the scope line —
|
|
148
173
|
* which makes no compensation claim, so it is true for volunteers too and renders unconditionally.
|
|
149
174
|
* The scope line is the verbatim FTC copy (`CERT_SCOPE_LINE`), never re-assembled here.
|
|
150
175
|
*/
|
|
151
176
|
function disclosureFooter(expert: CertModalExpert | null | undefined): string {
|
|
152
|
-
const cue = (expert?.compensationCue
|
|
177
|
+
const cue = asText(expert?.compensationCue).trim()
|
|
153
178
|
const cueHtml = cue ? `<span class="crm-disclosure__cue">${escapeHtml(cue)}</span> ` : ''
|
|
154
179
|
return `<div class="crm-disclosure">${cueHtml}${escapeHtml(CERT_SCOPE_LINE)}</div>`
|
|
155
180
|
}
|
|
@@ -161,15 +186,15 @@ function disclosureFooter(expert: CertModalExpert | null | undefined): string {
|
|
|
161
186
|
*/
|
|
162
187
|
export function renderCertDialogInner(content: CertModalContent): string | null {
|
|
163
188
|
const expert = content.expert ?? null
|
|
164
|
-
const name = (expert?.displayName
|
|
189
|
+
const name = asText(expert?.displayName).trim()
|
|
165
190
|
if (!name) return null
|
|
166
191
|
|
|
167
192
|
const showPhoto = content.display?.showExpertPhoto !== false
|
|
168
193
|
const creds = credLabels(expert).join(' · ')
|
|
169
194
|
const issued = formatCertDisplayDate(content.certifiedAt) ?? ''
|
|
170
|
-
const certId = (content.displayCertId
|
|
171
|
-
const title = (content.articleTitle
|
|
172
|
-
const verifyUrl = (content.verifyUrl
|
|
195
|
+
const certId = asText(content.displayCertId).trim()
|
|
196
|
+
const title = asText(content.articleTitle).trim()
|
|
197
|
+
const verifyUrl = asText(content.verifyUrl).trim()
|
|
173
198
|
const sig = signatureName(name)
|
|
174
199
|
|
|
175
200
|
const certifiesBlock = title
|
|
@@ -182,17 +207,17 @@ export function renderCertDialogInner(content: CertModalContent): string | null
|
|
|
182
207
|
|
|
183
208
|
const urlLine = verifyUrl ? `<div class="crm-sig__url">${escapeHtml(stripProtocol(verifyUrl))}</div>` : ''
|
|
184
209
|
|
|
185
|
-
return `${CLOSE_BUTTON}${headHtml('Certificate of Expert Review')}<div class="crm-body--cert"><div class="crm-expert-row">${avatarHtml(expert, name, 'crm-avatar--56', showPhoto)}<div class="crm-expert-row__info"><div class="crm-expert-row__eyebrow">Reviewing Expert</div><div class="crm-expert-row__name">${escapeHtml(name)}</div>${creds ? `<div class="crm-expert-row__creds">${escapeHtml(creds)}</div>` : ''}</div></div>${certifiesBlock}<div class="crm-meta"><div class="crm-meta__col"><div class="crm-meta__label">Issued</div><div class="crm-meta__value">${escapeHtml(issued)}</div></div>${certIdCol}<div class="crm-meta__col"><div class="crm-meta__label">Status</div><div class="crm-meta__status">${certifiedChip('crm-chip')}</div></div></div><div class="crm-sig"><div><div class="crm-sig__name">${escapeHtml(sig)}</div><div class="crm-sig__rule"></div>${urlLine}</div>${verifiedStamp('cert')}</div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(verifyUrl))}" target="_blank" rel="
|
|
210
|
+
return `${CLOSE_BUTTON}${headHtml('Certificate of Expert Review')}<div class="crm-body--cert"><div class="crm-expert-row">${avatarHtml(expert, name, 'crm-avatar--56', showPhoto)}<div class="crm-expert-row__info"><div class="crm-expert-row__eyebrow">Reviewing Expert</div><div class="crm-expert-row__name">${escapeHtml(name)}</div>${creds ? `<div class="crm-expert-row__creds">${escapeHtml(creds)}</div>` : ''}</div></div>${certifiesBlock}<div class="crm-meta"><div class="crm-meta__col"><div class="crm-meta__label">Issued</div><div class="crm-meta__value">${escapeHtml(issued)}</div></div>${certIdCol}<div class="crm-meta__col"><div class="crm-meta__label">Status</div><div class="crm-meta__status">${certifiedChip('crm-chip')}</div></div></div><div class="crm-sig"><div><div class="crm-sig__name">${escapeHtml(sig)}</div><div class="crm-sig__rule"></div>${urlLine}</div>${verifiedStamp('cert')}</div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(verifyUrl))}" target="_blank" rel="${CERTREV_MODAL_LINK_REL}">View full certificate</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
|
|
186
211
|
}
|
|
187
212
|
|
|
188
213
|
/**
|
|
189
|
-
* The reviewer/profile modal
|
|
190
|
-
* reviewer name (fail-closed). The affiliation subtitle is intentionally omitted
|
|
191
|
-
*
|
|
214
|
+
* The reviewer/profile modal inner HTML. Returns `null` when there is no
|
|
215
|
+
* reviewer name (fail-closed). The affiliation subtitle is intentionally omitted:
|
|
216
|
+
* there is no verified data source for it.
|
|
192
217
|
*/
|
|
193
218
|
export function renderExpertDialogInner(content: CertModalContent): string | null {
|
|
194
219
|
const expert = content.expert ?? null
|
|
195
|
-
const name = (expert?.displayName
|
|
220
|
+
const name = asText(expert?.displayName).trim()
|
|
196
221
|
if (!name) return null
|
|
197
222
|
|
|
198
223
|
const showPhoto = content.display?.showExpertPhoto !== false
|
|
@@ -206,5 +231,5 @@ export function renderExpertDialogInner(content: CertModalContent): string | nul
|
|
|
206
231
|
const bioHtml = bio ? `<p class="crm-bio">${escapeHtml(bio)}</p>` : ''
|
|
207
232
|
const trustText = date ? `Independently reviewed · Certified ${escapeHtml(date)}` : 'Independently reviewed'
|
|
208
233
|
|
|
209
|
-
return `${CLOSE_BUTTON}${headHtml('Verified reviewer')}<div class="crm-body--expert"><div class="crm-id">${avatarHtml(expert, name, 'crm-avatar--64', showPhoto)}<div class="crm-id__info"><div class="crm-id__name">${escapeHtml(name)}</div></div></div>${chipsHtml}${bioHtml}<div class="crm-trust">${certifiedChip('crm-trust__chip')}<span class="crm-trust__text">${trustText}</span></div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(expert?.profileUrl))}" target="_blank" rel="
|
|
234
|
+
return `${CLOSE_BUTTON}${headHtml('Verified reviewer')}<div class="crm-body--expert"><div class="crm-id">${avatarHtml(expert, name, 'crm-avatar--64', showPhoto)}<div class="crm-id__info"><div class="crm-id__name">${escapeHtml(name)}</div></div></div>${chipsHtml}${bioHtml}<div class="crm-trust">${certifiedChip('crm-trust__chip')}<span class="crm-trust__text">${trustText}</span></div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(expert?.profileUrl))}" target="_blank" rel="${CERTREV_MODAL_LINK_REL}">View full profile on CertREV</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
|
|
210
235
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scoped styles + inline SVGs for the `<certrev-cert-modal>` Shadow-DOM element
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* (`docs/claude/design/certificate-modals/`, footer variant 3a + reviewer 4b).
|
|
2
|
+
* Scoped styles + inline SVGs for the `<certrev-cert-modal>` Shadow-DOM element.
|
|
3
|
+
* The single source of truth for the certificate + reviewer modal look, ported
|
|
4
|
+
* byte-faithfully from the approved design reference.
|
|
6
5
|
*
|
|
7
6
|
* Because the element renders inside a shadow root, this CSS is fully scoped: the
|
|
8
7
|
* brand storefront theme cannot bleed in, and these rules never leak out — so the
|
|
@@ -16,7 +15,8 @@
|
|
|
16
15
|
import { LOGO_PATHS } from './logo-paths.js'
|
|
17
16
|
|
|
18
17
|
/** The four-chevron CertREV mark (viewBox 0 0 375 375), tinted via the passed fill. Path data is
|
|
19
|
-
* single-sourced from
|
|
18
|
+
* single-sourced from the portal design-guide's logo module (the SOT the codegen mirrors into
|
|
19
|
+
* every twin). */
|
|
20
20
|
export function chevronMark(size: number, fill: string): string {
|
|
21
21
|
const paths = LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"/>`).join('')
|
|
22
22
|
return `<svg width="${size}" height="${size}" viewBox="0 0 375 375" aria-hidden="true" style="display:block;flex-shrink:0">${paths}</svg>`
|
|
@@ -52,7 +52,7 @@ export const CERT_MODAL_FONT_FAMILIES = [
|
|
|
52
52
|
'Allura',
|
|
53
53
|
] as const
|
|
54
54
|
|
|
55
|
-
// The modal's fonts are self-hosted
|
|
55
|
+
// The modal's fonts are self-hosted: the standalone loader (certrev-cert-modal.ts)
|
|
56
56
|
// injects the shared CERT_FONT_FACE_CSS @font-face block (R2 URLs) instead of a render-blocking
|
|
57
57
|
// Google Fonts stylesheet. In the theme-app-extension the host page already declares these via
|
|
58
58
|
// certrev-cert.css, so `data-fonts="host"` suppresses the injection entirely.
|
|
@@ -96,7 +96,7 @@ export const CERT_MODAL_CSS = `
|
|
|
96
96
|
background: var(--cr-white);
|
|
97
97
|
color: var(--cr-navy);
|
|
98
98
|
font-family: var(--cr-sans);
|
|
99
|
-
/* fixed, NOT relative
|
|
99
|
+
/* fixed, NOT relative: a :modal dialog (showModal) renders in the TOP LAYER; a
|
|
100
100
|
* relative dialog anchors to its scrolled document position, so once the page is scrolled the
|
|
101
101
|
* panel renders off-screen while the ::backdrop still covers the viewport (measured top -1972 at
|
|
102
102
|
* scrollY 2537). fixed is viewport-anchored (the UA inset:0 + margin:auto centers it) and still
|
|
@@ -462,7 +462,7 @@ export const CERT_MODAL_CSS = `
|
|
|
462
462
|
color: var(--cr-navy-50);
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
/* FTC disclosure footer
|
|
465
|
+
/* FTC disclosure footer — material-connection cue (compensated only) + scope line
|
|
466
466
|
* (always). Rendered on both the certificate + reviewer dialogs, immediately above the actions. */
|
|
467
467
|
.crm-disclosure {
|
|
468
468
|
margin-top: 14px;
|