@certrev/cert-block 1.1.1 → 1.2.0-canary.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.
- package/CHANGELOG.md +71 -0
- package/README.md +66 -4
- package/dist/builder/cert-chrome-data.d.ts +10 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +22 -2
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/envelope-face.d.ts +25 -1
- package/dist/builder/envelope-face.d.ts.map +1 -1
- package/dist/builder/envelope-face.js +40 -0
- package/dist/builder/envelope-face.js.map +1 -1
- package/dist/builder/index.d.ts +1 -1
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +1 -1
- package/dist/builder/index.js.map +1 -1
- package/dist/reader/face.d.ts +12 -0
- package/dist/reader/face.d.ts.map +1 -0
- package/dist/reader/face.js +19 -0
- package/dist/reader/face.js.map +1 -0
- package/dist/reader/helpers.d.ts +48 -0
- package/dist/reader/helpers.d.ts.map +1 -0
- package/dist/reader/helpers.js +166 -0
- package/dist/reader/helpers.js.map +1 -0
- package/dist/reader/index.d.ts +10 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +10 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/logo.d.ts +4 -0
- package/dist/reader/logo.d.ts.map +1 -0
- package/dist/reader/logo.js +23 -0
- package/dist/reader/logo.js.map +1 -0
- package/dist/reader/reader-css.generated.d.ts +2 -0
- package/dist/reader/reader-css.generated.d.ts.map +1 -0
- package/dist/reader/reader-css.generated.js +6 -0
- package/dist/reader/reader-css.generated.js.map +1 -0
- package/dist/reader/render.d.ts +26 -0
- package/dist/reader/render.d.ts.map +1 -0
- package/dist/reader/render.js +238 -0
- package/dist/reader/render.js.map +1 -0
- package/dist/reader/tokens.d.ts +8 -0
- package/dist/reader/tokens.d.ts.map +1 -0
- package/dist/reader/tokens.js +80 -0
- package/dist/reader/tokens.js.map +1 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +5 -1
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +7 -1
- package/src/builder/cert-chrome-data.ts +10 -0
- package/src/builder/cert-review-card.tsx +31 -2
- package/src/builder/envelope-face.ts +54 -5
- package/src/builder/index.tsx +3 -0
- package/src/reader/face.ts +35 -0
- package/src/reader/helpers.ts +178 -0
- package/src/reader/index.ts +14 -0
- package/src/reader/logo.ts +28 -0
- package/src/reader/reader-css.generated.ts +5 -0
- package/src/reader/reader.css +550 -0
- package/src/reader/render.ts +326 -0
- package/src/reader/tokens.ts +93 -0
- package/src/verify/get-verified-delivery.ts +5 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* `renderReaderFace` — the certrev.com "reader" face, as the Builder card paints it (1.2.0)
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The same markup, byte for byte, as the Shopify `certrev-reader-{banner,memo,sidebar,pill}` snippets, which are the
|
|
7
|
+
* portal's canonical render of the face (the "What your reader sees" demo on certrev.com). Styling is `reader.css`
|
|
8
|
+
* (the generated reader section of the Shopify stylesheet, `READER_CSS`); this file only emits the DOM it targets.
|
|
9
|
+
* The golden tests compare the sections to the portal's golden pages, and the differential tests compare edge cases to
|
|
10
|
+
* the portal's own Liquid harness output, so the two renderers cannot drift without a test saying so.
|
|
11
|
+
*
|
|
12
|
+
* It is a NEW face, selected only by a delivered render def that says `face: 'reader'` (see `envelopeCardHtml`).
|
|
13
|
+
* Nothing in the legacy `renderCertBlock` is read or changed here.
|
|
14
|
+
*
|
|
15
|
+
* The facts are read exactly as the snippets read the same envelope:
|
|
16
|
+
* - the card renders only with a reviewer name (the memo and sidebar too); the pill needs only a verify URL;
|
|
17
|
+
* - the credential is each credential's `cardLabel` (else `abbreviation`), joined; the "Credential verified by CertREV"
|
|
18
|
+
* attribution is gated on a displayed credential, and "on {date}" binds `expert.credentialVerifiedAt` and nothing
|
|
19
|
+
* else (no fallback to the certified date: with no date only the undated, still-true attribution prints);
|
|
20
|
+
* - the compensation cue renders ONLY when the envelope carries `expert.compensationCue`; the def cannot hide it, and
|
|
21
|
+
* its words are fixed. `illustrative` (a CertREV preview, never a live page) fills the cue's slot with the demo's
|
|
22
|
+
* "Illustrative example" label when the envelope has no cue; a real cue always wins the slot;
|
|
23
|
+
* - the scope line is never hideable: the card carries it when there is no memo, the memo card and the sidebar always;
|
|
24
|
+
* - the def's `hidden` set removes the avatars (`reviewerPhoto`), `display.showExpertPhoto === false` keeps the
|
|
25
|
+
* silhouette and never requests the photo, `display.showAuthor === false` drops the author column.
|
|
26
|
+
*
|
|
27
|
+
* Every interpolated field is escaped and every URL passes `safeUrl`. A hostile type (a number, an object) renders as
|
|
28
|
+
* nothing, never as a throw: see `helpers.ts`.
|
|
29
|
+
*/
|
|
30
|
+
import { asText } from '../components/escape.js'
|
|
31
|
+
import { CERTREV_LINK_REL } from '../components/rel.js'
|
|
32
|
+
import { CERT_SCOPE_LINE, CERT_STRINGS_VERSION, COMPENSATED_EXPERT_CUE } from '../components/render-def.js'
|
|
33
|
+
import type { CertContent } from '../contract/kernel.js'
|
|
34
|
+
import { readerFace } from './face.js'
|
|
35
|
+
import {
|
|
36
|
+
cleaned,
|
|
37
|
+
contains,
|
|
38
|
+
esc,
|
|
39
|
+
firstName,
|
|
40
|
+
isBlank,
|
|
41
|
+
isPresent,
|
|
42
|
+
isTruthy,
|
|
43
|
+
longDate,
|
|
44
|
+
type Rec,
|
|
45
|
+
rec,
|
|
46
|
+
safeUrl,
|
|
47
|
+
textOr,
|
|
48
|
+
} from './helpers.js'
|
|
49
|
+
import { readerLogo } from './logo.js'
|
|
50
|
+
import { readerTokens } from './tokens.js'
|
|
51
|
+
|
|
52
|
+
export type ReaderLayout = 'banner' | 'sidebar' | 'floating'
|
|
53
|
+
|
|
54
|
+
/** Which of the banner's two cards to paint: both (the page default), the byline card alone, or the memo card alone. */
|
|
55
|
+
export type ReaderPart = 'full' | 'header' | 'memo'
|
|
56
|
+
|
|
57
|
+
export interface ReaderFaceInput {
|
|
58
|
+
/** The verified envelope's `payload.content`. */
|
|
59
|
+
readonly content: CertContent
|
|
60
|
+
/** The delivered render def (already known to say `face: 'reader'`); read for tokens and `hidden`. */
|
|
61
|
+
readonly def?: unknown
|
|
62
|
+
readonly layout: ReaderLayout
|
|
63
|
+
/** Banner only; the sidebar and the floating pill are one section each. Defaults to `full`. */
|
|
64
|
+
readonly part?: ReaderPart
|
|
65
|
+
/** The envelope's `payload.certId`, stamped as `data-certrev-cert-id` (the revalidation hook reads it). */
|
|
66
|
+
readonly certId?: string
|
|
67
|
+
/** A CertREV preview (never a live page): label a cue-less article "Illustrative example". */
|
|
68
|
+
readonly illustrative?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* Every reader anchor carries the package's own link policy, `CERTREV_LINK_REL` (`nofollow sponsored noopener`), the
|
|
73
|
+
* same as the legacy faces: a Builder page's CertREV links are a vendor-required placement and must keep the
|
|
74
|
+
* sponsored/nofollow signal. The Shopify snippets (and so the portal's goldens) say `rel="noopener noreferrer"`; that is
|
|
75
|
+
* theirs, not the target. It is the ONE byte the reader differs from them in, and `reader-link-rel.test.ts` pins that:
|
|
76
|
+
* the golden and differential tests rewrite the Liquid's rel on anchors to this value and compare everything else exactly.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/** What every section of one render shares: the facts read once from the envelope and the def. */
|
|
80
|
+
interface Facts {
|
|
81
|
+
readonly content: Rec
|
|
82
|
+
readonly expert: Rec
|
|
83
|
+
readonly disp: Rec
|
|
84
|
+
readonly def: unknown
|
|
85
|
+
readonly certId: string
|
|
86
|
+
readonly ename: string
|
|
87
|
+
readonly hideAvatar: boolean
|
|
88
|
+
readonly showPhoto: boolean
|
|
89
|
+
/** The joined credential labels, '' when there are none. */
|
|
90
|
+
readonly creds: string
|
|
91
|
+
readonly hasCue: boolean
|
|
92
|
+
readonly illus: boolean
|
|
93
|
+
readonly noMemo: boolean
|
|
94
|
+
readonly purl: string
|
|
95
|
+
readonly vurl: string
|
|
96
|
+
readonly style: string
|
|
97
|
+
readonly fg: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readFacts(input: ReaderFaceInput): Facts {
|
|
101
|
+
const content = rec(input.content)
|
|
102
|
+
const expert = rec(content.expert)
|
|
103
|
+
const disp = rec(content.display)
|
|
104
|
+
const def = input.def
|
|
105
|
+
const rd = rec(def)
|
|
106
|
+
|
|
107
|
+
const hideAvatar = contains(rd.hidden, 'reviewerPhoto')
|
|
108
|
+
const showPhoto = disp.showExpertPhoto !== false && !hideAvatar
|
|
109
|
+
|
|
110
|
+
// POR-9738: each credential's cardLabel over its bare abbreviation, joined with ', ' (labels are not trimmed).
|
|
111
|
+
const labels: string[] = []
|
|
112
|
+
if (Array.isArray(expert.credentials)) {
|
|
113
|
+
for (const credential of expert.credentials) {
|
|
114
|
+
const cr = rec(credential)
|
|
115
|
+
const label = asText(isPresent(cr.cardLabel) ? cr.cardLabel : cr.abbreviation)
|
|
116
|
+
if (!isBlank(label)) labels.push(label)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const hasCue = isPresent(expert.compensationCue)
|
|
121
|
+
const { style, fg } = readerTokens(def)
|
|
122
|
+
return {
|
|
123
|
+
content,
|
|
124
|
+
expert,
|
|
125
|
+
disp,
|
|
126
|
+
def,
|
|
127
|
+
certId: asText(input.certId),
|
|
128
|
+
ename: textOr(expert.displayName),
|
|
129
|
+
hideAvatar,
|
|
130
|
+
showPhoto,
|
|
131
|
+
creds: labels.join(', '),
|
|
132
|
+
hasCue,
|
|
133
|
+
illus: input.illustrative === true && !hasCue,
|
|
134
|
+
noMemo: isBlank(content.memo),
|
|
135
|
+
purl: cleaned(safeUrl(expert.profileUrl)),
|
|
136
|
+
vurl: cleaned(safeUrl(content.verifyUrl)),
|
|
137
|
+
style,
|
|
138
|
+
fg,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The `<section>` open tag every reader section shares. `badge` is the `.certrev-badge` hook the revalidation reads. */
|
|
143
|
+
function sectionOpen(f: Facts, badge: boolean, aria: string): string {
|
|
144
|
+
const cls = badge ? 'certrev-reader certrev-badge' : 'certrev-reader'
|
|
145
|
+
// `{% if rd %}`: any def that is not nil/false stamps its version.
|
|
146
|
+
const version = isTruthy(f.def) ? ` data-certrev-def-version="${esc(rec(f.def).v)}"` : ''
|
|
147
|
+
return (
|
|
148
|
+
`<section class="${cls}" style="${f.style}"${version} data-certrev-strings-version="${CERT_STRINGS_VERSION}" ` +
|
|
149
|
+
`data-certrev-cert-id="${esc(f.certId)}" aria-label="${esc(aria)}">`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const anchor = (href: string, modal: string, label: string): string =>
|
|
154
|
+
`<a href="${esc(href)}" target="_blank" rel="${CERTREV_LINK_REL}" data-certrev-modal-open${modal}>${label}</a>`
|
|
155
|
+
|
|
156
|
+
/** The words the memo-less banner and the memo link row use for the profile link. */
|
|
157
|
+
function profileLabel(f: Facts): string {
|
|
158
|
+
const first = cleaned(firstName(f.ename))
|
|
159
|
+
return first !== '' ? `View ${esc(first)}’s profile` : 'View profile'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const credentialAria = (f: Facts): string => `Content reviewed by ${f.ename}${f.creds !== '' ? `, ${f.creds}` : ''}`
|
|
163
|
+
|
|
164
|
+
/** The compensation cue's slot at the head of the scope line (memo footer, sidebar). */
|
|
165
|
+
function scopeLead(f: Facts): string {
|
|
166
|
+
if (f.hasCue) return `<em>${COMPENSATED_EXPERT_CUE}</em> · `
|
|
167
|
+
if (f.illus) return '<em>Illustrative example</em> · '
|
|
168
|
+
return ''
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const SCOPE = `<span data-ftc-line>${CERT_SCOPE_LINE}</span>`
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The muted credential line, empty when the reviewer has no displayed credential (so no "verified" claim prints).
|
|
175
|
+
*
|
|
176
|
+
* The banner's is the demo's three leaves in one `<em>`: the credential, " · ", and the attribution, which reads
|
|
177
|
+
* "Credential verified by CertREV on {date}" or, with no date, just "Credential verified by CertREV". The spans carry
|
|
178
|
+
* no style of their own. The sidebar names the credential in its bold line already, so its `<em>` is the attribution
|
|
179
|
+
* alone.
|
|
180
|
+
*/
|
|
181
|
+
function verifiedLine(f: Facts, withCredentials: boolean): string {
|
|
182
|
+
if (f.creds === '') return ''
|
|
183
|
+
const date = cleaned(isBlank(f.expert.credentialVerifiedAt) ? '' : longDate(f.expert.credentialVerifiedAt))
|
|
184
|
+
const on = date !== '' ? ` on ${esc(date)}` : ''
|
|
185
|
+
if (!withCredentials) return `<em>Credential verified by CertREV${on}</em>`
|
|
186
|
+
return `<em><span>${esc(f.creds)}</span> · <span>Credential verified by CertREV${on}</span></em>`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// Banner: the "Expert reviewed & certified" card, and the expert memo card beneath it
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function bannerCard(f: Facts): string {
|
|
194
|
+
if (f.ename === '') return ''
|
|
195
|
+
const author = rec(f.content.author)
|
|
196
|
+
const authorName = textOr(author.name)
|
|
197
|
+
const authorTitle = textOr(author.title)
|
|
198
|
+
const hasAuthor = f.disp.showAuthor !== false && authorName !== '' && authorName !== f.ename
|
|
199
|
+
const avatar = (photo: unknown, size: 'sm' | ''): string =>
|
|
200
|
+
f.hideAvatar ? '' : readerFace({ photo, show: f.showPhoto, size, width: 84 })
|
|
201
|
+
|
|
202
|
+
const authorBy = hasAuthor
|
|
203
|
+
? `<div class="certrev-r-by">${avatar(author.photoUrl, 'sm')}<div>\n <b>${esc(authorName)}</b>` +
|
|
204
|
+
`${authorTitle !== '' ? `<em>${esc(authorTitle)}</em>` : ''}<span class="certrev-r-role">Written by</span>\n </div>\n </div>`
|
|
205
|
+
: ''
|
|
206
|
+
const expertBy =
|
|
207
|
+
`<div class="certrev-r-by">${avatar(f.expert.photoUrl, '')}<div>\n <b>${esc(f.ename)}</b>` +
|
|
208
|
+
`${verifiedLine(f, true)}<span class="certrev-r-role on">Reviewed by</span>\n </div>\n </div>`
|
|
209
|
+
|
|
210
|
+
const certifiedAt = f.content.certifiedAt
|
|
211
|
+
const certified = isBlank(certifiedAt) ? '' : cleaned(longDate(certifiedAt))
|
|
212
|
+
|
|
213
|
+
// A memo-less article gets the profile and certificate row, so the certificate stays reachable.
|
|
214
|
+
let links = ''
|
|
215
|
+
if (f.noMemo && (f.purl !== '' || f.vurl !== '')) {
|
|
216
|
+
links =
|
|
217
|
+
'<div class="certrev-r-links">' +
|
|
218
|
+
(f.purl !== '' ? anchor(f.purl, '="expert"', profileLabel(f)) : '') +
|
|
219
|
+
(f.vurl !== '' ? anchor(f.vurl, '', 'View certificate') : '') +
|
|
220
|
+
'</div>'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const hasFoot = certified !== '' || f.hasCue || f.illus
|
|
224
|
+
const rule = hasFoot || f.noMemo ? '<div class="certrev-r-rule"></div>' : ''
|
|
225
|
+
|
|
226
|
+
let foot = ''
|
|
227
|
+
if (hasFoot) {
|
|
228
|
+
const when =
|
|
229
|
+
certified !== ''
|
|
230
|
+
? `<span><em>Certified</em><time datetime="${esc(certifiedAt)}">${esc(certified)}</time></span>`
|
|
231
|
+
: '<span></span>'
|
|
232
|
+
let cue = ''
|
|
233
|
+
if (f.hasCue) {
|
|
234
|
+
cue = `<span>Compensated ${f.purl !== '' ? anchor(f.purl, '="expert"', 'expert') : 'expert'}</span>`
|
|
235
|
+
} else if (f.illus) {
|
|
236
|
+
cue = '<span>Illustrative example</span>'
|
|
237
|
+
}
|
|
238
|
+
foot = `<div class="certrev-r-foot">${when}${cue}</div>`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// POR-9317: the memo card carries the scope line when there is a memo; with none this card is the sole claim face.
|
|
242
|
+
const scope = f.noMemo ? `<div class="certrev-r-scope" data-ftc-disclosure>${SCOPE}</div>` : ''
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
`${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-card">\n` +
|
|
246
|
+
` <div class="certrev-r-cap">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed & certified</div>\n` +
|
|
247
|
+
` <div class="certrev-r-cap-body">\n <div class="certrev-r-bys${hasAuthor ? '' : ' solo'}">${authorBy}${expertBy}\n </div>` +
|
|
248
|
+
`${links}${rule}${foot}${scope}</div>\n </div>\n</section>`
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function bannerMemo(f: Facts): string {
|
|
253
|
+
if (f.ename === '' || isBlank(f.content.memo)) return ''
|
|
254
|
+
const links =
|
|
255
|
+
f.purl !== '' || f.vurl !== ''
|
|
256
|
+
? '<div class="certrev-r-links">' +
|
|
257
|
+
(f.purl !== '' ? anchor(f.purl, '="expert"', profileLabel(f)) : '') +
|
|
258
|
+
(f.vurl !== '' ? anchor(f.vurl, '', 'View certificate') : '') +
|
|
259
|
+
'</div>'
|
|
260
|
+
: ''
|
|
261
|
+
const avatar = f.hideAvatar ? '' : readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, size: 'lg', width: 112 })
|
|
262
|
+
return (
|
|
263
|
+
`${sectionOpen(f, false, `Expert memo by ${f.ename}`)}\n <div class="certrev-r-card pad">\n` +
|
|
264
|
+
' <div class="certrev-r-kick">Expert memo</div>\n' +
|
|
265
|
+
` <div class="certrev-r-memo">${avatar}<div>\n` +
|
|
266
|
+
` <div class="certrev-r-memo-name"><b>${esc(f.ename)}</b>${f.creds !== '' ? `, ${esc(f.creds)}` : ''}</div>\n` +
|
|
267
|
+
` <blockquote>${esc(f.content.memo)}</blockquote>${links}</div>\n </div>\n` +
|
|
268
|
+
' <div class="certrev-r-rule"></div>\n <div class="certrev-r-vfoot">\n' +
|
|
269
|
+
` <span class="certrev-r-verified">${readerLogo('mono', 17, '')}Verified by <b>CertREV</b></span>\n` +
|
|
270
|
+
` <span class="certrev-r-scope" data-ftc-disclosure>${scopeLead(f)}${SCOPE}</span>\n </div>\n </div>\n</section>`
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
// Sidebar and floating pill
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function sidebar(f: Facts): string {
|
|
279
|
+
if (f.ename === '') return ''
|
|
280
|
+
const bio = f.disp.showBio !== false && !isBlank(f.expert.bio) ? asText(f.expert.bio) : ''
|
|
281
|
+
const avatar = f.hideAvatar ? '' : readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, size: 'lg', width: 112 })
|
|
282
|
+
const both = f.purl !== '' && f.vurl !== ''
|
|
283
|
+
const links =
|
|
284
|
+
f.purl !== '' || f.vurl !== ''
|
|
285
|
+
? `<div class="certrev-r-side-links${both ? '' : ' single'}">` +
|
|
286
|
+
(f.purl !== '' ? anchor(f.purl, '="expert"', 'View Profile') : '') +
|
|
287
|
+
(f.vurl !== '' ? anchor(f.vurl, '', 'View Certificate') : '') +
|
|
288
|
+
'</div>'
|
|
289
|
+
: ''
|
|
290
|
+
return (
|
|
291
|
+
`${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-side">\n` +
|
|
292
|
+
` <div class="certrev-r-cap center">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed</div>\n` +
|
|
293
|
+
` <div class="certrev-r-side-body">\n <div class="certrev-r-by">${avatar}<div>\n` +
|
|
294
|
+
` <b>${esc(f.ename)}${f.creds !== '' ? `, ${esc(f.creds)}` : ''}</b>${verifiedLine(f, false)}</div>\n </div>` +
|
|
295
|
+
`${bio !== '' ? `<p>${esc(bio)}</p>` : ''}` +
|
|
296
|
+
`${isBlank(f.content.memo) ? '' : `<blockquote>${esc(f.content.memo)}</blockquote>`}` +
|
|
297
|
+
`<div class="certrev-r-scope" data-ftc-disclosure>${scopeLead(f)}${SCOPE}</div>\n </div>` +
|
|
298
|
+
`${links}</div>\n</section>`
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function pill(f: Facts): string {
|
|
303
|
+
if (f.vurl === '') return ''
|
|
304
|
+
const profile = f.purl !== '' ? `${anchor(f.purl, '="expert"', 'Profile')}<i aria-hidden="true">·</i>` : ''
|
|
305
|
+
return (
|
|
306
|
+
`${sectionOpen(f, true, 'Expert reviewed')}\n <div class="certrev-r-pill">\n <span>Expert reviewed:</span>` +
|
|
307
|
+
`${profile}${anchor(f.vurl, '', 'Certificate')}\n </div>\n</section>`
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The reader face's HTML for one layout. '' where the face paints nothing (no reviewer name; a pill with no safe
|
|
313
|
+
* verify URL; a banner `part` that has no block): the caller treats '' as "render nothing", never as an empty section.
|
|
314
|
+
*
|
|
315
|
+
* A def with no `layout` meaning here (`custom`, or any value that is not banner, sidebar or floating) is the
|
|
316
|
+
* caller's to resolve before it calls: this takes the layout it will paint.
|
|
317
|
+
*/
|
|
318
|
+
export function renderReaderFace(input: ReaderFaceInput): string {
|
|
319
|
+
const f = readFacts(input)
|
|
320
|
+
if (input.layout === 'sidebar') return sidebar(f)
|
|
321
|
+
if (input.layout === 'floating') return pill(f)
|
|
322
|
+
const part = input.part ?? 'full'
|
|
323
|
+
if (part === 'header') return bannerCard(f)
|
|
324
|
+
if (part === 'memo') return bannerMemo(f)
|
|
325
|
+
return [bannerCard(f), bannerMemo(f)].filter((block) => block !== '').join('\n')
|
|
326
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `certrev-reader-tokens`: the four CSS custom properties a reader section carries on its root, from the render def.
|
|
3
|
+
*
|
|
4
|
+
* --cr-ba the accent the card cap and pill paint: the def's `barInk`, else its `accentColor`, else navy
|
|
5
|
+
* --cr-ba-fg the ink on that accent: white, or `#141414` when the def baked it for the colour that painted
|
|
6
|
+
* --cr-br the corner radius
|
|
7
|
+
* --cr-bf the font stack for the brand's `fontSlot`
|
|
8
|
+
*
|
|
9
|
+
* The values land inside a double-quoted `style` attribute, and they are held to the allow-list the Liquid snippet and
|
|
10
|
+
* the PHP twin's def parser hold them to: a colour is `#RGB` or `#RRGGBB`, a radius one non-negative px / rem / em / %
|
|
11
|
+
* length or a bare `0`, and anything else paints the default (navy, 14px). A denylist of characters is not enough: a
|
|
12
|
+
* quote, a backslash, a comment opener or a bracket left in a value hides the `;` that ends it and swallows the
|
|
13
|
+
* declarations after it. The variable names are the reader stylesheet's (`reader.css`), which `.certrev-reader`
|
|
14
|
+
* declares defaults for.
|
|
15
|
+
*/
|
|
16
|
+
import { asText } from '../components/escape.js'
|
|
17
|
+
import { esc, rec, textOr } from './helpers.js'
|
|
18
|
+
|
|
19
|
+
/** The accent when the def has none: CertREV navy. */
|
|
20
|
+
const DEFAULT_ACCENT = '#0A1B3F'
|
|
21
|
+
const DEFAULT_RADIUS = '14px'
|
|
22
|
+
/** The foreground the def bakes for a light accent; anything else is the white default. */
|
|
23
|
+
const DARK_INK = '#141414'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The font stack per `fontSlot`, in one table so the Liquid `certrev-reader-tokens` snippet and this one are compared
|
|
27
|
+
* on a single list. An unknown or missing slot is the sans stack, as in the snippet. The serif and grotesk stacks name
|
|
28
|
+
* the reader's own `@font-face` aliases first ("CertREV Reader Serif" / "CertREV Reader Grotesk", declared in
|
|
29
|
+
* `reader.css`), then the legacy family and the generic fallbacks, as the snippet's `--cr-bf` does.
|
|
30
|
+
*/
|
|
31
|
+
function fontStack(slot: string): string {
|
|
32
|
+
switch (slot) {
|
|
33
|
+
case 'serif':
|
|
34
|
+
return "'CertREV Reader Serif', 'Playfair Display', ui-serif, Georgia, serif"
|
|
35
|
+
case 'grotesk':
|
|
36
|
+
return "'CertREV Reader Grotesk', 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif"
|
|
37
|
+
case 'mono':
|
|
38
|
+
return "'JetBrains Mono', ui-monospace, monospace"
|
|
39
|
+
case 'system':
|
|
40
|
+
return "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
41
|
+
default:
|
|
42
|
+
return "'DM Sans', ui-sans-serif, system-ui, sans-serif"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A colour is `#` and then 3 or 6 hex digits (ASCII only, no trailing newline), else '' (which the caller defaults). */
|
|
47
|
+
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
|
48
|
+
/**
|
|
49
|
+
* A radius is a bare `0`, or digits with at most one `.` (not trailing) and then px, rem, em or %, else ''. `\d` is
|
|
50
|
+
* ASCII here (no `u` flag), as the snippet's `0123456789.` is; the unit is case-sensitive.
|
|
51
|
+
*/
|
|
52
|
+
const LENGTH = /^(?:\d+|\d+\.\d+|\.\d+)(?:px|rem|em|%)$/
|
|
53
|
+
|
|
54
|
+
/** A colour token that is a hex colour, or '' (which the caller defaults). */
|
|
55
|
+
function hexColour(value: unknown): string {
|
|
56
|
+
const text = textOr(value)
|
|
57
|
+
return HEX.test(text) ? text : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A radius token that is a bare 0 or a non-negative length, or '' (which the caller defaults). */
|
|
61
|
+
function radius(value: unknown): string {
|
|
62
|
+
const text = textOr(value)
|
|
63
|
+
return text === '0' || LENGTH.test(text) ? text : ''
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ReaderTokens {
|
|
67
|
+
/** The value of the section's `style` attribute. */
|
|
68
|
+
readonly style: string
|
|
69
|
+
/** The ink on the accent: `#ffffff` or `#141414`. The logo is white on the first and mono on the second. */
|
|
70
|
+
readonly fg: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readerTokens(def: unknown): ReaderTokens {
|
|
74
|
+
const rd = rec(def)
|
|
75
|
+
const tokens = rec(rd.tokens)
|
|
76
|
+
|
|
77
|
+
const barInk = hexColour(tokens.barInk)
|
|
78
|
+
const accent = hexColour(tokens.accentColor)
|
|
79
|
+
const ba = barInk || accent || DEFAULT_ACCENT
|
|
80
|
+
|
|
81
|
+
// The ink follows the value that painted the accent: a def with a bar ink takes the foreground baked for it
|
|
82
|
+
// (barInkFg); one that paints its accent colour takes the one baked for that colour (accentFg).
|
|
83
|
+
let fg = '#ffffff'
|
|
84
|
+
if (barInk !== '') {
|
|
85
|
+
if (rd.barInkFg === DARK_INK) fg = DARK_INK
|
|
86
|
+
} else if (accent !== '' && rd.accentFg === DARK_INK) {
|
|
87
|
+
fg = DARK_INK
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const br = radius(tokens.cornerRadius) || DEFAULT_RADIUS
|
|
91
|
+
const bf = fontStack(asText(tokens.fontSlot))
|
|
92
|
+
return { style: `--cr-ba:${esc(ba)};--cr-ba-fg:${fg};--cr-br:${esc(br)};--cr-bf:${bf}`, fg }
|
|
93
|
+
}
|
|
@@ -99,7 +99,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
|
|
|
99
99
|
*/
|
|
100
100
|
export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
|
|
101
101
|
if (!isRecord(value)) return null
|
|
102
|
-
const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value
|
|
102
|
+
const { v, preset, rung, tokens, hidden, barInkFg, accentFg, face: faceName, layout, customFace } = value
|
|
103
103
|
if (typeof v !== 'number' || !Number.isFinite(v)) return null
|
|
104
104
|
if (!isString(preset) || !isString(rung) || !isRecord(tokens)) return null
|
|
105
105
|
if (!Array.isArray(hidden) || !hidden.every(isString)) return null
|
|
@@ -121,6 +121,10 @@ export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
|
|
|
121
121
|
rung,
|
|
122
122
|
tokens: tokenOut,
|
|
123
123
|
...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
|
|
124
|
+
// 1.2.0: the reader face's two keys. 1.1.x dropped both; they are kept only when well-formed, so a def that
|
|
125
|
+
// carries neither parses to exactly the object 1.1.1 returned for it.
|
|
126
|
+
...(accentFg === '#141414' || accentFg === '#ffffff' ? { accentFg } : {}),
|
|
127
|
+
...(isString(faceName) ? { face: faceName } : {}),
|
|
124
128
|
hidden: [...hidden],
|
|
125
129
|
...(layout !== undefined ? { layout } : {}),
|
|
126
130
|
...(face ? { customFace: face } : {}),
|