@certrev/cert-block 0.5.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +189 -25
  2. package/LICENSE +21 -0
  3. package/README.md +415 -54
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +117 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +5 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +149 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +15 -8
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/readme-version.test.ts +0 -51
  179. package/src/__tests__/render-cert-block.test.ts +0 -657
  180. package/src/__tests__/render-def.test.tsx +0 -295
  181. package/src/__tests__/verify.test.ts +0 -239
  182. package/src/__tests__/webcomponent.test.tsx +0 -116
  183. package/src/builder/__tests__/builder.test.tsx +0 -158
  184. package/src/modal/cert-modal-view.test.ts +0 -175
  185. package/src/modal/certrev-cert-modal.test.ts +0 -125
  186. package/src/modal/ftc-guard.test.ts +0 -153
  187. package/src/modal/interactions.test.ts +0 -250
  188. package/src/modal/placement.test.ts +0 -398
  189. package/src/modal/revalidate.test.ts +0 -111
  190. 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 (WS6): presentation defaults come from `payload.content.display`; an optional
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 (WS6). ABSENT → the badge renders the CertREV preset
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
- alt={`Photo of ${content.expert.displayName}`}
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
- const suffix = credentialSuffix(content)
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
- ['--certrev-accent']: accent,
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="noopener">
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="noopener">
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="noopener"
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 { projectCertJsonLd, type ProjectJsonLdOptions, serializeJsonLdForScript } from '../jsonld/project.js'
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> = { ['--certrev-accent']: display.accentColor, color: display.accentColor }
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="noopener"
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 d="M8.5 12l2.3 2.3L15.5 9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
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 (WS6). Forwarded to `<CertBadge>` for themeable-token
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> = { ['--certrev-accent']: display.accentColor }
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
- {expert.credentials.length > 0 ? (
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
- alt={`Photo of ${expert.displayName}`}
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="noopener">
62
+ <a className={`${ROOT_CLASS}__name-link`} href={profileUrl} rel={CERTREV_LINK_REL}>
58
63
  {nameNode}
59
64
  </a>
60
65
  ) : (
@@ -65,7 +70,9 @@ export function ExpertBio(props: ExpertBioProps) {
65
70
  {expert.credentials.map((c) => (
66
71
  <li key={`${c.abbreviation}:${c.fullName}`} className={`${ROOT_CLASS}__credential`}>
67
72
  <abbr title={c.fullName}>{c.abbreviation}</abbr>
68
- <span className={`${ROOT_CLASS}__credential-full`}> — {c.fullName}</span>
73
+ {/* Middle dot, not an em dash: this is labelled data (abbreviation, then what it
74
+ expands to), and it renders on the customer's own article. */}
75
+ <span className={`${ROOT_CLASS}__credential-full`}> · {c.fullName}</span>
69
76
  </li>
70
77
  ))}
71
78
  </ul>
@@ -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
  '&': '&amp;',
19
51
  '<': '&lt;',
@@ -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
- if (!input) return null
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
- if (!input) return null
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 ?? '').replace(/\s+/g, ' ').trim()
101
+ return asText(input).replace(/\s+/g, ' ').trim()
72
102
  }
@@ -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 (!iso) return null
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
- /** "PhD, RD" — the expert's credential abbreviations, comma-joined. */
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
- return content.expert.credentials.map((c) => c.abbreviation).join(', ')
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
- /** "Dr. Jane Doe, PhD, RD" — display name with credential suffix appended. */
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 suffix = credentialSuffix(content)
71
- return suffix ? `${content.expert.displayName}, ${suffix}` : content.expert.displayName
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 (POR-10743, from the Builder demo).
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 — not a verified certification'
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 — static, non-revocable, NOT a verified certification. Expires ${escapeHtml(expires)}. Never use as delivery. -->` +
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'