@certrev/cert-block 0.3.0 → 0.5.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/README.md +48 -1
- package/dist/builder/cert-chrome-data.d.ts +129 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -0
- package/dist/builder/cert-chrome-data.js +59 -0
- package/dist/builder/cert-chrome-data.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +68 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -0
- package/dist/builder/cert-review-card.js +129 -0
- package/dist/builder/cert-review-card.js.map +1 -0
- package/dist/builder/index.d.ts +2 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +3 -0
- package/dist/builder/index.js.map +1 -1
- package/dist/components/preview-block.d.ts +43 -0
- package/dist/components/preview-block.d.ts.map +1 -0
- package/dist/components/preview-block.js +53 -0
- package/dist/components/preview-block.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -7
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +304 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +27 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +38 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +60 -0
- package/dist/modal/avatar-resize.d.ts.map +1 -0
- package/dist/modal/avatar-resize.js +91 -0
- package/dist/modal/avatar-resize.js.map +1 -0
- package/dist/modal/cert-modal-view.d.ts +74 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -0
- package/dist/modal/cert-modal-view.js +160 -0
- package/dist/modal/cert-modal-view.js.map +1 -0
- package/dist/modal/certrev-cert-modal.d.ts +72 -0
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.js +270 -0
- package/dist/modal/certrev-cert-modal.js.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.js +488 -0
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
- package/dist/modal/certrev-cert.entry.d.ts +19 -0
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
- package/dist/modal/certrev-cert.entry.js +39 -0
- package/dist/modal/certrev-cert.entry.js.map +1 -0
- package/dist/modal/certrev-cert.js +557 -0
- package/dist/modal/display-strings.d.ts +22 -0
- package/dist/modal/display-strings.d.ts.map +1 -0
- package/dist/modal/display-strings.js +35 -0
- package/dist/modal/display-strings.js.map +1 -0
- package/dist/modal/escape.d.ts +13 -0
- package/dist/modal/escape.d.ts.map +1 -0
- package/dist/modal/escape.js +20 -0
- package/dist/modal/escape.js.map +1 -0
- package/dist/modal/font-face.d.ts +4 -0
- package/dist/modal/font-face.d.ts.map +1 -0
- package/dist/modal/font-face.js +137 -0
- package/dist/modal/font-face.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +12 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -0
- package/dist/modal/ftc-disclosure.js +12 -0
- package/dist/modal/ftc-disclosure.js.map +1 -0
- package/dist/modal/ftc-guard.d.ts +50 -0
- package/dist/modal/ftc-guard.d.ts.map +1 -0
- package/dist/modal/ftc-guard.js +117 -0
- package/dist/modal/ftc-guard.js.map +1 -0
- package/dist/modal/index.d.ts +20 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +25 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/interactions.d.ts +53 -0
- package/dist/modal/interactions.d.ts.map +1 -0
- package/dist/modal/interactions.js +147 -0
- package/dist/modal/interactions.js.map +1 -0
- package/dist/modal/logo-paths.d.ts +15 -0
- package/dist/modal/logo-paths.d.ts.map +1 -0
- package/dist/modal/logo-paths.js +24 -0
- package/dist/modal/logo-paths.js.map +1 -0
- package/dist/modal/placement.d.ts +95 -0
- package/dist/modal/placement.d.ts.map +1 -0
- package/dist/modal/placement.js +302 -0
- package/dist/modal/placement.js.map +1 -0
- package/dist/modal/revalidate.d.ts +48 -0
- package/dist/modal/revalidate.d.ts.map +1 -0
- package/dist/modal/revalidate.js +65 -0
- package/dist/modal/revalidate.js.map +1 -0
- package/dist/modal/verify-client.d.ts +24 -0
- package/dist/modal/verify-client.d.ts.map +1 -0
- package/dist/modal/verify-client.js +38 -0
- package/dist/modal/verify-client.js.map +1 -0
- package/package.json +14 -4
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
- package/src/__tests__/preview-block.test.ts +84 -0
- package/src/__tests__/render-cert-block.test.ts +380 -5
- package/src/builder/__tests__/builder.test.tsx +71 -0
- package/src/builder/cert-chrome-data.ts +159 -0
- package/src/builder/cert-review-card.tsx +177 -0
- package/src/builder/index.tsx +18 -0
- package/src/components/preview-block.ts +74 -0
- package/src/components/render-cert-block.ts +387 -102
- package/src/components/render-def.ts +55 -0
- package/src/index.ts +9 -0
- package/src/modal/avatar-resize.ts +99 -0
- package/src/modal/cert-modal-view.test.ts +175 -0
- package/src/modal/cert-modal-view.ts +210 -0
- package/src/modal/certrev-cert-modal.styles.ts +494 -0
- package/src/modal/certrev-cert-modal.test.ts +125 -0
- package/src/modal/certrev-cert-modal.ts +273 -0
- package/src/modal/certrev-cert.entry.ts +44 -0
- package/src/modal/display-strings.ts +38 -0
- package/src/modal/escape.ts +20 -0
- package/src/modal/font-face.ts +138 -0
- package/src/modal/ftc-disclosure.ts +13 -0
- package/src/modal/ftc-guard.test.ts +153 -0
- package/src/modal/ftc-guard.ts +119 -0
- package/src/modal/index.ts +34 -0
- package/src/modal/interactions.test.ts +250 -0
- package/src/modal/interactions.ts +166 -0
- package/src/modal/logo-paths.ts +23 -0
- package/src/modal/placement.test.ts +398 -0
- package/src/modal/placement.ts +331 -0
- package/src/modal/revalidate.test.ts +111 -0
- package/src/modal/revalidate.ts +81 -0
- package/src/modal/verify-client.test.ts +164 -0
- package/src/modal/verify-client.ts +42 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cert-card PLACEMENT for the single `target: body` CertREV app embed.
|
|
3
|
+
*
|
|
4
|
+
* The embed renders globally just before `</body>` — the only theme-app-extension primitive that
|
|
5
|
+
* reliably renders on modern themes (Horizon and others forbid app blocks inside the blog-post
|
|
6
|
+
* content section, so an in-content app block isn't available). On DOM-ready this module, ONCE:
|
|
7
|
+
*
|
|
8
|
+
* (a) Relocates the @graph `<script data-certrev-graph>` into `<head>`. JSON-LD is inert, so the
|
|
9
|
+
* move is invisible + reliable: JS-executing crawlers (Google + most AI fetchers) read it
|
|
10
|
+
* head-side, while non-JS fetchers still read the server-rendered copy that shipped in
|
|
11
|
+
* `<body>`. The AEO payload is never gambled on body-only.
|
|
12
|
+
* (b) Repositions the TWO cert wrappers to mirror the legacy iframe layout (iframe→native parity),
|
|
13
|
+
* BOTH anchored off the html-generator's `#references` list so they land in the SAME article:
|
|
14
|
+
* • `.certrev-card-wrap` (`data-certrev-anchor="top"`) → the TOP of the article content
|
|
15
|
+
* that OWNS `#references` (the contributors banner sat at the top of the legacy `body_html`).
|
|
16
|
+
* • `.certrev-memo-wrap` (`data-certrev-anchor="bottom"`) → the BOTTOM, just before the
|
|
17
|
+
* references list if present (where the legacy inline memo sat), else after the article.
|
|
18
|
+
* When no references exist, each falls back to a chrome-aware semantic-landmark / theme-class
|
|
19
|
+
* scan, with a STYLED end-of-article fallback when nothing matches.
|
|
20
|
+
* (c) Records the matched tier on each wrap as `data-certrev-placement` — the observability the
|
|
21
|
+
* pre-launch theme-matrix sweep + the POR-9578 liveness check read, so a silent misplacement
|
|
22
|
+
* becomes an inspectable signal rather than a surprise on a real brand's store.
|
|
23
|
+
*
|
|
24
|
+
* It runs BEFORE the badge re-verify pass and never moves a wrap twice, so the `<certrev-badge>`
|
|
25
|
+
* element is never detached/re-attached mid-verify (no duplicate Delivery-API calls).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Anchor tiers for the BOTTOM (memo) wrap, best→worst — landmarks first (stable across themes),
|
|
29
|
+
* theme classes last, above the styled body-bottom fallback. The memo is appended AFTER the matched
|
|
30
|
+
* anchor (or just before the references list). */
|
|
31
|
+
export const PLACEMENT_ANCHORS: ReadonlyArray<{ readonly selector: string; readonly tier: string }> = [
|
|
32
|
+
{ selector: 'main article', tier: 'article' },
|
|
33
|
+
{ selector: 'article', tier: 'article' },
|
|
34
|
+
{ selector: 'main', tier: 'main' },
|
|
35
|
+
{ selector: '.article-template__content', tier: 'theme-class' },
|
|
36
|
+
{ selector: '[id^="ArticleContent"]', tier: 'theme-class' },
|
|
37
|
+
{ selector: '.rte', tier: 'theme-class' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Anchor tiers for the TOP (contributors card) wrap. Body-CONTENT containers come FIRST so the
|
|
41
|
+
* `afterbegin` insert lands above the article text but below the title/hero — matching the legacy
|
|
42
|
+
* contributors banner's first-in-`body_html` position. The post-level landmarks (`article`/`main`)
|
|
43
|
+
* are the fallback (an `afterbegin` there may land above an in-element title; acceptable + flagged
|
|
44
|
+
* for per-theme tuning, and observable via the recorded tier). */
|
|
45
|
+
export const TOP_ANCHORS: ReadonlyArray<{ readonly selector: string; readonly tier: string }> = [
|
|
46
|
+
{ selector: '.article-template__content', tier: 'theme-class' },
|
|
47
|
+
{ selector: '[id^="ArticleContent"]', tier: 'theme-class' },
|
|
48
|
+
{ selector: '.rte', tier: 'theme-class' },
|
|
49
|
+
{ selector: 'main article', tier: 'article' },
|
|
50
|
+
{ selector: 'article', tier: 'article' },
|
|
51
|
+
{ selector: 'main', tier: 'main' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** The union of TOP_ANCHORS selectors — the shapes that count as an "article content container". The
|
|
55
|
+
* card resolves its anchor by walking UP from `#references` (`closest`) to the nearest of these, so
|
|
56
|
+
* it lands in the exact article body that owns the references — never a same-classed element that
|
|
57
|
+
* merely precedes the article in document order (see placeWrap + SITE_CHROME_SELECTOR). */
|
|
58
|
+
export const TOP_CONTENT_SELECTOR = TOP_ANCHORS.map((a) => a.selector).join(', ')
|
|
59
|
+
|
|
60
|
+
/** Site-chrome containers a cert wrap must NEVER anchor into. The anchor selectors (`.rte` / `article`
|
|
61
|
+
* / `main`) are NOT article-exclusive on real merchant themes: a VISIBLE announcement bar, header,
|
|
62
|
+
* footer, nav, or cart drawer often carries its own `.rte` that precedes the article in document
|
|
63
|
+
* order — IntelliPure's announcement bar is literally `<div class="announcement__content … rte">`.
|
|
64
|
+
* `isRenderable` can't reject those (they're visible), so `querySelector('.rte')` returned the
|
|
65
|
+
* announcement bar and the contributors card was injected there instead of the article (POR-9809 —
|
|
66
|
+
* "contributors not showing"). Placement scopes anchors out of chrome STRUCTURALLY via this set. */
|
|
67
|
+
export const SITE_CHROME_SELECTOR =
|
|
68
|
+
'header, [role="banner"], [class*="announcement"], footer, [role="contentinfo"], nav, [role="navigation"], cart-drawer, .cart-drawer, .drawer, .mini-cart, .modal, [role="dialog"]'
|
|
69
|
+
|
|
70
|
+
/** Achieved-tier attribute the JS stamps on each wrap (CSS body-fallback styling + observability). */
|
|
71
|
+
export const PLACEMENT_ATTR = 'data-certrev-placement'
|
|
72
|
+
/** Desired-position marker the Liquid sets on each wrap: `top` (card) | `bottom` (memo). */
|
|
73
|
+
export const ANCHOR_TARGET_ATTR = 'data-certrev-anchor'
|
|
74
|
+
export const GRAPH_SELECTOR = 'script[type="application/ld+json"][data-certrev-graph]'
|
|
75
|
+
export const CARD_WRAP_SELECTOR = '.certrev-card-wrap'
|
|
76
|
+
export const MEMO_WRAP_SELECTOR = '.certrev-memo-wrap'
|
|
77
|
+
/** The floating-pill wrap (layout 'floating'). CSS owns its fixed placement; the JS only stamps it. */
|
|
78
|
+
export const PILL_WRAP_SELECTOR = '.certrev-pill-wrap'
|
|
79
|
+
/** Min free right gutter (px) beside the article column for the fixed rail. 320 rail + margins. */
|
|
80
|
+
export const RAIL_MIN_GUTTER = 384
|
|
81
|
+
/** Gap (px) between the article column's right edge and the fixed rail. */
|
|
82
|
+
export const RAIL_GAP = 24
|
|
83
|
+
/** The article's references list (emitted by html-generator into the clean body_html). The memo
|
|
84
|
+
* renders just before it, matching the legacy inline-memo position (memo → references). */
|
|
85
|
+
export const REFERENCES_SELECTOR = '#references'
|
|
86
|
+
|
|
87
|
+
export interface PlaceResult {
|
|
88
|
+
/** Whether the wrapper was moved to an article anchor (false on the body-bottom fallback). */
|
|
89
|
+
readonly anchored: boolean
|
|
90
|
+
/** The matched anchor tier (`references` | `article` | `main` | `theme-class` | `body-fallback`). */
|
|
91
|
+
readonly tier: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PlaceCertResult {
|
|
95
|
+
/** Whether the @graph script was relocated into <head> this pass. */
|
|
96
|
+
readonly graph: boolean
|
|
97
|
+
/** Placement of the contributors card wrap (null when absent). */
|
|
98
|
+
readonly card: PlaceResult | null
|
|
99
|
+
/** Placement of the memo wrap (null when absent). */
|
|
100
|
+
readonly memo: PlaceResult | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether `el` is RENDERABLE — not inside a `display:none` / `visibility:hidden` / `[hidden]` /
|
|
105
|
+
* `aria-hidden` subtree. The guard exists because anchor selectors like `.rte` / `main` / `article`
|
|
106
|
+
* are NOT article-exclusive on real merchant themes: a CLOSED cart-drawer / nav / search popup often
|
|
107
|
+
* carries its own `.rte` (e.g. an empty-cart message) that sits BEFORE the article in document order,
|
|
108
|
+
* so `querySelector('.rte')` returns the DRAWER's copy and the cert card gets relocated into a hidden
|
|
109
|
+
* panel — invisible to the reader (observed live on IntelliPure's theme: the card landed in
|
|
110
|
+
* `<cart-drawer hidden>`). Skipping hidden matches makes placement fall through to the visible article
|
|
111
|
+
* content for BOTH the top card and the bottom memo.
|
|
112
|
+
*
|
|
113
|
+
* Walks ancestors via `getComputedStyle` + the hidden/aria-hidden attributes (NOT `offsetParent`,
|
|
114
|
+
* which jsdom always reports null since it does no layout — that would false-reject everything in the
|
|
115
|
+
* unit suite). Normal elements compute `display:block` / `visibility:visible` and pass.
|
|
116
|
+
*/
|
|
117
|
+
function isRenderable(el: Element): boolean {
|
|
118
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
119
|
+
if (node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return false
|
|
120
|
+
const style = getComputedStyle(node as HTMLElement)
|
|
121
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false
|
|
122
|
+
}
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `el` sits inside SITE CHROME (announcement bar / header / footer / nav / cart drawer /
|
|
128
|
+
* modal). Complements `isRenderable`: chrome anchors are usually VISIBLE, so the visibility guard
|
|
129
|
+
* can't reject them — but a cert wrap must never anchor there. This is the guard that keeps the
|
|
130
|
+
* contributors card out of IntelliPure's `.rte`-classed announcement bar (POR-9809).
|
|
131
|
+
*/
|
|
132
|
+
function isInSiteChrome(el: Element): boolean {
|
|
133
|
+
return el.closest(SITE_CHROME_SELECTOR) !== null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Observability tier for an already-resolved container: the first anchor selector it matches
|
|
137
|
+
* (`theme-class` | `article` | `main`), defaulting to `article`. Used to record the tier of the
|
|
138
|
+
* `#references`-resolved card container so the placement attribute stays in the known vocabulary. */
|
|
139
|
+
function anchorTier(el: Element, anchors: ReadonlyArray<{ readonly selector: string; readonly tier: string }>): string {
|
|
140
|
+
for (const { selector, tier } of anchors) if (el.matches(selector)) return tier
|
|
141
|
+
return 'article'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* First anchor that exists, is RENDERABLE (not in a hidden drawer/popup), is NOT site chrome (a
|
|
146
|
+
* visible announcement bar / header / nav — POR-9809), and is safely positionable relative to
|
|
147
|
+
* `wrap`. Iterates ALL matches per selector (not just the first in document order) so an unusable
|
|
148
|
+
* early match (a closed drawer's `.rte`, or the announcement bar's) is skipped for the article copy.
|
|
149
|
+
*/
|
|
150
|
+
function firstUsableAnchor(
|
|
151
|
+
doc: Document,
|
|
152
|
+
wrap: HTMLElement,
|
|
153
|
+
anchors: ReadonlyArray<{ readonly selector: string; readonly tier: string }>,
|
|
154
|
+
): { readonly el: Element; readonly tier: string } | null {
|
|
155
|
+
for (const { selector, tier } of anchors) {
|
|
156
|
+
for (const el of Array.from(doc.querySelectorAll(selector))) {
|
|
157
|
+
// Never anchor to the wrap itself or to an element that contains / is contained by it (that
|
|
158
|
+
// would detach the wrap into its own subtree or no-op the move), never to a HIDDEN match, and
|
|
159
|
+
// never into site chrome (a visible announcement bar / header / nav carrying its own `.rte`).
|
|
160
|
+
if (el !== wrap && !el.contains(wrap) && !wrap.contains(el) && isRenderable(el) && !isInSiteChrome(el))
|
|
161
|
+
return { el, tier }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reposition ONE wrap to its `data-certrev-anchor` target. Idempotent: a wrap already carrying
|
|
169
|
+
* `data-certrev-placement` is left untouched (records the achieved tier on first pass).
|
|
170
|
+
*/
|
|
171
|
+
export function placeWrap(doc: Document, wrap: HTMLElement): PlaceResult {
|
|
172
|
+
const existing = wrap.getAttribute(PLACEMENT_ATTR)
|
|
173
|
+
if (existing) return { anchored: existing !== 'body-fallback', tier: existing }
|
|
174
|
+
|
|
175
|
+
const anchorAttr = wrap.getAttribute(ANCHOR_TARGET_ATTR)
|
|
176
|
+
|
|
177
|
+
// RAIL (layout 'sidebar'): pin the whole rail beside the article column when there's room, else
|
|
178
|
+
// gracefully fall back to the banner-style in-flow TOP placement (a full-width card). The owner is
|
|
179
|
+
// resolved exactly like the top card (`#references` → closest content container, chrome-aware).
|
|
180
|
+
if (anchorAttr === 'rail') {
|
|
181
|
+
const refs = doc.querySelector(REFERENCES_SELECTOR)
|
|
182
|
+
const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? firstUsableAnchor(doc, wrap, TOP_ANCHORS)?.el ?? null
|
|
183
|
+
const win = doc.defaultView
|
|
184
|
+
if (owner && win && isRenderable(owner) && !isInSiteChrome(owner)) {
|
|
185
|
+
const rect = owner.getBoundingClientRect()
|
|
186
|
+
const gutter = win.innerWidth - rect.right
|
|
187
|
+
if (gutter >= RAIL_MIN_GUTTER) {
|
|
188
|
+
// Fixed gutter rail — the working "sticky" for an injected widget (a CSS-sticky float has no
|
|
189
|
+
// stick room without theme layout control). The wrap stays at the embed point.
|
|
190
|
+
wrap.style.setProperty('--certrev-rail-left', `${Math.round(rect.right + RAIL_GAP)}px`)
|
|
191
|
+
wrap.setAttribute(PLACEMENT_ATTR, 'rail')
|
|
192
|
+
return { anchored: true, tier: 'rail' }
|
|
193
|
+
}
|
|
194
|
+
// GRACEFUL FALLBACK: no gutter → banner-style in-flow top placement (full-width card).
|
|
195
|
+
owner.insertAdjacentElement('afterbegin', wrap)
|
|
196
|
+
const fallbackTier = anchorTier(owner, TOP_ANCHORS)
|
|
197
|
+
wrap.setAttribute(PLACEMENT_ATTR, fallbackTier)
|
|
198
|
+
return { anchored: true, tier: fallbackTier }
|
|
199
|
+
}
|
|
200
|
+
wrap.setAttribute(PLACEMENT_ATTR, 'body-fallback')
|
|
201
|
+
return { anchored: false, tier: 'body-fallback' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const target = anchorAttr === 'bottom' ? 'bottom' : 'top'
|
|
205
|
+
let tier = 'body-fallback'
|
|
206
|
+
|
|
207
|
+
if (target === 'top') {
|
|
208
|
+
// Anchor the card to the TOP of the article CONTENT that OWNS `#references` — the same reliable
|
|
209
|
+
// marker the memo uses at the bottom. `#references` (emitted by the html-generator, always present
|
|
210
|
+
// on a certified article) lives INSIDE the body-content container, so `closest(TOP_CONTENT_SELECTOR)`
|
|
211
|
+
// resolves the exact article `.rte` / `article` / `main` — never a same-classed site-chrome element
|
|
212
|
+
// that merely precedes the article in document order (IntelliPure's `.rte` announcement bar, which
|
|
213
|
+
// is visible so isRenderable can't reject it — POR-9809 "contributors not showing"). This also ties
|
|
214
|
+
// the card + memo to the SAME article region by construction.
|
|
215
|
+
const refs = doc.querySelector(REFERENCES_SELECTOR)
|
|
216
|
+
const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? null
|
|
217
|
+
if (
|
|
218
|
+
owner &&
|
|
219
|
+
owner !== wrap &&
|
|
220
|
+
!owner.contains(wrap) &&
|
|
221
|
+
!wrap.contains(owner) &&
|
|
222
|
+
isRenderable(owner) &&
|
|
223
|
+
!isInSiteChrome(owner)
|
|
224
|
+
) {
|
|
225
|
+
// First child of the content container → above the article text, below the title/hero.
|
|
226
|
+
owner.insertAdjacentElement('afterbegin', wrap)
|
|
227
|
+
tier = anchorTier(owner, TOP_ANCHORS)
|
|
228
|
+
} else {
|
|
229
|
+
// No references (rare) → fall back to the chrome-aware anchor scan.
|
|
230
|
+
const found = firstUsableAnchor(doc, wrap, TOP_ANCHORS)
|
|
231
|
+
if (found) {
|
|
232
|
+
found.el.insertAdjacentElement('afterbegin', wrap)
|
|
233
|
+
tier = found.tier
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
// Memo → just before the references list when one exists. The references are the reliable
|
|
238
|
+
// bottom-of-content marker INDEPENDENT of which landmark matched: a theme can have a small hero
|
|
239
|
+
// <article> BEFORE the content <article> (IntelliPure: `article banner` then `article
|
|
240
|
+
// page-width`), and `firstUsableAnchor` matches the hero first — so `afterend(hero)` would
|
|
241
|
+
// strand the memo at the TOP, above the card. Anchoring off `#references` (the html-generator's
|
|
242
|
+
// references list, always present on a certified article) avoids that. The old code required the
|
|
243
|
+
// refs to live INSIDE the matched landmark (`found.el.contains(refs)`), which failed exactly when
|
|
244
|
+
// the matched landmark was the hero and the refs were in the separate content article.
|
|
245
|
+
const refs = doc.querySelector(REFERENCES_SELECTOR)
|
|
246
|
+
if (refs && refs !== wrap && !wrap.contains(refs) && !refs.contains(wrap) && isRenderable(refs)) {
|
|
247
|
+
refs.insertAdjacentElement('beforebegin', wrap)
|
|
248
|
+
tier = 'references'
|
|
249
|
+
} else {
|
|
250
|
+
const found = firstUsableAnchor(doc, wrap, PLACEMENT_ANCHORS)
|
|
251
|
+
if (found) {
|
|
252
|
+
found.el.insertAdjacentElement('afterend', wrap)
|
|
253
|
+
tier = found.tier
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
wrap.setAttribute(PLACEMENT_ATTR, tier)
|
|
259
|
+
return { anchored: tier !== 'body-fallback', tier }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Relocate the @graph into `<head>`, then reposition the card (top) + memo (bottom) wraps — ONCE. */
|
|
263
|
+
export function placeCert(doc: Document): PlaceCertResult {
|
|
264
|
+
// (a) @graph → <head> (idempotent: skip if already there or absent).
|
|
265
|
+
const graph = doc.querySelector(GRAPH_SELECTOR)
|
|
266
|
+
let graphMoved = false
|
|
267
|
+
if (graph && graph.parentNode !== doc.head) {
|
|
268
|
+
doc.head.appendChild(graph)
|
|
269
|
+
graphMoved = true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// (b)+(c) reposition each wrap to its target, once.
|
|
273
|
+
const cardEl = doc.querySelector<HTMLElement>(CARD_WRAP_SELECTOR)
|
|
274
|
+
const memoEl = doc.querySelector<HTMLElement>(MEMO_WRAP_SELECTOR)
|
|
275
|
+
// The floating pill (layout 'floating') is CSS-fixed — no repositioning; stamp it for observability
|
|
276
|
+
// only, so a live-liveness check still sees a placement attribute on the pill wrap.
|
|
277
|
+
doc.querySelector<HTMLElement>(PILL_WRAP_SELECTOR)?.setAttribute(PLACEMENT_ATTR, 'pill-fixed')
|
|
278
|
+
return {
|
|
279
|
+
graph: graphMoved,
|
|
280
|
+
card: cardEl ? placeWrap(doc, cardEl) : null,
|
|
281
|
+
memo: memoEl ? placeWrap(doc, memoEl) : null,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A fixed sidebar rail only fits while the article column leaves ≥`RAIL_MIN_GUTTER` of right gutter;
|
|
287
|
+
* a viewport shrink (resize / rotate) can erase it. Bind a debounced resize listener that re-measures
|
|
288
|
+
* and, on gutter loss, DEMOTES the rail to the in-flow banner-style top placement — ONE-WAY (never
|
|
289
|
+
* bounces back on scrollbar jitter): once demoted the listener detaches. No-op unless a fixed rail
|
|
290
|
+
* exists. Idempotent per pass (a rail already demoted carries no `rail` placement so it's skipped).
|
|
291
|
+
*/
|
|
292
|
+
function bindRailResize(doc: Document): void {
|
|
293
|
+
const win = doc.defaultView
|
|
294
|
+
if (!win) return
|
|
295
|
+
const wrap = doc.querySelector<HTMLElement>(`${MEMO_WRAP_SELECTOR}[${ANCHOR_TARGET_ATTR}="rail"]`)
|
|
296
|
+
if (!wrap || wrap.getAttribute(PLACEMENT_ATTR) !== 'rail') return
|
|
297
|
+
|
|
298
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
299
|
+
const onResize = (): void => {
|
|
300
|
+
if (timer) clearTimeout(timer)
|
|
301
|
+
timer = setTimeout(() => {
|
|
302
|
+
// Only act while still a fixed rail; once demoted we're done (one-way — no bounce-back).
|
|
303
|
+
if (wrap.getAttribute(PLACEMENT_ATTR) !== 'rail') {
|
|
304
|
+
win.removeEventListener('resize', onResize)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const refs = doc.querySelector(REFERENCES_SELECTOR)
|
|
308
|
+
const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? firstUsableAnchor(doc, wrap, TOP_ANCHORS)?.el ?? null
|
|
309
|
+
const gutter = owner ? win.innerWidth - owner.getBoundingClientRect().right : 0
|
|
310
|
+
if (gutter < RAIL_MIN_GUTTER) {
|
|
311
|
+
// Demote to in-flow — clear the fixed var + attr, then re-run placement once (the rail
|
|
312
|
+
// branch now measures a lost gutter → the banner-style top card fallback).
|
|
313
|
+
wrap.style.removeProperty('--certrev-rail-left')
|
|
314
|
+
wrap.removeAttribute(PLACEMENT_ATTR)
|
|
315
|
+
placeWrap(doc, wrap)
|
|
316
|
+
win.removeEventListener('resize', onResize)
|
|
317
|
+
}
|
|
318
|
+
}, 150)
|
|
319
|
+
}
|
|
320
|
+
win.addEventListener('resize', onResize)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Run placement once the DOM is parsed (mirrors `initBadgeRevalidation`'s readiness handling). */
|
|
324
|
+
export function initCertPlacement(doc: Document): void {
|
|
325
|
+
const run = (): void => {
|
|
326
|
+
placeCert(doc)
|
|
327
|
+
bindRailResize(doc)
|
|
328
|
+
}
|
|
329
|
+
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true })
|
|
330
|
+
else run()
|
|
331
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the Shopify cert-badge Web-Component revalidation DOM layer — the fail-closed
|
|
4
|
+
* (suppress → hide) / fail-open (404|offline → keep SSR) behavior, driven with a mocked verdict
|
|
5
|
+
* (no network), plus the key-sync cross-check guarding the browser-bundled public key against
|
|
6
|
+
* signer.ts drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CertVerdict } from "@certrev/cert-contract";
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { type RevalidateDeps, revalidateBadge } from "./revalidate.js";
|
|
12
|
+
|
|
13
|
+
// NOTE (POR-10721 W2): the "published key sync" cross-check that lived here — asserting the
|
|
14
|
+
// browser-bundled `CERT_ISSUER_*` (certrev-cert.entry.ts) equals portal's signer
|
|
15
|
+
// `CERT_ENVELOPE_*` — is a CROSS-REPO guard and cannot run in this package (there is no portal
|
|
16
|
+
// signer to compare against). It stays a PORTAL-side test: once portal re-points at the published
|
|
17
|
+
// package, portal asserts `@certrev/cert-block/modal`'s exported CERT_ISSUER_* == its own
|
|
18
|
+
// signer's CERT_ENVELOPE_*. The package exports CERT_ISSUER_* from the modal barrel for exactly
|
|
19
|
+
// that consumer-side assertion.
|
|
20
|
+
|
|
21
|
+
/** Build a `<certrev-badge revalidate>` host wrapping an SSR `.certrev-badge`. */
|
|
22
|
+
function mountHost(attrs: Record<string, string> = {}): Element {
|
|
23
|
+
const host = document.createElement("certrev-badge");
|
|
24
|
+
host.setAttribute("revalidate", "");
|
|
25
|
+
host.setAttribute("data-delivery-api", "https://portal.certrev.com");
|
|
26
|
+
host.setAttribute("data-external-id", "gid://shopify/Article/12345");
|
|
27
|
+
host.setAttribute("data-platform", "shopify");
|
|
28
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
29
|
+
if (v === "") host.removeAttribute(k);
|
|
30
|
+
else host.setAttribute(k, v);
|
|
31
|
+
}
|
|
32
|
+
host.innerHTML =
|
|
33
|
+
'<section class="certrev-badge">Reviewed by Dr. Jane Doe</section>';
|
|
34
|
+
document.body.appendChild(host);
|
|
35
|
+
return host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function depsReturning(verdict: CertVerdict): {
|
|
39
|
+
deps: RevalidateDeps;
|
|
40
|
+
spy: ReturnType<typeof vi.fn>;
|
|
41
|
+
} {
|
|
42
|
+
const spy = vi.fn(async () => verdict);
|
|
43
|
+
return { deps: { verify: spy as RevalidateDeps["verify"] }, spy };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
document.body.innerHTML = "";
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("revalidateBadge", () => {
|
|
51
|
+
it("leaves the SSR badge visible on a `render` verdict", async () => {
|
|
52
|
+
const host = mountHost();
|
|
53
|
+
const { deps } = depsReturning({
|
|
54
|
+
decision: "render",
|
|
55
|
+
payload: {},
|
|
56
|
+
} as unknown as CertVerdict);
|
|
57
|
+
await revalidateBadge(host, deps);
|
|
58
|
+
expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("HIDES the SSR badge on a `suppress` verdict (fail-closed)", async () => {
|
|
64
|
+
const host = mountHost();
|
|
65
|
+
const { deps } = depsReturning({ decision: "suppress", reason: "revoked" });
|
|
66
|
+
await revalidateBadge(host, deps);
|
|
67
|
+
const badge = host.querySelector(".certrev-badge")!;
|
|
68
|
+
expect(badge.hasAttribute("hidden")).toBe(true);
|
|
69
|
+
expect(badge.getAttribute("data-certrev-suppressed")).toBe("revoked");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("leaves the SSR badge when verify throws (404 / offline → never a new failure mode)", async () => {
|
|
73
|
+
const host = mountHost();
|
|
74
|
+
const deps: RevalidateDeps = {
|
|
75
|
+
verify: vi.fn(async () => {
|
|
76
|
+
throw new Error("cert delivery 404");
|
|
77
|
+
}) as RevalidateDeps["verify"],
|
|
78
|
+
};
|
|
79
|
+
await revalidateBadge(host, deps);
|
|
80
|
+
expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
|
|
81
|
+
false,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not call verify when routing attributes are missing", async () => {
|
|
86
|
+
const host = mountHost({ "data-external-id": "" });
|
|
87
|
+
const { deps, spy } = depsReturning({
|
|
88
|
+
decision: "suppress",
|
|
89
|
+
reason: "revoked",
|
|
90
|
+
});
|
|
91
|
+
await revalidateBadge(host, deps);
|
|
92
|
+
expect(spy).not.toHaveBeenCalled();
|
|
93
|
+
expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
|
|
94
|
+
false,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("passes the host routing attributes through to verify", async () => {
|
|
99
|
+
const host = mountHost();
|
|
100
|
+
const { deps, spy } = depsReturning({
|
|
101
|
+
decision: "suppress",
|
|
102
|
+
reason: "expired",
|
|
103
|
+
});
|
|
104
|
+
await revalidateBadge(host, deps);
|
|
105
|
+
expect(spy).toHaveBeenCalledWith({
|
|
106
|
+
baseUrl: "https://portal.certrev.com",
|
|
107
|
+
platform: "shopify",
|
|
108
|
+
externalId: "gid://shopify/Article/12345",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify cert-badge Web-Component revalidation — the `crypto_verify` progressive-enhancement
|
|
3
|
+
* layer over the SSR Liquid badge.
|
|
4
|
+
*
|
|
5
|
+
* The Liquid edge renders the badge server-side (crawlable) and gates lifecycle in Liquid, but
|
|
6
|
+
* it CANNOT run Ed25519 (`platform_ownership`). This module upgrades the HUMAN-visible badge to
|
|
7
|
+
* a real crypto_verify check: on page load it re-fetches the Delivery artifact from the public
|
|
8
|
+
* Delivery API and runs CertREV's CANONICAL verdict kernel (`verifyArtifact` from
|
|
9
|
+
* `@certrev/cert-contract` — NOT a re-implementation), then:
|
|
10
|
+
*
|
|
11
|
+
* • verdict `render` → leave the SSR badge (verified live);
|
|
12
|
+
* • verdict `suppress` (invalid-sig / subject-mismatch / revoked / expired) → HIDE it
|
|
13
|
+
* (fail-closed), stamping `data-certrev-suppressed` with the verdict's reason. A revoked
|
|
14
|
+
* placement now serves the SLIM `CertTombstone` (POR-10481), which `verifyArtifact`
|
|
15
|
+
* resolves to `suppress:'revoked'` — so the badge hides AND the suppressed-reason telemetry
|
|
16
|
+
* is right (`verifyEnvelope` used to mis-report it as `unsupported_contract_version`;
|
|
17
|
+
* POR-10684);
|
|
18
|
+
* • the Delivery API 404s (no servable artifact) or the network errors → LEAVE the SSR badge.
|
|
19
|
+
* The SSR copy already passed the Liquid lifecycle gate against the app-owned metafield
|
|
20
|
+
* (the authoritative revoke/expire surface — the binding retracts it on revoke), so the WC
|
|
21
|
+
* is enhancement, never a NEW failure mode or a false-hide on an untracked placement.
|
|
22
|
+
*
|
|
23
|
+
* Crawlers + AI extractors read the SSR copy regardless (this never blocks first paint). The
|
|
24
|
+
* verdict source is injected so the unit test drives it without a network; the production entry
|
|
25
|
+
* (`certrev-cert.entry.ts`) wires the real fetch-+-`verifyArtifact` verifier + published key.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { CertVerdict } from '@certrev/cert-contract'
|
|
29
|
+
|
|
30
|
+
/** Where + what to verify, derived from the `<certrev-badge>` host's routing attributes. */
|
|
31
|
+
export interface BadgeSource {
|
|
32
|
+
readonly baseUrl: string
|
|
33
|
+
readonly platform: string
|
|
34
|
+
readonly externalId: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fetch-the-Delivery-API-+-verify, injected so tests drive it without a network. THROWS on a
|
|
38
|
+
* 404 / network error (→ keep SSR); returns a verdict on a served envelope. */
|
|
39
|
+
export type VerifyBadgeFn = (source: BadgeSource) => Promise<CertVerdict>
|
|
40
|
+
|
|
41
|
+
export interface RevalidateDeps {
|
|
42
|
+
readonly verify: VerifyBadgeFn
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Re-verify one `<certrev-badge revalidate>` host and hide its inner `.certrev-badge` on a
|
|
47
|
+
* definitive `suppress` verdict. No-op (keep SSR) when the host lacks routing attributes or the
|
|
48
|
+
* verify call throws (404 / offline).
|
|
49
|
+
*/
|
|
50
|
+
export async function revalidateBadge(host: Element, deps: RevalidateDeps): Promise<void> {
|
|
51
|
+
const badge = host.querySelector('.certrev-badge')
|
|
52
|
+
if (!badge) return
|
|
53
|
+
|
|
54
|
+
const baseUrl = host.getAttribute('data-delivery-api')
|
|
55
|
+
const externalId = host.getAttribute('data-external-id')
|
|
56
|
+
const platform = host.getAttribute('data-platform') ?? 'shopify'
|
|
57
|
+
if (!baseUrl || !externalId) return // no source → keep the lifecycle-gated SSR copy
|
|
58
|
+
|
|
59
|
+
let verdict: CertVerdict
|
|
60
|
+
try {
|
|
61
|
+
verdict = await deps.verify({ baseUrl, platform, externalId })
|
|
62
|
+
} catch {
|
|
63
|
+
return // 404 / offline → keep SSR (never a new failure mode or a false-hide)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (verdict.decision !== 'render') {
|
|
67
|
+
badge.setAttribute('hidden', '')
|
|
68
|
+
badge.setAttribute('data-certrev-suppressed', verdict.reason)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Revalidate every `<certrev-badge revalidate>` once the DOM is ready. */
|
|
73
|
+
export function initBadgeRevalidation(doc: Document, deps: RevalidateDeps): void {
|
|
74
|
+
const run = (): void => {
|
|
75
|
+
for (const host of Array.from(doc.querySelectorAll('certrev-badge[revalidate]'))) {
|
|
76
|
+
void revalidateBadge(host, deps)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true })
|
|
80
|
+
else run()
|
|
81
|
+
}
|