@certrev/cert-block 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +48 -1
  2. package/dist/builder/cert-chrome-data.d.ts +129 -0
  3. package/dist/builder/cert-chrome-data.d.ts.map +1 -0
  4. package/dist/builder/cert-chrome-data.js +59 -0
  5. package/dist/builder/cert-chrome-data.js.map +1 -0
  6. package/dist/builder/cert-review-card.d.ts +68 -0
  7. package/dist/builder/cert-review-card.d.ts.map +1 -0
  8. package/dist/builder/cert-review-card.js +129 -0
  9. package/dist/builder/cert-review-card.js.map +1 -0
  10. package/dist/builder/index.d.ts +2 -0
  11. package/dist/builder/index.d.ts.map +1 -1
  12. package/dist/builder/index.js +3 -0
  13. package/dist/builder/index.js.map +1 -1
  14. package/dist/components/preview-block.d.ts +43 -0
  15. package/dist/components/preview-block.d.ts.map +1 -0
  16. package/dist/components/preview-block.js +53 -0
  17. package/dist/components/preview-block.js.map +1 -0
  18. package/dist/components/render-cert-block.d.ts +50 -7
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +304 -80
  21. package/dist/components/render-cert-block.js.map +1 -1
  22. package/dist/components/render-def.d.ts +27 -0
  23. package/dist/components/render-def.d.ts.map +1 -1
  24. package/dist/components/render-def.js +38 -0
  25. package/dist/components/render-def.js.map +1 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/modal/avatar-resize.d.ts +60 -0
  31. package/dist/modal/avatar-resize.d.ts.map +1 -0
  32. package/dist/modal/avatar-resize.js +91 -0
  33. package/dist/modal/avatar-resize.js.map +1 -0
  34. package/dist/modal/cert-modal-view.d.ts +74 -0
  35. package/dist/modal/cert-modal-view.d.ts.map +1 -0
  36. package/dist/modal/cert-modal-view.js +160 -0
  37. package/dist/modal/cert-modal-view.js.map +1 -0
  38. package/dist/modal/certrev-cert-modal.d.ts +72 -0
  39. package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
  40. package/dist/modal/certrev-cert-modal.js +270 -0
  41. package/dist/modal/certrev-cert-modal.js.map +1 -0
  42. package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
  43. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
  44. package/dist/modal/certrev-cert-modal.styles.js +488 -0
  45. package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
  46. package/dist/modal/certrev-cert.entry.d.ts +19 -0
  47. package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
  48. package/dist/modal/certrev-cert.entry.js +39 -0
  49. package/dist/modal/certrev-cert.entry.js.map +1 -0
  50. package/dist/modal/certrev-cert.js +557 -0
  51. package/dist/modal/display-strings.d.ts +22 -0
  52. package/dist/modal/display-strings.d.ts.map +1 -0
  53. package/dist/modal/display-strings.js +35 -0
  54. package/dist/modal/display-strings.js.map +1 -0
  55. package/dist/modal/escape.d.ts +13 -0
  56. package/dist/modal/escape.d.ts.map +1 -0
  57. package/dist/modal/escape.js +20 -0
  58. package/dist/modal/escape.js.map +1 -0
  59. package/dist/modal/font-face.d.ts +4 -0
  60. package/dist/modal/font-face.d.ts.map +1 -0
  61. package/dist/modal/font-face.js +137 -0
  62. package/dist/modal/font-face.js.map +1 -0
  63. package/dist/modal/ftc-disclosure.d.ts +12 -0
  64. package/dist/modal/ftc-disclosure.d.ts.map +1 -0
  65. package/dist/modal/ftc-disclosure.js +12 -0
  66. package/dist/modal/ftc-disclosure.js.map +1 -0
  67. package/dist/modal/ftc-guard.d.ts +50 -0
  68. package/dist/modal/ftc-guard.d.ts.map +1 -0
  69. package/dist/modal/ftc-guard.js +117 -0
  70. package/dist/modal/ftc-guard.js.map +1 -0
  71. package/dist/modal/index.d.ts +20 -0
  72. package/dist/modal/index.d.ts.map +1 -0
  73. package/dist/modal/index.js +25 -0
  74. package/dist/modal/index.js.map +1 -0
  75. package/dist/modal/interactions.d.ts +53 -0
  76. package/dist/modal/interactions.d.ts.map +1 -0
  77. package/dist/modal/interactions.js +147 -0
  78. package/dist/modal/interactions.js.map +1 -0
  79. package/dist/modal/logo-paths.d.ts +15 -0
  80. package/dist/modal/logo-paths.d.ts.map +1 -0
  81. package/dist/modal/logo-paths.js +24 -0
  82. package/dist/modal/logo-paths.js.map +1 -0
  83. package/dist/modal/placement.d.ts +95 -0
  84. package/dist/modal/placement.d.ts.map +1 -0
  85. package/dist/modal/placement.js +302 -0
  86. package/dist/modal/placement.js.map +1 -0
  87. package/dist/modal/revalidate.d.ts +48 -0
  88. package/dist/modal/revalidate.d.ts.map +1 -0
  89. package/dist/modal/revalidate.js +65 -0
  90. package/dist/modal/revalidate.js.map +1 -0
  91. package/dist/modal/verify-client.d.ts +24 -0
  92. package/dist/modal/verify-client.d.ts.map +1 -0
  93. package/dist/modal/verify-client.js +38 -0
  94. package/dist/modal/verify-client.js.map +1 -0
  95. package/package.json +10 -4
  96. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
  97. package/src/__tests__/preview-block.test.ts +84 -0
  98. package/src/__tests__/render-cert-block.test.ts +380 -5
  99. package/src/builder/__tests__/builder.test.tsx +71 -0
  100. package/src/builder/cert-chrome-data.ts +159 -0
  101. package/src/builder/cert-review-card.tsx +177 -0
  102. package/src/builder/index.tsx +18 -0
  103. package/src/components/preview-block.ts +74 -0
  104. package/src/components/render-cert-block.ts +387 -102
  105. package/src/components/render-def.ts +55 -0
  106. package/src/index.ts +9 -0
  107. package/src/modal/avatar-resize.ts +99 -0
  108. package/src/modal/cert-modal-view.test.ts +175 -0
  109. package/src/modal/cert-modal-view.ts +210 -0
  110. package/src/modal/certrev-cert-modal.styles.ts +494 -0
  111. package/src/modal/certrev-cert-modal.test.ts +125 -0
  112. package/src/modal/certrev-cert-modal.ts +273 -0
  113. package/src/modal/certrev-cert.entry.ts +44 -0
  114. package/src/modal/display-strings.ts +38 -0
  115. package/src/modal/escape.ts +20 -0
  116. package/src/modal/font-face.ts +138 -0
  117. package/src/modal/ftc-disclosure.ts +13 -0
  118. package/src/modal/ftc-guard.test.ts +153 -0
  119. package/src/modal/ftc-guard.ts +119 -0
  120. package/src/modal/index.ts +34 -0
  121. package/src/modal/interactions.test.ts +250 -0
  122. package/src/modal/interactions.ts +166 -0
  123. package/src/modal/logo-paths.ts +23 -0
  124. package/src/modal/placement.test.ts +398 -0
  125. package/src/modal/placement.ts +331 -0
  126. package/src/modal/revalidate.test.ts +111 -0
  127. package/src/modal/revalidate.ts +81 -0
  128. package/src/modal/verify-client.test.ts +164 -0
  129. package/src/modal/verify-client.ts +42 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * `BuilderCertChromeData` — the CANONICAL headless cert-chrome wire type
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * The brand-render projection of the cert envelope (portal POR-10091) — the OPTIONS
7
+ * of a brand's native cert component (e.g. e.l.f.'s registered `CertReviewCard`) on
8
+ * a headless blocks-mode entry (Builder.io). CertREV serves the data + the ready-made
9
+ * strings; the brand renders it within the design-guide ladder (enforced live by the
10
+ * crawl monitor + the MSA anti-stripping teeth).
11
+ *
12
+ * SINGLE SOURCE (POR-10721 W1): this type is HOMED HERE so the portal exporter
13
+ * (`src/lib/builder/cert-chrome.ts`) and the kit component (`CertReviewCard`) +
14
+ * `BUILDER_REGISTRATION.inputs` all reference ONE definition — killing the drift the
15
+ * hand-rolled Hydrogen-replica component suffered (it carried a stale separate
16
+ * `credential`/`credentialVerifiedAt` shape from before the fused-pair projection).
17
+ * Keep byte-for-byte in sync with the portal interface until the portal migrates to
18
+ * import THIS (that migration is gated on publishing the package that carries it).
19
+ *
20
+ * PURITY: types + a pure key tuple only — no runtime deps, tree-shakeable.
21
+ */
22
+
23
+ /**
24
+ * The baked render-def projection (the Shopify `render_def` metafield shape) the
25
+ * exporter delivers so a brand's Builder face themes byte-identically to its Shopify
26
+ * face. The component maps `{ tokens, version }` onto `renderCertBlock`'s theme +
27
+ * `data-certrev-def-version` stamp. Structurally matches the portal's
28
+ * `ShopifyRenderDefMetafield` (single-sourcing that metafield type too is a follow-up).
29
+ */
30
+ export interface CertChromeRenderDef {
31
+ /** Monotonic per-brand def version → the `data-certrev-def-version` stamp. */
32
+ readonly v: number
33
+ readonly preset: string
34
+ readonly rung: string
35
+ /**
36
+ * The resolved themeable tokens (the baked metafield's `tokens`) — includes the v2
37
+ * `barInk`/`inkColor`, so this is a superset of the input `CertThemeTokens` (which
38
+ * predates brand-ink theming). Inlined structurally to match what the exporter bakes.
39
+ */
40
+ readonly tokens: {
41
+ readonly accentColor?: string
42
+ readonly surface?: string
43
+ readonly cornerRadius?: string
44
+ readonly fontSlot?: string
45
+ readonly barInk?: string
46
+ readonly inkColor?: string
47
+ }
48
+ /** The auto-contrast bar foreground — present only on a barInk-themed face (v2). */
49
+ readonly barInkFg?: '#141414' | '#ffffff'
50
+ /** The subtractive field-visibility hide-set. */
51
+ readonly hidden: readonly string[]
52
+ /** v2 layout axis. Omitted ⇒ 'banner'. */
53
+ readonly layout?: string
54
+ /** Custom-face composition — present only when layout === 'custom'. */
55
+ readonly customFace?: { readonly placedFields: readonly string[] }
56
+ }
57
+
58
+ /**
59
+ * The OPTIONS a brand's native cert component receives. Delivered per certified
60
+ * article; until the brand registers the component the block renders nothing.
61
+ */
62
+ export interface BuilderCertChromeData {
63
+ /**
64
+ * The credentialed byline — credential + dated CertREV verification as ONE
65
+ * inseparable string. Null when the R1 gate fails (a displayed credential is
66
+ * unverified / missing `verified_at`); the brand then has only `bylinePlain`.
67
+ */
68
+ bylineCredentialed: string | null
69
+ /**
70
+ * The RAW verified credential welded to its dated verification, delivered as ONE
71
+ * INSEPARABLE PAIR (present together) or `null` (omitted together). The WHOLE
72
+ * point: the type hands the brand `credential` and `verifiedAt` only as the two
73
+ * keys of one object, so a bare credential without its dated verification is
74
+ * UNCONSTRUCTIBLE — the fused-verification invariant enforced by construction, not
75
+ * a hopeful side rule. Non-null iff `bylineCredentialed` is. The sidebar/banner
76
+ * faces map this to their `credential` + `credentialVerifiedAt` inputs.
77
+ */
78
+ credentialVerification: { credential: string; verifiedAt: string } | null
79
+ /** The name-only byline (`Reviewed [date] by [Name].`). Always present. */
80
+ bylinePlain: string
81
+ /** The reviewer's resolved professional name (QA-safe), raw, for a name-only layout. */
82
+ reviewerName: string
83
+ /** Reviewer's short verified bio, or null (verified-only). */
84
+ bio: string | null
85
+ /** Reviewer's longer verified background, or null (verified-only). */
86
+ background: string | null
87
+ /** The QA-SCREENED `verifiedExpertMemo` (never the raw in-flight `expertMemo`), or null. */
88
+ memo: string | null
89
+ /** The compensation cue (`Compensated expert`). Null for a pro-bono reviewer. */
90
+ compensationCue: string | null
91
+ /** The verbatim FTC scope disclaimer. Always present. */
92
+ scopeLine: string
93
+ /** Reviewer photo URL (cert-frozen override ?? live), or null. */
94
+ reviewerPhoto: string | null
95
+ /** The CertREV expert-profile URL (`certrev.com/expert/{id}`) — never LinkedIn. */
96
+ reviewerProfileUrl: string | null
97
+ /** The public certificate-verification URL (`/verify/{id}`). */
98
+ verifyUrl: string
99
+ /** When the article was certified (ISO 8601) — the same date baked into the bylines. */
100
+ certifiedAt: string
101
+ /** The last real content change after certification (ISO 8601), or null. */
102
+ contentModifiedAt: string | null
103
+ /** The article's AUTHOR (a different person from the reviewer; carries no verification claim). */
104
+ authorName: string | null
105
+ authorTitle: string | null
106
+ /** Brand display prefs the rendering component honors. */
107
+ display: {
108
+ showExpertPhoto: boolean
109
+ /** When false the component renders `bylinePlain` even if the credentialed form was delivered. */
110
+ showCredentials: boolean
111
+ }
112
+ /** The brand's ACTIVE resolved render-def projection, or null (⇒ preset default). */
113
+ renderDef: CertChromeRenderDef | null
114
+ /** Version of the delivered string set (`data-certrev-strings-version` for the crawl monitor). */
115
+ stringsVersion: string
116
+ }
117
+
118
+ /**
119
+ * The ordered keys of `BuilderCertChromeData` — the SINGLE SOURCE the Builder
120
+ * `inputs` array and the W3 exporter⇄inputs⇄wire-type parity lock derive from, so
121
+ * the three can never silently drift again (POR-10721 W3). Adding a field to the
122
+ * interface without adding it here (or vice-versa) fails the `keyof` exhaustiveness
123
+ * check in `assertChromeKeysExhaustive` below.
124
+ */
125
+ export const BUILDER_CERT_CHROME_KEYS = [
126
+ 'bylineCredentialed',
127
+ 'credentialVerification',
128
+ 'bylinePlain',
129
+ 'reviewerName',
130
+ 'bio',
131
+ 'background',
132
+ 'memo',
133
+ 'compensationCue',
134
+ 'scopeLine',
135
+ 'reviewerPhoto',
136
+ 'reviewerProfileUrl',
137
+ 'verifyUrl',
138
+ 'certifiedAt',
139
+ 'contentModifiedAt',
140
+ 'authorName',
141
+ 'authorTitle',
142
+ 'display',
143
+ 'renderDef',
144
+ 'stringsVersion',
145
+ ] as const
146
+
147
+ export type BuilderCertChromeKey = (typeof BUILDER_CERT_CHROME_KEYS)[number]
148
+
149
+ /**
150
+ * Compile-time proof that `BUILDER_CERT_CHROME_KEYS` === `keyof BuilderCertChromeData`
151
+ * exactly (both directions). If a field is added/removed on the interface without the
152
+ * matching key edit, ONE of these two assignments stops type-checking — so the tuple
153
+ * can never drift from the type silently.
154
+ */
155
+ const _keysCoverType: Record<keyof BuilderCertChromeData, true> = Object.fromEntries(
156
+ BUILDER_CERT_CHROME_KEYS.map((k) => [k, true]),
157
+ ) as Record<BuilderCertChromeKey, true>
158
+ const _typeCoversKeys: Record<BuilderCertChromeKey, true> = _keysCoverType
159
+ void _typeCoversKeys
@@ -0,0 +1,177 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * `CertReviewCard` — the ready-to-register Builder.io PUSH cert component (W1)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * A headless brand (e.l.f.) registers `BUILDER_REGISTRATION` in its Builder
7
+ * `customComponents` and CertREV's exporter emits a block whose options are the
8
+ * `BuilderCertChromeData` projection — so the visible cert chrome renders itself,
9
+ * design-identical to the Shopify Liquid + WordPress PHP faces (same `renderCertBlock`
10
+ * string renderer). This REPLACES the ~200-line hand-rolled component every headless
11
+ * brand copied from the demo (POR-10721 W1).
12
+ *
13
+ * Improvements over the canary demo's hand-rolled version:
14
+ * - consumes the CANONICAL `BuilderCertChromeData` (the fused `credentialVerification`
15
+ * pair — a credential is UNCONSTRUCTIBLE without its dated verification), not the
16
+ * demo's stale separate `credential`/`credentialVerifiedAt` fields;
17
+ * - maps v2 `barInk`/`inkColor` into the theme (the demo predated brand-ink theming);
18
+ * - honors `display.showCredentials`.
19
+ */
20
+
21
+ import { type CertBlockMode, renderCertBlock } from '../components/render-cert-block.js'
22
+ import type { ResolvedBlockTheme } from '../components/render-def.js'
23
+ import type { BuilderCertChromeData } from './cert-chrome-data.js'
24
+ import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
25
+
26
+ /**
27
+ * The canonical Builder block name. A brand's registration + the portal
28
+ * `certComponent` connection field default to THIS; config exists only for
29
+ * deviation (portal `client-for-brand.ts` gains the default in a follow-up).
30
+ */
31
+ export const CERT_COMPONENT_NAME = 'CertREV Cert'
32
+
33
+ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
34
+ /** Placement face — the ENTRY/editor chooses this, NOT the exporter (not a wire-type field). */
35
+ mode?: CertBlockMode | null
36
+ /**
37
+ * Banner memo placement (not a wire-type field — the ENTRY/editor chooses it). Passed
38
+ * straight through to `renderCertBlock`'s first-class `part` (POR-10741): `'full'`
39
+ * (default) renders header + memo in one block; `'header'`/`'memo'` render only that
40
+ * card so the memo can be placed as its own block under the article body. The old
41
+ * string-splitting shim is gone — `renderCertBlock` composes each part directly.
42
+ */
43
+ part?: 'full' | 'header' | 'memo' | null
44
+ }
45
+
46
+ export function CertReviewCard(props: CertReviewCardProps) {
47
+ const { reviewerName, verifyUrl } = props
48
+ if (!reviewerName || !verifyUrl) return null // no chrome pre-cert
49
+
50
+ // The credential + its verification date arrive ONLY as the fused pair, so we can
51
+ // never obtain one without the other (name-only face when the pair is null, or when
52
+ // the brand set showCredentials:false). This IS the R1 gate, enforced by the type.
53
+ const cv = props.credentialVerification ?? null
54
+ const showCred = props.display?.showCredentials !== false
55
+ const withCred = cv && showCred ? cv : null
56
+
57
+ const tokens = props.renderDef?.tokens
58
+ const theme: Partial<ResolvedBlockTheme> | undefined = tokens
59
+ ? {
60
+ accentColor: tokens.accentColor,
61
+ surface: tokens.surface,
62
+ cornerRadius: tokens.cornerRadius,
63
+ fontSlot: tokens.fontSlot as ResolvedBlockTheme['fontSlot'] | undefined,
64
+ // v2 brand-ink (post-demo): the bar + free-content ink follow the def.
65
+ barInk: tokens.barInk,
66
+ inkColor: tokens.inkColor,
67
+ }
68
+ : undefined
69
+
70
+ const html = renderCertBlock({
71
+ mode: (props.mode as CertBlockMode) ?? 'banner',
72
+ theme,
73
+ // First-class banner memo split (POR-10741) — renderCertBlock composes the requested
74
+ // part directly; no string-splitting. Ignored outside banner mode.
75
+ ...(props.part ? { part: props.part } : {}),
76
+ facts: {
77
+ authorName: props.authorName ?? 'Editorial',
78
+ authorTitle: props.authorTitle ?? undefined,
79
+ reviewerName,
80
+ credential: withCred ? withCred.credential : '',
81
+ credentialVerifiedAt: withCred ? withCred.verifiedAt : '',
82
+ certifiedAt: props.certifiedAt ?? '',
83
+ memo: props.memo ?? '',
84
+ bio: props.bio ?? undefined,
85
+ profileUrl: props.reviewerProfileUrl ?? '',
86
+ certificateUrl: verifyUrl,
87
+ // Locked strings default byte-verbatim inside renderCertBlock; pass the
88
+ // delivered cue/scope through so a pro-bono (null-cue) delivery is honored.
89
+ ...(props.compensationCue ? { compensationCue: props.compensationCue } : {}),
90
+ ...(props.scopeLine ? { scopeLine: props.scopeLine } : {}),
91
+ },
92
+ })
93
+
94
+ return (
95
+ <div
96
+ data-certrev-cert-chrome=""
97
+ data-certrev-strings-version={props.stringsVersion ?? undefined}
98
+ data-certrev-def-version={props.renderDef?.v ?? undefined}
99
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
100
+ dangerouslySetInnerHTML={{ __html: html }}
101
+ />
102
+ )
103
+ }
104
+
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ // Builder.io registration — inputs SINGLE-SOURCED from the wire-type keys (W1/W3)
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+
109
+ /** The subset of the Builder.io input schema this kit emits. */
110
+ export interface BuilderInput {
111
+ readonly name: string
112
+ readonly type: 'string' | 'longText' | 'object' | 'boolean' | 'number'
113
+ readonly friendlyName?: string
114
+ readonly helperText?: string
115
+ }
116
+
117
+ /** Builder input TYPE per wire-type key — the exporter serializes these option values. */
118
+ const WIRE_INPUT_TYPE: Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], BuilderInput['type']> = {
119
+ bylineCredentialed: 'string',
120
+ credentialVerification: 'object',
121
+ bylinePlain: 'string',
122
+ reviewerName: 'string',
123
+ bio: 'longText',
124
+ background: 'longText',
125
+ memo: 'longText',
126
+ compensationCue: 'string',
127
+ scopeLine: 'string',
128
+ reviewerPhoto: 'string',
129
+ reviewerProfileUrl: 'string',
130
+ verifyUrl: 'string',
131
+ certifiedAt: 'string',
132
+ contentModifiedAt: 'string',
133
+ authorName: 'string',
134
+ authorTitle: 'string',
135
+ display: 'object',
136
+ renderDef: 'object',
137
+ stringsVersion: 'string',
138
+ }
139
+
140
+ /**
141
+ * The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the 19-field
142
+ * option set can never drift from the type (the W3 lock asserts name-parity). This
143
+ * kills the demo's hand-maintained 17-entry array.
144
+ */
145
+ export const WIRE_INPUTS: readonly BuilderInput[] = BUILDER_CERT_CHROME_KEYS.map((name) => ({
146
+ name,
147
+ type: WIRE_INPUT_TYPE[name],
148
+ }))
149
+
150
+ /** Placement inputs — chosen by the entry/editor, NOT emitted by the exporter (not wire-type keys). */
151
+ export const PLACEMENT_INPUTS: readonly BuilderInput[] = [
152
+ {
153
+ name: 'mode',
154
+ type: 'string',
155
+ friendlyName: 'Placement',
156
+ helperText: 'banner (default) · sidebar · floating',
157
+ },
158
+ { name: 'part', type: 'string', friendlyName: 'Banner part', helperText: 'full (default) · header · memo' },
159
+ ]
160
+
161
+ /** A Builder.io custom-component registration (the general shape; the anchor uses the same). */
162
+ export interface CertBlockBuilderRegistration {
163
+ readonly name: string
164
+ readonly component: typeof CertReviewCard
165
+ readonly inputs: readonly BuilderInput[]
166
+ }
167
+
168
+ /**
169
+ * Ready to spread into a brand's Builder `customComponents`:
170
+ * `Builder.registerComponent(BUILDER_REGISTRATION.component, BUILDER_REGISTRATION)`.
171
+ * The exporter-emitted option keys === these `inputs` === the wire type — locked by W3.
172
+ */
173
+ export const BUILDER_REGISTRATION: CertBlockBuilderRegistration = {
174
+ name: CERT_COMPONENT_NAME,
175
+ component: CertReviewCard,
176
+ inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
177
+ }
@@ -120,3 +120,21 @@ export const certRevAnchorComponent: CertRevBuilderRegistration = {
120
120
  name: 'CertREV Review',
121
121
  inputs: [],
122
122
  }
