@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-badge-html.d.ts","sourceRoot":"","sources":["../../src/webcomponent/render-badge-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;
|
|
1
|
+
{"version":3,"file":"render-badge-html.d.ts","sourceRoot":"","sources":["../../src/webcomponent/render-badge-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAWH,OAAO,EAGN,KAAK,aAAa,EAGlB,MAAM,6BAA6B,CAAA;AACpC,OAAO,KAAK,EAAe,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAErE,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,CAAA;CAClC;AAwCD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,GAAE,kBAAuB,GAAG,MAAM,CA2G3F"}
|
|
@@ -12,9 +12,18 @@
|
|
|
12
12
|
* the badge must be in the server HTML, never client-only.
|
|
13
13
|
*/
|
|
14
14
|
import { escapeAttribute, escapeHtml, safeCssColor, safeHttpUrl } from '../components/escape.js';
|
|
15
|
-
import { credentialSuffix, formatDate, resolveDisplay } from '../components/format.js';
|
|
15
|
+
import { credentialSuffix, dedupeCredential, expertNameWithCredentials, formatDate, resolveDisplay, } from '../components/format.js';
|
|
16
|
+
import { CERTREV_LINK_REL } from '../components/rel.js';
|
|
16
17
|
import { CERT_SCOPE_LINE, CERT_STRINGS_VERSION, resolveRenderTheme, themeCssVarDeclarations, } from '../components/render-def.js';
|
|
17
18
|
const C = 'certrev-badge';
|
|
19
|
+
/**
|
|
20
|
+
* The badge has exactly two faces, and this is the ONE place that decides which. Anything
|
|
21
|
+
* that is not the compact face renders the full face — so an unvalidated `badgeStyle` (a
|
|
22
|
+
* malformed envelope's number, an untyped caller's typo) can never reach the class name.
|
|
23
|
+
*/
|
|
24
|
+
function badgeFace(style) {
|
|
25
|
+
return style === 'compact' ? 'compact' : 'full';
|
|
26
|
+
}
|
|
18
27
|
function attr(name, value) {
|
|
19
28
|
if (value == null || value === '')
|
|
20
29
|
return '';
|
|
@@ -26,26 +35,31 @@ function attr(name, value) {
|
|
|
26
35
|
* already decided to render by the time this is called).
|
|
27
36
|
*/
|
|
28
37
|
export function renderBadgeHtml(payload, opts = {}) {
|
|
29
|
-
const content = payload
|
|
30
|
-
const
|
|
38
|
+
const content = payload?.content ?? {};
|
|
39
|
+
const expert = content.expert ?? null;
|
|
40
|
+
const display = resolveDisplay(content.display ?? undefined, opts.accentColor, opts.renderDef);
|
|
31
41
|
const theme = resolveRenderTheme(opts.renderDef);
|
|
32
|
-
const style = opts.badgeStyle ?? display.badgeStyle;
|
|
42
|
+
const style = badgeFace(opts.badgeStyle ?? display.badgeStyle);
|
|
33
43
|
const accent = safeCssColor(display.accentColor) ?? '#0f766e';
|
|
34
44
|
const verifyUrl = safeHttpUrl(content.verifyUrl);
|
|
35
|
-
const profileUrl = safeHttpUrl(
|
|
36
|
-
const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(
|
|
37
|
-
|
|
45
|
+
const profileUrl = safeHttpUrl(expert?.profileUrl);
|
|
46
|
+
const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(expert?.photoUrl) : null;
|
|
47
|
+
// Same composition rule as <CertBadge>: the suffix drops a credential the display name
|
|
48
|
+
// already carries, so the two renderers never disagree about one payload.
|
|
49
|
+
const suffix = dedupeCredential(expert?.displayName, credentialSuffix(payload?.content));
|
|
38
50
|
const certified = formatDate(content.certifiedAt);
|
|
39
51
|
const updated = formatDate(content.contentModifiedAt);
|
|
40
|
-
const name = `${escapeHtml(
|
|
52
|
+
const name = `${escapeHtml(expert?.displayName)}${suffix ? `<span class="${C}__credentials">, ${escapeHtml(suffix)}</span>` : ''}`;
|
|
41
53
|
const nameNode = profileUrl
|
|
42
|
-
? `<a class="${C}__expert-link" href="${escapeAttribute(profileUrl)}" rel="
|
|
54
|
+
? `<a class="${C}__expert-link" href="${escapeAttribute(profileUrl)}" rel="${CERTREV_LINK_REL}">${name}</a>`
|
|
43
55
|
: name;
|
|
44
56
|
const mark = `<svg class="${C}__mark" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">` +
|
|
45
57
|
`<circle cx="12" cy="12" r="11" fill="${escapeAttribute(accent)}"></circle>` +
|
|
46
58
|
`<path d="M7 12.5l3.2 3.2L17 9" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>`;
|
|
59
|
+
// alt="" is deliberate: the byline below renders the same name as text, so alt text would make a
|
|
60
|
+
// screen reader announce it twice. Kept in lockstep with <CertBadge> and the modal avatar.
|
|
47
61
|
const photo = photoUrl
|
|
48
|
-
? `<img class="${C}__photo" src="${escapeAttribute(photoUrl)}" alt="
|
|
62
|
+
? `<img class="${C}__photo" src="${escapeAttribute(photoUrl)}" alt="" width="40" height="40" loading="lazy" decoding="async" />`
|
|
49
63
|
: '';
|
|
50
64
|
const header = `<div class="${C}__header">${mark}${photo}` +
|
|
51
65
|
`<div class="${C}__heading">` +
|
|
@@ -57,7 +71,7 @@ export function renderBadgeHtml(payload, opts = {}) {
|
|
|
57
71
|
if (display.showMemo && content.memo) {
|
|
58
72
|
body += `<p class="${C}__memo">${escapeHtml(content.memo)}</p>`;
|
|
59
73
|
}
|
|
60
|
-
if (display.showAuthor && content.author
|
|
74
|
+
if (display.showAuthor && content.author?.name && content.author.name !== expert?.displayName) {
|
|
61
75
|
const title = content.author.title ? `, ${escapeHtml(content.author.title)}` : '';
|
|
62
76
|
body += `<p class="${C}__author">Written by ${escapeHtml(content.author.name)}${title}</p>`;
|
|
63
77
|
}
|
|
@@ -79,17 +93,19 @@ export function renderBadgeHtml(payload, opts = {}) {
|
|
|
79
93
|
// byte-verbatim COMPENSATED_EXPERT_CUE. The scope line is byte-verbatim
|
|
80
94
|
// CERT_SCOPE_LINE (its curly apostrophe U+2019 survives escapeHtml untouched).
|
|
81
95
|
const cueInner = profileUrl
|
|
82
|
-
? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="
|
|
96
|
+
? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="${CERTREV_LINK_REL}">expert</a>`
|
|
83
97
|
: 'Compensated expert';
|
|
84
98
|
const disclosure = `<div class="${C}__disclosure">` +
|
|
85
99
|
`<span class="${C}__compensated-cue">${cueInner}</span>` +
|
|
86
100
|
`<p class="${C}__ftc" data-ftc-disclosure><span data-ftc-line>${escapeHtml(CERT_SCOPE_LINE)}</span></p>` +
|
|
87
101
|
`</div>`;
|
|
88
102
|
const verify = verifyUrl
|
|
89
|
-
? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="
|
|
103
|
+
? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="${CERTREV_LINK_REL}" aria-label="Verify this certification on CertREV">Verify on CertREV</a>`
|
|
90
104
|
: '';
|
|
91
|
-
|
|
92
|
-
//
|
|
105
|
+
// Same derivation <CertBadge> labels with, so the two renderers cannot drift on the one
|
|
106
|
+
// string a screen reader actually announces.
|
|
107
|
+
const ariaLabel = `Content reviewed by ${expertNameWithCredentials(payload?.content)}`;
|
|
108
|
+
// Accent LEADS so the un-themed output is byte-identical to the older single-var
|
|
93
109
|
// form. A THEMED brand appends surface/radius/font: the data-bearing tokens ride
|
|
94
110
|
// escaped (a no-op for validated hex/length — defense in depth), the font is a
|
|
95
111
|
// controlled literal stack emitted raw. The whole string is NOT re-escaped
|
|
@@ -99,8 +115,11 @@ export function renderBadgeHtml(payload, opts = {}) {
|
|
|
99
115
|
for (const [name, value] of themeCssVarDeclarations(theme)) {
|
|
100
116
|
rootStyle += name === '--certrev-font' ? `;${name}:${value}` : `;${name}:${escapeAttribute(value)}`;
|
|
101
117
|
}
|
|
102
|
-
return (
|
|
103
|
-
|
|
118
|
+
return (
|
|
119
|
+
// `style` is one of two literals by construction (`badgeFace`), so the class name needs
|
|
120
|
+
// no escaping — there is no path by which caller data reaches it.
|
|
121
|
+
`<section class="${C} ${C}--${style}" style="${rootStyle}"` +
|
|
122
|
+
attr('data-certrev-cert-id', payload?.certId) +
|
|
104
123
|
` data-certrev-strings-version="${CERT_STRINGS_VERSION}"` +
|
|
105
124
|
attr('data-certrev-def-version', theme.version != null ? String(theme.version) : null) +
|
|
106
125
|
` aria-label="${escapeAttribute(ariaLabel)}">${header}${body}${disclosure}${verify}</section>`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-badge-html.js","sourceRoot":"","sources":["../../src/webcomponent/render-badge-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAChG,OAAO,
|
|
1
|
+
{"version":3,"file":"render-badge-html.js","sourceRoot":"","sources":["../../src/webcomponent/render-badge-html.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@certrev/cert-block",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Headless
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Headless render edge for the CertREV CertDeliveryEnvelope: SSR-safe React cert components, a framework-agnostic <certrev-badge> Web Component, a schema.org JSON-LD projector, and a fail-closed verify layer over the shared VerdictKernel.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/CertREV/cert-kit.git",
|
|
8
|
+
"directory": "packages/cert-block"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/CertREV/cert-kit/tree/HEAD/packages/cert-block#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/CertREV/cert-kit/issues"
|
|
13
|
+
},
|
|
5
14
|
"type": "module",
|
|
6
15
|
"main": "./dist/index.js",
|
|
7
16
|
"types": "./dist/index.d.ts",
|
|
@@ -35,6 +44,9 @@
|
|
|
35
44
|
"files": [
|
|
36
45
|
"dist",
|
|
37
46
|
"src",
|
|
47
|
+
"!src/**/__tests__",
|
|
48
|
+
"!src/**/*.test.ts",
|
|
49
|
+
"!src/**/*.test.tsx",
|
|
38
50
|
"README.md",
|
|
39
51
|
"CHANGELOG.md"
|
|
40
52
|
],
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* `BuilderCertChromeData` — the CANONICAL headless cert-chrome wire type
|
|
4
4
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
*
|
|
6
|
-
* The brand-render projection of the cert envelope
|
|
6
|
+
* The brand-render projection of the cert envelope — the OPTIONS
|
|
7
7
|
* of a brand's native cert component (the registered `CertReviewCard`) on
|
|
8
8
|
* a headless blocks-mode entry (Builder.io). CertREV serves the data + the ready-made
|
|
9
9
|
* strings; the brand renders it within the design-guide ladder (enforced live by the
|
|
10
10
|
* crawl monitor + the MSA anti-stripping teeth).
|
|
11
11
|
*
|
|
12
|
-
* SINGLE SOURCE
|
|
13
|
-
*
|
|
12
|
+
* SINGLE SOURCE: this type is HOMED HERE so the portal's cert-chrome exporter,
|
|
13
|
+
* the kit component (`CertReviewCard`) and
|
|
14
14
|
* `BUILDER_REGISTRATION.inputs` all reference ONE definition — killing the drift the
|
|
15
15
|
* hand-rolled Hydrogen-replica component suffered (it carried a stale separate
|
|
16
16
|
* `credential`/`credentialVerifiedAt` shape from before the fused-pair projection).
|
|
@@ -49,7 +49,12 @@ export interface CertChromeRenderDef {
|
|
|
49
49
|
readonly barInkFg?: '#141414' | '#ffffff'
|
|
50
50
|
/** The subtractive field-visibility hide-set. */
|
|
51
51
|
readonly hidden: readonly string[]
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* v2 layout axis. This is the DEF's own axis (what the brand baked), NOT the placement the
|
|
54
|
+
* component renders — the entry/editor picks that via `mode`, and its default lives in
|
|
55
|
+
* `renderCertBlock` alone (`sidebar`). This comment used to claim the default was 'banner',
|
|
56
|
+
* which made it the third place a placement default was written down and the second one wrong.
|
|
57
|
+
*/
|
|
53
58
|
readonly layout?: string
|
|
54
59
|
/** Custom-face composition — present only when layout === 'custom'. */
|
|
55
60
|
readonly customFace?: { readonly placedFields: readonly string[] }
|
|
@@ -86,9 +91,22 @@ export interface BuilderCertChromeData {
|
|
|
86
91
|
background: string | null
|
|
87
92
|
/** The QA-SCREENED `verifiedExpertMemo` (never the raw in-flight `expertMemo`), or null. */
|
|
88
93
|
memo: string | null
|
|
89
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* The compensation cue (`Compensated expert`). Null for a pro-bono reviewer.
|
|
96
|
+
*
|
|
97
|
+
* COPY IS NOT CARRIED BY THIS FIELD — only the BOOLEAN it implies is. The face renders the
|
|
98
|
+
* locked `COMPENSATED_EXPERT_CUE` constant when the cue is present and NOTHING when it is
|
|
99
|
+
* null; a rewritten string here changes no pixel. Kept on the wire type because the
|
|
100
|
+
* pro-bono `null` is real signal, and because a brand's exporter payload already carries it.
|
|
101
|
+
*/
|
|
90
102
|
compensationCue: string | null
|
|
91
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The verbatim FTC scope disclaimer. Always present.
|
|
105
|
+
*
|
|
106
|
+
* Delivered for the brand's own records / non-CertREV surfaces. The face does NOT read it:
|
|
107
|
+
* it renders the locked `CERT_SCOPE_LINE` constant, whose curly apostrophe is pinned to
|
|
108
|
+
* MSA §3, so an edited copy here can never reach a rendered disclosure.
|
|
109
|
+
*/
|
|
92
110
|
scopeLine: string
|
|
93
111
|
/** Reviewer photo URL (cert-frozen override ?? live), or null. */
|
|
94
112
|
reviewerPhoto: string | null
|
|
@@ -117,8 +135,8 @@ export interface BuilderCertChromeData {
|
|
|
117
135
|
|
|
118
136
|
/**
|
|
119
137
|
* The ordered keys of `BuilderCertChromeData` — the SINGLE SOURCE the Builder
|
|
120
|
-
* `inputs` array and the
|
|
121
|
-
* the three can never silently drift again
|
|
138
|
+
* `inputs` array and the exporter⇄inputs⇄wire-type parity lock derive from, so
|
|
139
|
+
* the three can never silently drift again. Adding a field to the
|
|
122
140
|
* interface without adding it here (or vice-versa) fails the `keyof` exhaustiveness
|
|
123
141
|
* check in `assertChromeKeysExhaustive` below.
|
|
124
142
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `BuilderCertChromeData` projection — so the visible cert chrome renders itself,
|
|
9
9
|
* design-identical to the Shopify Liquid + WordPress PHP faces (same `renderCertBlock`
|
|
10
10
|
* string renderer). This REPLACES the ~200-line hand-rolled component every headless
|
|
11
|
-
* brand copied from the demo
|
|
11
|
+
* brand copied from the demo.
|
|
12
12
|
*
|
|
13
13
|
* Improvements over the canary demo's hand-rolled version:
|
|
14
14
|
* - consumes the CANONICAL `BuilderCertChromeData` (the fused `credentialVerification`
|
|
@@ -31,11 +31,15 @@ import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
|
|
|
31
31
|
export const CERT_COMPONENT_NAME = 'CertREV Cert'
|
|
32
32
|
|
|
33
33
|
export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Placement face — the ENTRY/editor chooses this, NOT the exporter (not a wire-type field).
|
|
36
|
+
* Absent ⇒ whatever `renderCertBlock` defaults to (`sidebar`); this component names no
|
|
37
|
+
* fallback of its own, so there is exactly ONE place the default is written down.
|
|
38
|
+
*/
|
|
35
39
|
mode?: CertBlockMode | null
|
|
36
40
|
/**
|
|
37
41
|
* Banner memo placement (not a wire-type field — the ENTRY/editor chooses it). Passed
|
|
38
|
-
* straight through to `renderCertBlock`'s first-class `part
|
|
42
|
+
* straight through to `renderCertBlock`'s first-class `part`: `'full'`
|
|
39
43
|
* (default) renders header + memo in one block; `'header'`/`'memo'` render only that
|
|
40
44
|
* card so the memo can be placed as its own block under the article body. The old
|
|
41
45
|
* string-splitting shim is gone — `renderCertBlock` composes each part directly.
|
|
@@ -43,59 +47,106 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
|
43
47
|
part?: 'full' | 'header' | 'memo' | null
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Coerce ONE untyped option to renderable text.
|
|
52
|
+
*
|
|
53
|
+
* `CertReviewCardProps` is erased at runtime and these options are authored in a VISUAL
|
|
54
|
+
* EDITOR — a number, an object or an array can sit in any field, and `??` does nothing about
|
|
55
|
+
* it (`props.memo ?? ''` forwards the number 42 happily). `renderCertBlock` reads its facts as
|
|
56
|
+
* strings (`.trim()`, `.split()`), so a mistyped option threw INSIDE the SSR render; under
|
|
57
|
+
* `renderToReadableStream` that aborts the whole article route with zero HTML. A value that is
|
|
58
|
+
* not text is not copy, so it renders as nothing.
|
|
59
|
+
*/
|
|
60
|
+
function text(value: unknown): string {
|
|
61
|
+
return typeof value === 'string' ? value : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The same rule where ABSENT and EMPTY are different renders (`authorTitle`, `bio`). */
|
|
65
|
+
function optionalText(value: unknown): string | undefined {
|
|
66
|
+
return text(value) || undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The fused credential pair — accepted ONLY when both halves are present AND both are strings.
|
|
71
|
+
*
|
|
72
|
+
* The R1 gate ("a credential never displays without its dated verification") is THIS FUNCTION,
|
|
73
|
+
* not the type: the pair arrives as untyped CMS JSON, and the old truthiness check keyed the
|
|
74
|
+
* whole pair off the OBJECT, so `{credential:'MD'}` forwarded `verifiedAt: undefined` into the
|
|
75
|
+
* renderer and crashed. Anything malformed — a half pair, a bare string, an array, a number
|
|
76
|
+
* where a date belongs — degrades to the name-only face, which is the honest render of a
|
|
77
|
+
* delivery whose verification cannot be read.
|
|
78
|
+
*/
|
|
79
|
+
function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] | undefined) {
|
|
80
|
+
if (!pair || typeof pair !== 'object') return null
|
|
81
|
+
const credential = text(pair.credential)
|
|
82
|
+
const verifiedAt = text(pair.verifiedAt)
|
|
83
|
+
return credential && verifiedAt ? { credential, verifiedAt } : null
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
export function CertReviewCard(props: CertReviewCardProps) {
|
|
47
|
-
const
|
|
48
|
-
|
|
87
|
+
const reviewerName = text(props.reviewerName)
|
|
88
|
+
const verifyUrl = text(props.verifyUrl)
|
|
89
|
+
if (!reviewerName || !verifyUrl) return null // no chrome pre-cert (or an unreadable delivery)
|
|
49
90
|
|
|
50
|
-
//
|
|
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
|
|
91
|
+
// Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
|
|
54
92
|
const showCred = props.display?.showCredentials !== false
|
|
55
|
-
const withCred =
|
|
93
|
+
const withCred = showCred ? fusedCredential(props.credentialVerification) : null
|
|
56
94
|
|
|
57
95
|
const tokens = props.renderDef?.tokens
|
|
58
96
|
const theme: Partial<ResolvedBlockTheme> | undefined = tokens
|
|
59
97
|
? {
|
|
60
|
-
accentColor: tokens.accentColor,
|
|
61
|
-
surface: tokens.surface,
|
|
62
|
-
cornerRadius: tokens.cornerRadius,
|
|
63
|
-
fontSlot: tokens.fontSlot as ResolvedBlockTheme['fontSlot'] | undefined,
|
|
98
|
+
accentColor: optionalText(tokens.accentColor),
|
|
99
|
+
surface: optionalText(tokens.surface),
|
|
100
|
+
cornerRadius: optionalText(tokens.cornerRadius),
|
|
101
|
+
fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
|
|
64
102
|
// v2 brand-ink (post-demo): the bar + free-content ink follow the def.
|
|
65
|
-
barInk: tokens.barInk,
|
|
66
|
-
inkColor: tokens.inkColor,
|
|
103
|
+
barInk: optionalText(tokens.barInk),
|
|
104
|
+
inkColor: optionalText(tokens.inkColor),
|
|
67
105
|
}
|
|
68
106
|
: undefined
|
|
69
107
|
|
|
70
108
|
const html = renderCertBlock({
|
|
71
|
-
|
|
109
|
+
// NO fallback here, deliberately: `renderCertBlock` owns the ONE placement default
|
|
110
|
+
// (absent or unrecognised ⇒ sidebar). This line used to say `?? 'banner'`, and it was
|
|
111
|
+
// one of THREE layers each naming a default — here, the registered input's helper text,
|
|
112
|
+
// and the wire type's own doc comment — while the renderer, the only place that actually
|
|
113
|
+
// paints a face, named none. Three copies of a default is three chances to disagree.
|
|
114
|
+
mode: props.mode ?? undefined,
|
|
72
115
|
theme,
|
|
73
|
-
// First-class banner memo split
|
|
74
|
-
// part directly; no string-splitting. Ignored outside banner mode
|
|
116
|
+
// First-class banner memo split — renderCertBlock composes the requested
|
|
117
|
+
// part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
|
|
118
|
+
// value is normalised to 'full' there, same as `mode` — not here.
|
|
75
119
|
...(props.part ? { part: props.part } : {}),
|
|
76
120
|
facts: {
|
|
77
|
-
authorName: props.authorName ?? 'Editorial',
|
|
78
|
-
authorTitle: props.authorTitle
|
|
121
|
+
authorName: optionalText(props.authorName) ?? 'Editorial',
|
|
122
|
+
authorTitle: optionalText(props.authorTitle),
|
|
79
123
|
reviewerName,
|
|
80
|
-
credential: withCred
|
|
81
|
-
credentialVerifiedAt: withCred
|
|
82
|
-
certifiedAt: props.certifiedAt
|
|
83
|
-
memo: props.memo
|
|
84
|
-
bio: props.bio
|
|
85
|
-
profileUrl: props.reviewerProfileUrl
|
|
124
|
+
credential: withCred?.credential ?? '',
|
|
125
|
+
credentialVerifiedAt: withCred?.verifiedAt ?? '',
|
|
126
|
+
certifiedAt: text(props.certifiedAt),
|
|
127
|
+
memo: text(props.memo),
|
|
128
|
+
bio: optionalText(props.bio),
|
|
129
|
+
profileUrl: text(props.reviewerProfileUrl),
|
|
86
130
|
certificateUrl: verifyUrl,
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
131
|
+
// The compliance disclosures are CONSTANTS the renderer owns — a delivered cue is
|
|
132
|
+
// IGNORED (an editor cannot retype the FTC copy into something friendlier), and the
|
|
133
|
+
// scope line is not forwarded at all. The ONE thing honored is an explicit `null`:
|
|
134
|
+
// the pro-bono reviewer has no compensation to disclose, so the cue is omitted
|
|
135
|
+
// rather than replaced by the compensated constant (the modal already gets this
|
|
136
|
+
// right — a card claiming "Compensated expert" over a volunteer is an FTC-facing
|
|
137
|
+
// falsehood, not a cosmetic default).
|
|
138
|
+
compensationCue: props.compensationCue === null ? null : undefined,
|
|
91
139
|
},
|
|
92
140
|
})
|
|
93
141
|
|
|
94
142
|
return (
|
|
95
143
|
<div
|
|
96
144
|
data-certrev-cert-chrome=""
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
// The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
|
|
146
|
+
// carried the right type, so a mistyped option leaves the attribute off instead of
|
|
147
|
+
// stamping "[object Object]" as a version.
|
|
148
|
+
data-certrev-strings-version={optionalText(props.stringsVersion)}
|
|
149
|
+
data-certrev-def-version={typeof props.renderDef?.v === 'number' ? props.renderDef.v : undefined}
|
|
99
150
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
100
151
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
101
152
|
/>
|
|
@@ -106,12 +157,28 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
106
157
|
// Builder.io registration — inputs SINGLE-SOURCED from the wire-type keys (W1/W3)
|
|
107
158
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
159
|
|
|
109
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* The subset of the Builder.io input schema this kit emits. Every field below is verified
|
|
162
|
+
* against `@builder.io/sdk-react`'s own `Input` declaration (`types/types/input.d.ts`, 5.2.7) —
|
|
163
|
+
* a field the SDK ignores would be worse than none, because it READS as configured.
|
|
164
|
+
*/
|
|
110
165
|
export interface BuilderInput {
|
|
111
166
|
readonly name: string
|
|
112
167
|
readonly type: 'string' | 'longText' | 'object' | 'boolean' | 'number'
|
|
113
168
|
readonly friendlyName?: string
|
|
114
169
|
readonly helperText?: string
|
|
170
|
+
/** The value Builder pre-fills a freshly-dropped block with (`Input.defaultValue`). */
|
|
171
|
+
readonly defaultValue?: string
|
|
172
|
+
/**
|
|
173
|
+
* Turns a `string` input into a DROPDOWN of exactly these choices (`Input.enum`). The SDK
|
|
174
|
+
* accepts bare strings or `{label, value}` pairs; the kit uses pairs so the editor reads a
|
|
175
|
+
* capitalised label while the component still receives its lowercase mode/part literal.
|
|
176
|
+
* The ARRAY is deliberately mutable (the entries are not): the SDK types `Input.enum` as a
|
|
177
|
+
* mutable array, and a `readonly` one here makes the whole registration un-assignable to
|
|
178
|
+
* `RegisteredComponent` — the same reason `inputs` itself is mutable below (verified by
|
|
179
|
+
* compiling this shape against the installed SDK's `RegisteredComponent`).
|
|
180
|
+
*/
|
|
181
|
+
readonly enum?: { readonly label: string; readonly value: string }[]
|
|
115
182
|
/** Tucks the input under the editor's "Advanced" reveal (Builder's `Input.advanced`). */
|
|
116
183
|
readonly advanced?: boolean
|
|
117
184
|
}
|
|
@@ -139,6 +206,23 @@ const WIRE_INPUT_TYPE: Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], Builder
|
|
|
139
206
|
stringsVersion: 'string',
|
|
140
207
|
}
|
|
141
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The COMPLIANCE-COPY inputs, called what they are.
|
|
211
|
+
*
|
|
212
|
+
* `advanced: true` is only a UI fold, so an editor who opens "Advanced" sees these two beside
|
|
213
|
+
* the rest and reads them as theirs to type in. They are NOT: the renderer paints the locked
|
|
214
|
+
* `COMPENSATED_EXPERT_CUE` / `CERT_SCOPE_LINE` constants and ignores whatever is here. They
|
|
215
|
+
* stay REGISTERED because they are wire-type keys — dropping them would change the contract
|
|
216
|
+
* the portal exporter writes, and would strand the `null` that signals a pro-bono reviewer.
|
|
217
|
+
* So the fix is truthful labelling, not removal.
|
|
218
|
+
*/
|
|
219
|
+
const WIRE_INPUT_HELPER_TEXT: Partial<Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], string>> = {
|
|
220
|
+
compensationCue:
|
|
221
|
+
'System-populated by CertREV. Only its PRESENCE is read (absent = a pro-bono reviewer, so no cue renders); the cue itself is a locked constant, so editing this text changes nothing on the page — and clearing it does not remove the disclosure.',
|
|
222
|
+
scopeLine:
|
|
223
|
+
'System-populated by CertREV. The FTC scope line renders byte-verbatim from a locked constant — editing this text changes nothing on the page.',
|
|
224
|
+
}
|
|
225
|
+
|
|
142
226
|
/**
|
|
143
227
|
* The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the 19-field
|
|
144
228
|
* option set can never drift from the type (the W3 lock asserts name-parity). This
|
|
@@ -147,20 +231,45 @@ const WIRE_INPUT_TYPE: Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], Builder
|
|
|
147
231
|
export const WIRE_INPUTS: readonly BuilderInput[] = BUILDER_CERT_CHROME_KEYS.map((name) => ({
|
|
148
232
|
name,
|
|
149
233
|
type: WIRE_INPUT_TYPE[name],
|
|
234
|
+
...(WIRE_INPUT_HELPER_TEXT[name] ? { helperText: WIRE_INPUT_HELPER_TEXT[name] } : {}),
|
|
150
235
|
// Exporter-populated wire fields: collapsed under the editor's "Advanced" reveal so the
|
|
151
236
|
// Options tab leads with the two placement choices an editor actually makes (mode/part).
|
|
152
237
|
advanced: true,
|
|
153
238
|
}))
|
|
154
239
|
|
|
155
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* Placement inputs — chosen by the entry/editor, NOT emitted by the exporter (not wire-type keys).
|
|
242
|
+
*
|
|
243
|
+
* `defaultValue` + `enum` are what make these two REAL choices in the editor: a dropdown of the
|
|
244
|
+
* faces that exist, pre-filled with the same default the renderer applies. They were free-text
|
|
245
|
+
* with no default before, so the only description of the default was the helper text — which had
|
|
246
|
+
* drifted from the code (it advertised `banner`; the renderer's default is `sidebar`).
|
|
247
|
+
*/
|
|
156
248
|
export const PLACEMENT_INPUTS: readonly BuilderInput[] = [
|
|
157
249
|
{
|
|
158
250
|
name: 'mode',
|
|
159
251
|
type: 'string',
|
|
160
252
|
friendlyName: 'Placement',
|
|
161
|
-
helperText: '
|
|
253
|
+
helperText: 'sidebar (default) · banner · floating',
|
|
254
|
+
defaultValue: 'sidebar',
|
|
255
|
+
enum: [
|
|
256
|
+
{ label: 'Sidebar', value: 'sidebar' },
|
|
257
|
+
{ label: 'Banner', value: 'banner' },
|
|
258
|
+
{ label: 'Floating', value: 'floating' },
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'part',
|
|
263
|
+
type: 'string',
|
|
264
|
+
friendlyName: 'Banner part',
|
|
265
|
+
helperText: 'full (default) · header · memo — banner placement only',
|
|
266
|
+
defaultValue: 'full',
|
|
267
|
+
enum: [
|
|
268
|
+
{ label: 'Full (header + memo)', value: 'full' },
|
|
269
|
+
{ label: 'Header only', value: 'header' },
|
|
270
|
+
{ label: 'Memo only', value: 'memo' },
|
|
271
|
+
],
|
|
162
272
|
},
|
|
163
|
-
{ name: 'part', type: 'string', friendlyName: 'Banner part', helperText: 'full (default) · header · memo' },
|
|
164
273
|
]
|
|
165
274
|
|
|
166
275
|
/** A Builder.io custom-component registration (the general shape; the anchor uses the same). */
|
|
@@ -207,6 +316,6 @@ export const certRevCertComponent: CertReviewCardRegisteredComponent = {
|
|
|
207
316
|
name: CERT_COMPONENT_NAME,
|
|
208
317
|
component: CertReviewCard,
|
|
209
318
|
description:
|
|
210
|
-
'CertREV expert-review cert card. Options are populated by the CertREV exporter
|
|
319
|
+
'CertREV expert-review cert card. Options are populated by the CertREV exporter; editors choose placement (mode/part) only.',
|
|
211
320
|
inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
|
|
212
321
|
}
|
package/src/builder/index.tsx
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @certrev/cert-block/builder — Builder.io adapter, "ambient-URL anchor" model.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* re-opens the wrong-subject surface. Instead:
|
|
4
|
+
* A visual page builder must NOT choose WHICH credential renders — that invites cross-article
|
|
5
|
+
* misuse and re-opens the wrong-subject surface. The ANCHOR path splits the two concerns:
|
|
7
6
|
* • the CMS controls LAYOUT — an editor drags a ZERO-INPUT <CertRevAnchor/> to position the
|
|
8
7
|
* badge on the page;
|
|
9
8
|
* • the SYSTEM controls TRUTH — the headless loader resolves THIS page's credential from its
|
|
10
|
-
* own URL
|
|
11
|
-
*
|
|
9
|
+
* own URL, crypto-verifies it (fail-closed VerdictKernel), and supplies the verdict via
|
|
10
|
+
* context.
|
|
12
11
|
* The anchor renders the current page's verified badge or NOTHING. It cannot be pointed at
|
|
13
12
|
* another article, and there is no `placementId` input to forge or mis-select.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* THE PUSH KIT DOES NOT INHERIT THAT PROPERTY, and this module exports it too. `CertReviewCard` /
|
|
15
|
+
* `certRevCertComponent` register the 19 wire-type fields — `reviewerName`,
|
|
16
|
+
* `credentialVerification`, `verifyUrl`, `memo` — as Builder INPUTS, and the card renders what
|
|
17
|
+
* those inputs hold; it runs no signature check and consults no verdict. `advanced: true` folds
|
|
18
|
+
* them out of the editor's default view, which is a UI reveal and not a lock, and `safeHttpUrl`
|
|
19
|
+
* bounds `verifyUrl` to the http(s) SCHEME rather than to a CertREV origin. So to the card a
|
|
20
|
+
* hand-typed option and an exporter-written one are the same string: the trust boundary on the
|
|
21
|
+
* PUSH path is WRITE ACCESS TO THE CMS SPACE, not a signature, and nothing in this package
|
|
22
|
+
* narrows it. See "PUSH trust model" in the package README for the full statement — including
|
|
23
|
+
* what a revocation upstream does and does not reach.
|
|
18
24
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* Three things the card DOES enforce, and they are the limit of it: no chrome at all without a
|
|
26
|
+
* `reviewerName` and a `verifyUrl`; a credential renders only welded to its dated verification
|
|
27
|
+
* (the fused pair); and the FTC disclosures render from locked constants, so rewriting the
|
|
28
|
+
* `compensationCue` / `scopeLine` options changes no pixel. Use the anchor when the page itself
|
|
29
|
+
* must not be able to state a credential.
|
|
30
|
+
*
|
|
31
|
+
* JSON-LD is DECOUPLED: the anchor renders the BADGE only (`omitJsonLd`). The article template
|
|
32
|
+
* emits the schema.org JSON-LD server-side from the same verified verdict, so a page builder
|
|
33
|
+
* can't duplicate, move, or omit structured data. See "The article route example" in the package
|
|
34
|
+
* README for the route both halves meet in.
|
|
35
|
+
*
|
|
36
|
+
* EDGE-CACHE DISCIPLINE: the consumer's loader MUST verify per request as a dynamic edge
|
|
37
|
+
* subrequest and must NOT long-cache the rendered badge HTML — a revoked/expired credential
|
|
38
|
+
* sitting in a stale edge cache would defeat fail-closed. Bound any positive-verdict cache by
|
|
39
|
+
* min(expiry, revocation-TTL, content-version).
|
|
23
40
|
*
|
|
24
41
|
* cert-block carries no Builder dependency: the registration shape is declared structurally, and
|
|
25
42
|
* `isEditing` is a passed-in flag (the consumer supplies Builder's `isPreviewing()`).
|
|
@@ -28,7 +45,7 @@ import { createContext, type ReactNode, useContext } from 'react'
|
|
|
28
45
|
import { CertReview } from '../components/CertReview.js'
|
|
29
46
|
import type { CertVerdict } from '../contract/kernel.js'
|
|
30
47
|
|
|
31
|
-
/** The current page's credential, resolved + verified by the loader
|
|
48
|
+
/** The current page's credential, resolved + verified by the consumer's loader. */
|
|
32
49
|
export interface CurrentCredential {
|
|
33
50
|
readonly verdict: CertVerdict
|
|
34
51
|
/** Canonical page URL for JSON-LD @id alignment (used by the server-side JSON-LD, not the anchor). */
|
|
@@ -87,8 +104,8 @@ export function CertRevEditorPlaceholder() {
|
|
|
87
104
|
/**
|
|
88
105
|
* The Builder block. ZERO inputs — a layout anchor only. Renders the current page's VERIFIED
|
|
89
106
|
* badge (badge only; JSON-LD is emitted server-side), the editor placeholder in design mode, or
|
|
90
|
-
* NOTHING in production (fail-closed). The credential is whatever
|
|
91
|
-
* THIS page; the editor can neither choose nor forge it.
|
|
107
|
+
* NOTHING in production (fail-closed). The credential is whatever the loader delivered + verified
|
|
108
|
+
* for THIS page; the editor can neither choose nor forge it.
|
|
92
109
|
*/
|
|
93
110
|
export function CertRevAnchor() {
|
|
94
111
|
const { current, isEditing } = useContext(CertRevContext)
|
|
@@ -121,7 +138,7 @@ export const certRevAnchorComponent: CertRevBuilderRegistration = {
|
|
|
121
138
|
inputs: [],
|
|
122
139
|
}
|
|
123
140
|
|
|
124
|
-
// ──
|
|
141
|
+
// ── The Builder.io PUSH kit (CertReviewCard) + the single-sourced wire type ──
|
|
125
142
|
export {
|
|
126
143
|
BUILDER_CERT_CHROME_KEYS,
|
|
127
144
|
type BuilderCertChromeData,
|
|
@@ -133,10 +150,10 @@ export {
|
|
|
133
150
|
type BuilderInput,
|
|
134
151
|
CERT_COMPONENT_NAME,
|
|
135
152
|
type CertBlockBuilderRegistration,
|
|
136
|
-
certRevCertComponent,
|
|
137
153
|
CertReviewCard,
|
|
138
154
|
type CertReviewCardProps,
|
|
139
155
|
type CertReviewCardRegisteredComponent,
|
|
156
|
+
certRevCertComponent,
|
|
140
157
|
PLACEMENT_INPUTS,
|
|
141
158
|
WIRE_INPUTS,
|
|
142
159
|
} from './cert-review-card.js'
|