@certrev/cert-block 0.5.4 → 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 +195 -12
- package/LICENSE +21 -0
- package/README.md +422 -50
- 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 +14 -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__/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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `'full'` shows the photo, certified/updated dates, and the memo (per display flags). BOTH
|
|
16
16
|
* styles carry the compensation cue + FTC scope line (the always-on compliance disclosures).
|
|
17
17
|
*
|
|
18
|
-
* THEMING
|
|
18
|
+
* THEMING: presentation defaults come from `payload.content.display`; an optional
|
|
19
19
|
* `renderDef` prop layers a brand's validated themeable tokens (emitted as `--certrev-*`
|
|
20
20
|
* CSS custom properties) and its subtractive field-visibility on top. No `renderDef` → the
|
|
21
21
|
* CertREV preset default (theme output byte-identical to un-themed).
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import type { CertContent, CertPayload } from '../contract/kernel.js'
|
|
25
25
|
import { safeHttpUrl } from './escape.js'
|
|
26
|
-
import { credentialSuffix, expertNameWithCredentials, formatDate, resolveDisplay } from './format.js'
|
|
26
|
+
import { credentialSuffix, dedupeCredential, expertNameWithCredentials, formatDate, resolveDisplay } from './format.js'
|
|
27
|
+
import { CERTREV_LINK_REL } from './rel.js'
|
|
27
28
|
import {
|
|
28
29
|
CERT_SCOPE_LINE,
|
|
29
30
|
CERT_STRINGS_VERSION,
|
|
@@ -42,7 +43,7 @@ export interface CertBadgeProps {
|
|
|
42
43
|
/** Override the badge style independent of the signed display config. */
|
|
43
44
|
readonly badgeStyle?: 'full' | 'compact'
|
|
44
45
|
/**
|
|
45
|
-
* The brand render-def
|
|
46
|
+
* The brand render-def. ABSENT → the badge renders the CertREV preset
|
|
46
47
|
* default (fail-safe; theme output byte-identical to un-themed). PRESENT → its
|
|
47
48
|
* validated themeable tokens are emitted as `--certrev-*` CSS custom properties
|
|
48
49
|
* and its subtractive hide-set removes brand-toggleable fields. Invalid tokens
|
|
@@ -79,7 +80,8 @@ function ExpertPhoto({ content, show }: { content: CertContent; show: boolean })
|
|
|
79
80
|
<img
|
|
80
81
|
className={`${ROOT_CLASS}__photo`}
|
|
81
82
|
src={src}
|
|
82
|
-
|
|
83
|
+
// Decorative: the byline renders the same name as text right beside it.
|
|
84
|
+
alt=""
|
|
83
85
|
width={40}
|
|
84
86
|
height={40}
|
|
85
87
|
loading="lazy"
|
|
@@ -98,7 +100,10 @@ export function CertBadge(props: CertBadgeProps) {
|
|
|
98
100
|
const profileUrl = safeHttpUrl(content.expert.profileUrl)
|
|
99
101
|
const certifiedLabel = formatDate(content.certifiedAt)
|
|
100
102
|
const updatedLabel = formatDate(content.contentModifiedAt)
|
|
101
|
-
|
|
103
|
+
// Composed name + suffix, so the suffix drops a credential the stored display name already
|
|
104
|
+
// carries ("Dr. Erik Schraga, MD" + "MD, EM" → one "MD"). `credentialSuffix` stays whole for
|
|
105
|
+
// callers that render the list away from the name.
|
|
106
|
+
const suffix = dedupeCredential(content.expert.displayName, credentialSuffix(content))
|
|
102
107
|
const accent = display.accentColor
|
|
103
108
|
|
|
104
109
|
const rootClass = `${ROOT_CLASS} ${ROOT_CLASS}--${style}${props.className ? ` ${props.className}` : ''}`
|
|
@@ -107,7 +112,7 @@ export function CertBadge(props: CertBadgeProps) {
|
|
|
107
112
|
// A THEMED brand additionally emits the surface/radius/font vars; with no render-def
|
|
108
113
|
// `themeCssVarDeclarations` is empty, so the un-themed style is byte-identical.
|
|
109
114
|
const rootStyle: Record<string, string> = {
|
|
110
|
-
|
|
115
|
+
'--certrev-accent': accent,
|
|
111
116
|
borderInlineStartColor: accent,
|
|
112
117
|
}
|
|
113
118
|
for (const [name, value] of themeCssVarDeclarations(theme)) rootStyle[name] = value
|
|
@@ -136,7 +141,7 @@ export function CertBadge(props: CertBadgeProps) {
|
|
|
136
141
|
<span className={`${ROOT_CLASS}__byline`}>
|
|
137
142
|
Reviewed by{' '}
|
|
138
143
|
{profileUrl ? (
|
|
139
|
-
<a className={`${ROOT_CLASS}__expert-link`} href={profileUrl} rel=
|
|
144
|
+
<a className={`${ROOT_CLASS}__expert-link`} href={profileUrl} rel={CERTREV_LINK_REL}>
|
|
140
145
|
{expertName}
|
|
141
146
|
</a>
|
|
142
147
|
) : (
|
|
@@ -189,7 +194,7 @@ export function CertBadge(props: CertBadgeProps) {
|
|
|
189
194
|
<span className={`${ROOT_CLASS}__compensated-cue`}>
|
|
190
195
|
Compensated{' '}
|
|
191
196
|
{profileUrl ? (
|
|
192
|
-
<a className={`${ROOT_CLASS}__cue-link`} href={profileUrl} rel=
|
|
197
|
+
<a className={`${ROOT_CLASS}__cue-link`} href={profileUrl} rel={CERTREV_LINK_REL}>
|
|
193
198
|
expert
|
|
194
199
|
</a>
|
|
195
200
|
) : (
|
|
@@ -205,7 +210,7 @@ export function CertBadge(props: CertBadgeProps) {
|
|
|
205
210
|
<a
|
|
206
211
|
className={`${ROOT_CLASS}__verify`}
|
|
207
212
|
href={verifyUrl}
|
|
208
|
-
rel=
|
|
213
|
+
rel={CERTREV_LINK_REL}
|
|
209
214
|
aria-label="Verify this certification on CertREV"
|
|
210
215
|
>
|
|
211
216
|
Verify on CertREV
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { CertPayload } from '../contract/kernel.js'
|
|
17
|
-
import {
|
|
17
|
+
import { type ProjectJsonLdOptions, projectCertJsonLd, serializeJsonLdForScript } from '../jsonld/project.js'
|
|
18
18
|
|
|
19
19
|
export interface CertJsonLdProps extends ProjectJsonLdOptions {
|
|
20
20
|
readonly payload: CertPayload
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { CertPayload } from '../contract/kernel.js'
|
|
11
11
|
import { safeHttpUrl } from './escape.js'
|
|
12
12
|
import { resolveDisplay } from './format.js'
|
|
13
|
+
import { CERTREV_LINK_REL } from './rel.js'
|
|
13
14
|
|
|
14
15
|
export interface CertRevBacklinkProps {
|
|
15
16
|
readonly payload: CertPayload
|
|
@@ -28,14 +29,14 @@ export function CertRevBacklink(props: CertRevBacklinkProps) {
|
|
|
28
29
|
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`
|
|
29
30
|
// Typed as a string record (not inline) so the `--certrev-accent` CSS custom property
|
|
30
31
|
// is accepted — React's CSSProperties rejects arbitrary custom props on an inline literal.
|
|
31
|
-
const rootStyle: Record<string, string> = {
|
|
32
|
+
const rootStyle: Record<string, string> = { '--certrev-accent': display.accentColor, color: display.accentColor }
|
|
32
33
|
|
|
33
34
|
return (
|
|
34
35
|
<a
|
|
35
36
|
className={rootClass}
|
|
36
37
|
style={rootStyle}
|
|
37
38
|
href={verifyUrl}
|
|
38
|
-
rel=
|
|
39
|
+
rel={CERTREV_LINK_REL}
|
|
39
40
|
aria-label="Verify this certification on CertREV"
|
|
40
41
|
>
|
|
41
42
|
<svg
|
|
@@ -55,7 +56,13 @@ export function CertRevBacklink(props: CertRevBacklinkProps) {
|
|
|
55
56
|
strokeWidth="1.8"
|
|
56
57
|
strokeLinejoin="round"
|
|
57
58
|
/>
|
|
58
|
-
<path
|
|
59
|
+
<path
|
|
60
|
+
d="M8.5 12l2.3 2.3L15.5 9"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
strokeWidth="1.8"
|
|
63
|
+
strokeLinecap="round"
|
|
64
|
+
strokeLinejoin="round"
|
|
65
|
+
/>
|
|
59
66
|
</svg>
|
|
60
67
|
<span className={`${ROOT_CLASS}__label`}>{props.label ?? 'Verify on CertREV'}</span>
|
|
61
68
|
</a>
|
|
@@ -22,7 +22,7 @@ export interface CertReviewProps {
|
|
|
22
22
|
readonly className?: string
|
|
23
23
|
readonly badgeStyle?: 'full' | 'compact'
|
|
24
24
|
/**
|
|
25
|
-
* The brand render-def
|
|
25
|
+
* The brand render-def. Forwarded to `<CertBadge>` for themeable-token
|
|
26
26
|
* emission + subtractive visibility; absent → the CertREV preset default. The
|
|
27
27
|
* JSON-LD is presentation-blind, so the def never touches the structured data.
|
|
28
28
|
*/
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CertPayload } from '../contract/kernel.js'
|
|
10
10
|
import { safeHttpUrl } from './escape.js'
|
|
11
|
-
import { resolveDisplay } from './format.js'
|
|
11
|
+
import { credentialSuffix, dedupeCredential, resolveDisplay } from './format.js'
|
|
12
|
+
import { CERTREV_LINK_REL } from './rel.js'
|
|
12
13
|
|
|
13
14
|
export interface ExpertBioProps {
|
|
14
15
|
readonly payload: CertPayload
|
|
@@ -28,14 +29,16 @@ export function ExpertBio(props: ExpertBioProps) {
|
|
|
28
29
|
const photoUrl = display.showExpertPhoto ? safeHttpUrl(expert.photoUrl) : null
|
|
29
30
|
const Heading = props.headingLevel ?? 'h3'
|
|
30
31
|
const rootClass = `${ROOT_CLASS}${props.className ? ` ${props.className}` : ''}`
|
|
31
|
-
const rootStyle: Record<string, string> = {
|
|
32
|
+
const rootStyle: Record<string, string> = { '--certrev-accent': display.accentColor }
|
|
32
33
|
|
|
34
|
+
// Same composition rule as <CertBadge> / renderBadgeHtml: drop a credential the stored
|
|
35
|
+
// display name already carries, so one payload never reads "Dr. Erik Schraga, MD, MD, EM".
|
|
36
|
+
// The full list still renders below, away from the name, where every credential belongs.
|
|
37
|
+
const nameSuffix = dedupeCredential(expert.displayName, credentialSuffix(payload.content))
|
|
33
38
|
const nameNode = (
|
|
34
39
|
<Heading className={`${ROOT_CLASS}__name`}>
|
|
35
40
|
{expert.displayName}
|
|
36
|
-
{
|
|
37
|
-
<span className={`${ROOT_CLASS}__credentials`}>, {expert.credentials.map((c) => c.abbreviation).join(', ')}</span>
|
|
38
|
-
) : null}
|
|
41
|
+
{nameSuffix ? <span className={`${ROOT_CLASS}__credentials`}>, {nameSuffix}</span> : null}
|
|
39
42
|
</Heading>
|
|
40
43
|
)
|
|
41
44
|
|
|
@@ -45,7 +48,9 @@ export function ExpertBio(props: ExpertBioProps) {
|
|
|
45
48
|
<img
|
|
46
49
|
className={`${ROOT_CLASS}__photo`}
|
|
47
50
|
src={photoUrl}
|
|
48
|
-
|
|
51
|
+
// Decorative: the name is announced by this aside's aria-label AND rendered as text
|
|
52
|
+
// immediately below, so alt text repeats it a third time. Matches the modal's avatar.
|
|
53
|
+
alt=""
|
|
49
54
|
width={64}
|
|
50
55
|
height={64}
|
|
51
56
|
loading="lazy"
|
|
@@ -54,7 +59,7 @@ export function ExpertBio(props: ExpertBioProps) {
|
|
|
54
59
|
) : null}
|
|
55
60
|
<div className={`${ROOT_CLASS}__body`}>
|
|
56
61
|
{profileUrl ? (
|
|
57
|
-
<a className={`${ROOT_CLASS}__name-link`} href={profileUrl} rel=
|
|
62
|
+
<a className={`${ROOT_CLASS}__name-link`} href={profileUrl} rel={CERTREV_LINK_REL}>
|
|
58
63
|
{nameNode}
|
|
59
64
|
</a>
|
|
60
65
|
) : (
|
package/src/components/escape.ts
CHANGED
|
@@ -12,8 +12,40 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Everything here is pure and runtime-agnostic (no DOM, no Node APIs) so it runs in a
|
|
14
14
|
* server component, an edge runtime, and the Web Component identically.
|
|
15
|
+
*
|
|
16
|
+
* Every helper here is TYPE-HOSTILE-SAFE, and that is load-bearing rather than defensive
|
|
17
|
+
* habit: these run downstream of `get-verified-envelope.ts`'s bare
|
|
18
|
+
* `JSON.parse(...) as CertDeliveryEnvelope` cast, and `verifyEnvelope` checks the SIGNATURE,
|
|
19
|
+
* never the SHAPE — so a number / object / array arrives where the contract promised a
|
|
20
|
+
* string. A `TypeError` here is not a missing badge: under `renderToReadableStream` it aborts
|
|
21
|
+
* the whole article route with zero HTML, and the Builder SDK ships no error boundary on its
|
|
22
|
+
* node/edge builds. So the family's contract is NEVER THROW — see `asText`.
|
|
15
23
|
*/
|
|
16
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Coerce an untyped value to renderable text. The rule is deliberately narrow:
|
|
27
|
+
* • a string passes through untouched — every helper below preserves its exact string
|
|
28
|
+
* semantics, because `escapeHtml` is the ONLY XSS boundary four hand-built-HTML
|
|
29
|
+
* renderers have and any change to its string output is a security regression;
|
|
30
|
+
* • a finite number renders as its digits (a stray count is at least legible copy);
|
|
31
|
+
* • EVERYTHING ELSE — null, undefined, booleans, objects, arrays, functions, symbols,
|
|
32
|
+
* NaN/Infinity — renders as empty.
|
|
33
|
+
*
|
|
34
|
+
* The last clause is the important one, and it is why this is not `String(input)`. Blind
|
|
35
|
+
* coercion would splice `[object Object]` (or a hostile `toString`'s payload) into the
|
|
36
|
+
* article body, and on a null-prototype object it throws the very TypeError this exists to
|
|
37
|
+
* prevent. A value that is not text is not copy; the correct render of it is nothing.
|
|
38
|
+
*
|
|
39
|
+
* Exported for `format.ts` only — so a helper there can't drift back to a bare dereference —
|
|
40
|
+
* and deliberately NOT re-exported from the package entry: an internal invariant, not a
|
|
41
|
+
* contract surface consumers should build on.
|
|
42
|
+
*/
|
|
43
|
+
export function asText(input: unknown): string {
|
|
44
|
+
if (typeof input === 'string') return input
|
|
45
|
+
if (typeof input === 'number') return Number.isFinite(input) ? String(input) : ''
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
const HTML_ESCAPES: Record<string, string> = {
|
|
18
50
|
'&': '&',
|
|
19
51
|
'<': '<',
|
|
@@ -23,8 +55,8 @@ const HTML_ESCAPES: Record<string, string> = {
|
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
/** Escape text for safe inclusion in HTML element content or double-quoted attributes. */
|
|
26
|
-
export function escapeHtml(input: string): string {
|
|
27
|
-
return input.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c)
|
|
58
|
+
export function escapeHtml(input: string | null | undefined): string {
|
|
59
|
+
return asText(input).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c)
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
/** Alias kept explicit at call sites where the value lands in an attribute. */
|
|
@@ -37,8 +69,7 @@ export const escapeAttribute = escapeHtml
|
|
|
37
69
|
* Callers treat null as "omit the link".
|
|
38
70
|
*/
|
|
39
71
|
export function safeHttpUrl(input: string | null | undefined): string | null {
|
|
40
|
-
|
|
41
|
-
const trimmed = input.trim()
|
|
72
|
+
const trimmed = asText(input).trim()
|
|
42
73
|
if (trimmed === '') return null
|
|
43
74
|
// Site-relative or protocol-relative URLs are safe (no scheme to abuse).
|
|
44
75
|
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?')) return trimmed
|
|
@@ -57,8 +88,7 @@ export function safeHttpUrl(input: string | null | undefined): string | null {
|
|
|
57
88
|
* `url(...)`, or break out of the style attribute. Returns null on anything suspicious.
|
|
58
89
|
*/
|
|
59
90
|
export function safeCssColor(input: string | null | undefined): string | null {
|
|
60
|
-
|
|
61
|
-
const v = input.trim()
|
|
91
|
+
const v = asText(input).trim()
|
|
62
92
|
if (v === '') return null
|
|
63
93
|
if (/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v)) return v
|
|
64
94
|
if (/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/.test(v)) return v
|
|
@@ -68,5 +98,5 @@ export function safeCssColor(input: string | null | undefined): string | null {
|
|
|
68
98
|
|
|
69
99
|
/** Collapse interior whitespace + trim. Used to normalize display text. */
|
|
70
100
|
export function tidyText(input: string | null | undefined): string {
|
|
71
|
-
return (input
|
|
101
|
+
return asText(input).replace(/\s+/g, ' ').trim()
|
|
72
102
|
}
|
package/src/components/format.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { CertContent, CertDisplayConfig } from '../contract/kernel.js'
|
|
7
|
+
import { asText } from './escape.js'
|
|
7
8
|
import { type CertRenderDef, isFieldHidden, resolveRenderTheme } from './render-def.js'
|
|
8
9
|
|
|
9
10
|
/** Default accent if the brand didn't pin one (CertREV teal). */
|
|
@@ -49,24 +50,119 @@ export function resolveDisplay(
|
|
|
49
50
|
* We deliberately DON'T use `toLocaleDateString` — its output varies by runtime ICU data,
|
|
50
51
|
* which would make SSR output non-deterministic + cause hydration mismatches. Fixed
|
|
51
52
|
* English month abbreviations keep server + client byte-identical.
|
|
53
|
+
*
|
|
54
|
+
* ISO-8601 STRINGS ONLY — a non-string yields null. `Date.parse` coerces its argument, so an
|
|
55
|
+
* unguarded number sailed straight through as a YEAR: `formatDate(42)` returned "Jan 1, 2042"
|
|
56
|
+
* off a malformed envelope, while the number a caller would actually MEAN (epoch ms,
|
|
57
|
+
* 1750000000000) returned null. A date helper that invents a confident wrong certification
|
|
58
|
+
* date is worse than one that renders nothing, and the wire contract (`certifiedAt`,
|
|
59
|
+
* `contentModifiedAt`) is ISO-8601 strings — there is no second input format to honour.
|
|
52
60
|
*/
|
|
53
61
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
54
62
|
|
|
55
63
|
export function formatDate(iso: string | null | undefined): string | null {
|
|
56
|
-
if (
|
|
64
|
+
if (typeof iso !== 'string' || iso === '') return null
|
|
57
65
|
const ms = Date.parse(iso)
|
|
58
66
|
if (Number.isNaN(ms)) return null
|
|
59
67
|
const d = new Date(ms)
|
|
60
68
|
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* "PhD, RD" — the expert's credential abbreviations, comma-joined.
|
|
73
|
+
*
|
|
74
|
+
* Shape-guarded because the envelope reaching here was only SIGNATURE-checked: a signed
|
|
75
|
+
* payload whose `credentials` is absent or is not an array used to throw
|
|
76
|
+
* `TypeError: ...map is not a function` and take the host route down with it. An entry with
|
|
77
|
+
* no usable abbreviation is DROPPED rather than joined as empty — the old code rendered
|
|
78
|
+
* "Dr. Jane Doe, , RD" off one missing field. A well-formed envelope is byte-identical.
|
|
79
|
+
*/
|
|
64
80
|
export function credentialSuffix(content: CertContent): string {
|
|
65
|
-
|
|
81
|
+
const credentials = content?.expert?.credentials
|
|
82
|
+
if (!Array.isArray(credentials)) return ''
|
|
83
|
+
return credentials
|
|
84
|
+
.map((c) => asText(c?.abbreviation))
|
|
85
|
+
.filter((abbreviation) => abbreviation !== '')
|
|
86
|
+
.join(', ')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Split a comma-separated label into its trimmed, non-empty parts.
|
|
91
|
+
* "Dr. Erik Schraga, MD" → `['Dr. Erik Schraga', 'MD']`.
|
|
92
|
+
*/
|
|
93
|
+
function commaTokens(value: string): string[] {
|
|
94
|
+
return value
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((token) => token.trim())
|
|
97
|
+
.filter((token) => token !== '')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold one token to the form two spellings of the SAME credential share: case-insensitive,
|
|
102
|
+
* periods dropped, interior whitespace collapsed. "M.D." / "MD." / "md" → `md`.
|
|
103
|
+
*/
|
|
104
|
+
function credentialKey(token: string): string {
|
|
105
|
+
return token.toLowerCase().replace(/\./g, '').replace(/\s+/g, ' ').trim()
|
|
66
106
|
}
|
|
67
107
|
|
|
68
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Drop from `credential` any token the display NAME already prints, so a name that arrives
|
|
110
|
+
* carrying its own post-nominal doesn't get it appended a second time — the defect that put
|
|
111
|
+
* "Dr. Erik Schraga, MD, MD, EM" on a live customer page (P2-6).
|
|
112
|
+
*
|
|
113
|
+
* THE ROOT CAUSE IS UPSTREAM DATA, NOT THIS FUNCTION. Portal cleans an expert display name
|
|
114
|
+
* with `cleanExpertDisplayName` and has DB CHECK constraints for exactly this shape
|
|
115
|
+
* (migration 20260506151204), but `resolveStampReviewerName` skips the cleaner and
|
|
116
|
+
* `package_drafts.expert_display_name` carries no constraint — so the duplicate is STORED,
|
|
117
|
+
* and a consumer reading the Delivery API directly still receives it. This is the render
|
|
118
|
+
* boundary refusing to print the same claim twice, nothing more.
|
|
119
|
+
*
|
|
120
|
+
* THE RULE, and why it is drawn this narrowly — a false positive here would delete a REAL
|
|
121
|
+
* credential from a compliance-facing card, which is worse than the duplicate it fixes:
|
|
122
|
+
* • a credential token is dropped only when it equals a WHOLE comma-separated segment of
|
|
123
|
+
* the name. Never a substring: "MD" must survive against "Dr. MDonald", "Jane Amdahl"
|
|
124
|
+
* and "Md. Rahman", all of which a naive `name.includes(credential)` would gut;
|
|
125
|
+
* • comparison is `credentialKey`'d, so "M.D." in the name cancels "MD" in the credential;
|
|
126
|
+
* • the credential may be a LIST ("MD, FAAD"): each token is judged on its own, and the
|
|
127
|
+
* survivors keep their original spelling and order. A name carrying only "MD" therefore
|
|
128
|
+
* still renders ", FAAD" — no verified credential is ever lost to a partial overlap;
|
|
129
|
+
* • nothing duplicated ⇒ the credential string is returned VERBATIM (not re-joined), so
|
|
130
|
+
* the overwhelmingly common case is byte-identical to the pre-guard render.
|
|
131
|
+
*
|
|
132
|
+
* What it deliberately does NOT do: it never edits the NAME. "Dr. Erik Schraga, MD" keeps its
|
|
133
|
+
* inline "MD" — un-embedding a post-nominal is `cleanExpertDisplayName`'s job on the write
|
|
134
|
+
* path, and guessing at it here would be the same false-positive class in the other direction.
|
|
135
|
+
* It also cannot catch a duplicate spelled differently in the two fields ("Doctor of Medicine"
|
|
136
|
+
* in the name against "MD" in the credential) or one embedded mid-name rather than as its own
|
|
137
|
+
* segment ("Erik Schraga MD" with no comma) — both stay for the upstream fix.
|
|
138
|
+
*/
|
|
139
|
+
export function dedupeCredential(name: unknown, credential: unknown): string {
|
|
140
|
+
const suffix = asText(credential)
|
|
141
|
+
const tokens = commaTokens(suffix)
|
|
142
|
+
if (tokens.length === 0) return suffix
|
|
143
|
+
// Every segment of the name, not just the trailing ones: for a well-formed name the first
|
|
144
|
+
// segment is a human name and can never equal a credential token, and the one case where it
|
|
145
|
+
// does — a stored name that is NOTHING but a credential ("MD") — is exactly the case where
|
|
146
|
+
// dropping the suffix is right, since the token still renders once as the name.
|
|
147
|
+
const carried = new Set(commaTokens(asText(name)).map(credentialKey))
|
|
148
|
+
const kept = tokens.filter((token) => !carried.has(credentialKey(token)))
|
|
149
|
+
if (kept.length === tokens.length) return suffix
|
|
150
|
+
return kept.join(', ')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* "Dr. Jane Doe, PhD, RD" — display name with credential suffix appended.
|
|
155
|
+
*
|
|
156
|
+
* With no name there is nobody to attribute the review to, so the whole label is empty:
|
|
157
|
+
* credentials alone would read as "Content reviewed by PhD, RD" in `CertBadge`'s aria-label.
|
|
158
|
+
*
|
|
159
|
+
* The suffix passes `dedupeCredential` because this composes name AND credential into one
|
|
160
|
+
* string; `credentialSuffix` above stays the full verified list, so a caller rendering the
|
|
161
|
+
* credentials AWAY from the name (a chip row, a standalone line) still gets every one of them.
|
|
162
|
+
*/
|
|
69
163
|
export function expertNameWithCredentials(content: CertContent): string {
|
|
70
|
-
const
|
|
71
|
-
|
|
164
|
+
const name = asText(content?.expert?.displayName)
|
|
165
|
+
if (name === '') return ''
|
|
166
|
+
const suffix = dedupeCredential(name, credentialSuffix(content))
|
|
167
|
+
return suffix ? `${name}, ${suffix}` : name
|
|
72
168
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `renderPreviewBlock` — a STATIC, NON-DELIVERY preview of the cert block for pre-sales
|
|
3
|
-
* look-demos (
|
|
3
|
+
* look-demos (lifted from the Builder demo).
|
|
4
4
|
*
|
|
5
5
|
* The visual is BYTE-FOR-BYTE `renderCertBlock` (pixel-parity — "see it on your own site
|
|
6
6
|
* today"), wrapped in a `[data-certrev-preview]` container carrying a REQUIRED expiry.
|
|
@@ -37,7 +37,7 @@ export interface PreviewBlockOptions {
|
|
|
37
37
|
readonly labelText?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const DEFAULT_LABEL = 'Preview
|
|
40
|
+
const DEFAULT_LABEL = 'Preview · Not a verified certification'
|
|
41
41
|
|
|
42
42
|
/** Extract a preview block's baked expiry (ISO), or null if the HTML isn't a preview block. */
|
|
43
43
|
export function previewExpiry(html: string): string | null {
|
|
@@ -65,7 +65,7 @@ export function renderPreviewBlock(input: RenderCertBlockInput, options: Preview
|
|
|
65
65
|
? `<div data-certrev-preview-label style="font:500 11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;color:#9a9a9a;margin-bottom:8px;">${escapeHtml(options.labelText ?? DEFAULT_LABEL)}</div>`
|
|
66
66
|
: ''
|
|
67
67
|
return (
|
|
68
|
-
`<!-- CertREV PREVIEW
|
|
68
|
+
`<!-- CertREV PREVIEW: static, non-revocable, NOT a verified certification. Expires ${escapeHtml(expires)}. Never use as delivery. -->` +
|
|
69
69
|
`<div data-certrev-preview="1" data-certrev-preview-expires="${escapeAttribute(expires)}">` +
|
|
70
70
|
label +
|
|
71
71
|
renderCertBlock(input) +
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link qualification for every anchor this package emits to a CertREV-owned host.
|
|
3
|
+
*
|
|
4
|
+
* The package renders links the consumer did not author and cannot edit: `CertBadgeProps`
|
|
5
|
+
* has no `rel` input, and the string renderers build their anchors internally. That makes
|
|
6
|
+
* qualification the PACKAGE's job — a consumer who wanted to qualify them has no seam to
|
|
7
|
+
* do it through.
|
|
8
|
+
*
|
|
9
|
+
* Google's link-spam policy treats a link a vendor requires on a customer's page as
|
|
10
|
+
* paid/placed unless it is qualified, and names the remedy explicitly: it is not a
|
|
11
|
+
* violation "as long as they are qualified with rel='nofollow' or rel='sponsored'".
|
|
12
|
+
* `sponsored` is the accurate token for a commercial placement; `nofollow` is carried
|
|
13
|
+
* alongside it because it is the token every downstream tool actually reads. `noopener`
|
|
14
|
+
* is unrelated to ranking and is kept for the window-handle hardening it always gave.
|
|
15
|
+
*
|
|
16
|
+
* There is NO override seam, deliberately: every anchor this package emits points at a
|
|
17
|
+
* CertREV-owned host (a profile URL or a verify URL, both carried on the signed payload —
|
|
18
|
+
* a brand cannot author either), so no consumer has a link of their own here to widen, and
|
|
19
|
+
* an opt-out would only be a way to un-qualify the placement the policy is about. The
|
|
20
|
+
* constant is exported so a consumer can READ the value and reason about it; the anchors
|
|
21
|
+
* themselves are not configurable.
|
|
22
|
+
*/
|
|
23
|
+
export const CERTREV_LINK_REL = 'nofollow sponsored noopener'
|