123
+
124
+ // ── POR-10721 W1/W3: the Builder.io PUSH kit (CertReviewCard) + single-sourced wire type ──
125
+ export {
126
+ BUILDER_CERT_CHROME_KEYS,
127
+ type BuilderCertChromeData,
128
+ type BuilderCertChromeKey,
129
+ type CertChromeRenderDef,
130
+ } from './cert-chrome-data.js'
131
+ export {
132
+ BUILDER_REGISTRATION,
133
+ type BuilderInput,
134
+ CERT_COMPONENT_NAME,
135
+ type CertBlockBuilderRegistration,
136
+ CertReviewCard,
137
+ type CertReviewCardProps,
138
+ PLACEMENT_INPUTS,
139
+ WIRE_INPUTS,
140
+ } from './cert-review-card.js'
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `renderPreviewBlock` — a STATIC, NON-DELIVERY preview of the cert block for pre-sales
3
+ * look-demos (POR-10743, from the e.l.f. Builder demo).
4
+ *
5
+ * The visual is BYTE-FOR-BYTE `renderCertBlock` (pixel-parity — "see it on your own site
6
+ * today"), wrapped in a `[data-certrev-preview]` container carrying a REQUIRED expiry.
7
+ *
8
+ * Why it is safe by construction — and can NEVER be delivery:
9
+ * - A preview has NO signed envelope and NO modal. CertREV's verify / badge-revalidation /
10
+ * crawl-audit all key off a signed `CertDeliveryEnvelope`; an unsigned block is SUPPRESSED
11
+ * on any live verify, never rendered as a certification. So a preview literally cannot pass
12
+ * as a delivered cert — the "never delivery" property is structural, not a policy.
13
+ * - The container marker (`data-certrev-preview="1"`) + the default-on visible label make it
14
+ * unmistakable to humans AND tooling; the required `data-certrev-preview-expires` bounds its
15
+ * life so a stale snapshot can be refused downstream.
16
+ * - It is NOT revocable — the kill-switch cannot reach a static HTML snapshot — so it must stay
17
+ * demo / draft-only. That is the CALLER's duty; this renderer bakes the markers that let the
18
+ * surrounding systems enforce it.
19
+ */
20
+
21
+ import { escapeAttribute, escapeHtml } from './escape.js'
22
+ import { type RenderCertBlockInput, renderCertBlock } from './render-cert-block.js'
23
+
24
+ export interface PreviewBlockOptions {
25
+ /**
26
+ * REQUIRED — when this preview expires (ISO 8601). A preview MUST be time-bounded so a stale
27
+ * snapshot can be refused downstream; there is deliberately NO default (an unbounded preview
28
+ * is the drift-into-shadow-delivery risk this option exists to prevent).
29
+ */
30
+ readonly expiresAt: string
31
+ /**
32
+ * Show the visible caption. Default `true` — the SAFE default. Opt out (`false`) ONLY for a
33
+ * true pixel-parity look-demo; the machine marker + expiry + non-delivery guarantee remain.
34
+ */
35
+ readonly showLabel?: boolean
36
+ /** Override the caption text (default below). */
37
+ readonly labelText?: string
38
+ }
39
+
40
+ const DEFAULT_LABEL = 'Preview — not a verified certification'
41
+
42
+ /** Extract a preview block's baked expiry (ISO), or null if the HTML isn't a preview block. */
43
+ export function previewExpiry(html: string): string | null {
44
+ const m = html.match(/data-certrev-preview-expires="([^"]*)"/)
45
+ return m ? m[1] : null
46
+ }
47
+
48
+ /**
49
+ * Render a static, clearly-labeled, time-bounded preview of the cert block. See the module doc
50
+ * for the safety model. `expiresAt` is required; throws without it.
51
+ */
52
+ export function renderPreviewBlock(input: RenderCertBlockInput, options: PreviewBlockOptions): string {
53
+ const expires = options.expiresAt.trim()
54
+ if (!expires) {
55
+ throw new Error('renderPreviewBlock: `expiresAt` is required — a preview must be time-bounded.')
56
+ }
57
+ // The bound must be REAL — an unparseable expiry is a toothless bound (downstream can't refuse a
58
+ // stale preview it can't date). Reject anything Date can't parse, so `previewExpiry()` consumers
59
+ // always get a usable timestamp.
60
+ if (Number.isNaN(new Date(expires).getTime())) {
61
+ throw new Error(`renderPreviewBlock: \`expiresAt\` must be a parseable date (got "${expires}").`)
62
+ }
63
+ const showLabel = options.showLabel !== false
64
+ const label = showLabel
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
+ : ''
67
+ return (
68
+ `<!-- CertREV PREVIEW — static, non-revocable, NOT a verified certification. Expires ${escapeHtml(expires)}. Never use as delivery. -->` +
69
+ `<div data-certrev-preview="1" data-certrev-preview-expires="${escapeAttribute(expires)}">` +
70
+ label +
71
+ renderCertBlock(input) +
72
+ `</div>`
73
+ )
74
+ }