@certrev/cert-block 0.5.5 → 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.
Files changed (190) hide show
  1. package/CHANGELOG.md +164 -26
  2. package/LICENSE +21 -0
  3. package/README.md +415 -54
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +115 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +5 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +147 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +12 -7
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/readme-version.test.ts +0 -51
  179. package/src/__tests__/render-cert-block.test.ts +0 -657
  180. package/src/__tests__/render-def.test.tsx +0 -295
  181. package/src/__tests__/verify.test.ts +0 -239
  182. package/src/__tests__/webcomponent.test.tsx +0 -116
  183. package/src/builder/__tests__/builder.test.tsx +0 -158
  184. package/src/modal/cert-modal-view.test.ts +0 -175
  185. package/src/modal/certrev-cert-modal.test.ts +0 -125
  186. package/src/modal/ftc-guard.test.ts +0 -153
  187. package/src/modal/interactions.test.ts +0 -250
  188. package/src/modal/placement.test.ts +0 -398
  189. package/src/modal/revalidate.test.ts +0 -111
  190. package/src/modal/verify-client.test.ts +0 -164
@@ -1,250 +0,0 @@
1
- // @vitest-environment jsdom
2
- /**
3
- * Unit tests for the cert-card interactions layer — accordion contributor bios (POR-9827 chevron
4
- * model) + the in-page certificate/profile modals. Pure DOM, delegated handlers.
5
- */
6
-
7
- import { beforeEach, describe, expect, it, vi } from "vitest";
8
- import {
9
- initCertInteractions,
10
- modalTargetKind,
11
- openCertModal,
12
- openModal,
13
- swapBrokenAvatar,
14
- sweepBrokenAvatars,
15
- toggleAccordion,
16
- } from "./interactions.js";
17
-
18
- beforeEach(() => {
19
- document.head.innerHTML = "";
20
- document.body.innerHTML = "";
21
- // Fresh document flag each test isn't possible (same global doc), so the install guard is
22
- // exercised explicitly in its own test; other tests call initCertInteractions once.
23
- });
24
-
25
- /** A contributors card with an accordion reviewer row (name/credentials split + hidden acc-bio)
26
- * and the footer "Compensated expert" modal trigger — the POR-9827 chevron-model markup. */
27
- function mountCard(): {
28
- card: HTMLElement;
29
- row: HTMLElement;
30
- bio: HTMLElement;
31
- } {
32
- const card = document.createElement("section");
33
- card.className = "certrev-badge certrev-card";
34
- card.innerHTML = `
35
- <div class="card-body">
36
- <div class="contributors">
37
- <div class="contributor clickable" data-certrev-acc role="button" tabindex="0" aria-expanded="false">
38
- <div class="person-row"><div class="info">
39
- <div class="name">Jane Doe <svg class="name-caret"></svg></div>
40
- <div class="credentials">RDN, PhD</div>
41
- <div class="label expert">Reviewed by</div>
42
- <div class="acc-bio">Twenty years in clinical nutrition.</div>
43
- </div></div>
44
- </div>
45
- </div>
46
- <div class="card-footer"><span class="compensated-cue">Compensated <a class="cert-link" href="https://certrev.com/experts/jane" data-certrev-modal-open="expert">expert</a></span></div>
47
- </div>`;
48
- document.body.appendChild(card);
49
- return {
50
- card,
51
- row: card.querySelector("[data-certrev-acc]")!,
52
- bio: card.querySelector(".acc-bio")!,
53
- };
54
- }
55
-
56
- type StubModal = HTMLElement & { open: ReturnType<typeof vi.fn> };
57
-
58
- /** A stand-in `<certrev-cert-modal>` with a controllable `open()` — the interactions layer only
59
- * routes to the element's `.open(kind)`; the element itself owns rendering (tested separately). */
60
- function mountModalElement(returns = true): StubModal {
61
- const el = document.createElement("certrev-cert-modal") as StubModal;
62
- el.open = vi.fn(() => returns);
63
- document.body.appendChild(el);
64
- return el;
65
- }
66
-
67
- describe("toggleAccordion (pure)", () => {
68
- it("toggles the row open and mirrors the state on aria-expanded", () => {
69
- const { row } = mountCard();
70
- toggleAccordion(row);
71
- expect(row.classList.contains("open")).toBe(true);
72
- expect(row.getAttribute("aria-expanded")).toBe("true");
73
- toggleAccordion(row);
74
- expect(row.classList.contains("open")).toBe(false);
75
- expect(row.getAttribute("aria-expanded")).toBe("false");
76
- });
77
- });
78
-
79
- describe("openCertModal", () => {
80
- it('routes to the <certrev-cert-modal> element open("cert")', () => {
81
- const el = mountModalElement(true);
82
- expect(openCertModal(document)).toBe(true);
83
- expect(el.open).toHaveBeenCalledWith("cert");
84
- });
85
-
86
- it("returns false (caller falls back to the link) when no modal element exists", () => {
87
- expect(openCertModal(document)).toBe(false);
88
- });
89
-
90
- it("returns whatever the element.open returns — false lets the link href fall back", () => {
91
- mountModalElement(false);
92
- expect(openCertModal(document)).toBe(false);
93
- });
94
- });
95
-
96
- describe("modal routing (POR-10102 — unified element)", () => {
97
- it('modalTargetKind routes "expert" to the reviewer and everything else to the certificate', () => {
98
- expect(modalTargetKind("expert")).toBe("expert");
99
- expect(modalTargetKind("cert")).toBe("cert");
100
- expect(modalTargetKind("")).toBe("cert");
101
- expect(modalTargetKind(null)).toBe("cert");
102
- });
103
-
104
- it("openModal opens the reviewer dialog via the element (and returns false when it is absent)", () => {
105
- expect(openModal(document, "expert")).toBe(false);
106
- const el = mountModalElement(true);
107
- expect(openModal(document, "expert")).toBe(true);
108
- expect(el.open).toHaveBeenCalledWith("expert");
109
- });
110
-
111
- it('the "Compensated expert" cue opens the reviewer modal and prevents navigation', () => {
112
- const { card, row } = mountCard();
113
- const el = mountModalElement(true);
114
- initCertInteractions(document);
115
- const link = card.querySelector(
116
- '[data-certrev-modal-open="expert"]',
117
- ) as HTMLElement;
118
- const evt = new MouseEvent("click", { bubbles: true, cancelable: true });
119
- link.dispatchEvent(evt);
120
- expect(el.open).toHaveBeenCalledWith("expert");
121
- expect(evt.defaultPrevented).toBe(true);
122
- // The modal open is NOT an accordion toggle.
123
- expect(row.classList.contains("open")).toBe(false);
124
- });
125
- });
126
-
127
- describe("broken avatar → initials fallback", () => {
128
- it("swapBrokenAvatar replaces a broken img with the matching initials span", () => {
129
- const img = document.createElement("img");
130
- img.className = "avatar";
131
- img.setAttribute("data-certrev-initials", "JS");
132
- img.src = "https://broken.example/x.jfif";
133
- document.body.appendChild(img);
134
- swapBrokenAvatar(img);
135
- const span = document.body.querySelector("span.avatar.avatar--initials");
136
- expect(span).not.toBeNull();
137
- expect(span!.textContent).toBe("JS");
138
- expect(span!.getAttribute("aria-hidden")).toBe("true");
139
- expect(document.body.querySelector("img")).toBeNull();
140
- });
141
-
142
- it("uses the memo base class when that is what the img carries", () => {
143
- const memoImg = document.createElement("img");
144
- memoImg.className = "certrev-memo__avatar";
145
- memoImg.setAttribute("data-certrev-initials", "CY");
146
- document.body.appendChild(memoImg);
147
- swapBrokenAvatar(memoImg);
148
- expect(
149
- document.body.querySelector(
150
- "span.certrev-memo__avatar.certrev-memo__avatar--initials",
151
- )?.textContent,
152
- ).toBe("CY");
153
- });
154
-
155
- it("does nothing for an img without the initials attribute", () => {
156
- const img = document.createElement("img");
157
- img.className = "avatar";
158
- document.body.appendChild(img);
159
- swapBrokenAvatar(img);
160
- expect(document.body.querySelector("img")).not.toBeNull();
161
- });
162
-
163
- it("sweepBrokenAvatars swaps an avatar that already failed before init (no error event re-fires)", () => {
164
- const img = document.createElement("img");
165
- img.className = "avatar";
166
- img.setAttribute("data-certrev-initials", "ZZ");
167
- // Simulate an image that 404'd during parse: complete=true, naturalWidth=0, no future error.
168
- Object.defineProperty(img, "complete", { value: true });
169
- Object.defineProperty(img, "naturalWidth", { value: 0 });
170
- document.body.appendChild(img);
171
- sweepBrokenAvatars(document);
172
- expect(
173
- document.body.querySelector("span.avatar--initials")?.textContent,
174
- ).toBe("ZZ");
175
- });
176
-
177
- it("sweepBrokenAvatars leaves a successfully-loaded avatar alone", () => {
178
- const img = document.createElement("img");
179
- img.className = "avatar";
180
- img.setAttribute("data-certrev-initials", "OK");
181
- Object.defineProperty(img, "complete", { value: true });
182
- Object.defineProperty(img, "naturalWidth", { value: 56 }); // loaded
183
- document.body.appendChild(img);
184
- sweepBrokenAvatars(document);
185
- expect(
186
- document.body.querySelector("img[data-certrev-initials]"),
187
- ).not.toBeNull();
188
- expect(document.body.querySelector("span.avatar--initials")).toBeNull();
189
- });
190
-
191
- it("the capture-phase error listener swaps a broken avatar on its error event", () => {
192
- const img = document.createElement("img");
193
- img.className = "avatar";
194
- img.setAttribute("data-certrev-initials", "AB");
195
- document.body.appendChild(img);
196
- initCertInteractions(document);
197
- img.dispatchEvent(new Event("error")); // error doesn't bubble → handler is capture-phase
198
- expect(
199
- document.body.querySelector("span.avatar--initials")?.textContent,
200
- ).toBe("AB");
201
- });
202
- });
203
-
204
- describe("initCertInteractions — delegated accordion + modal", () => {
205
- it("a row click opens the accordion; a second click closes it", () => {
206
- const { row } = mountCard();
207
- initCertInteractions(document);
208
- row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
209
- expect(row.classList.contains("open")).toBe(true);
210
- expect(row.getAttribute("aria-expanded")).toBe("true");
211
- row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
212
- expect(row.classList.contains("open")).toBe(false);
213
- expect(row.getAttribute("aria-expanded")).toBe("false");
214
- });
215
-
216
- it("a click on a LINK inside the card does not toggle the accordion (the link has its own action)", () => {
217
- const { card, row } = mountCard();
218
- // Give the row an inner link to prove the guard (e.g. a future inline control).
219
- const a = document.createElement("a");
220
- a.href = "https://certrev.com/x";
221
- a.textContent = "link";
222
- row.querySelector(".info")!.appendChild(a);
223
- initCertInteractions(document);
224
- a.dispatchEvent(new MouseEvent("click", { bubbles: true }));
225
- expect(row.classList.contains("open")).toBe(false);
226
- expect(card.querySelector("[data-certrev-acc].open")).toBeNull();
227
- });
228
-
229
- it("Enter on the role=button row toggles the accordion (a div fires no native click)", () => {
230
- const { row } = mountCard();
231
- initCertInteractions(document);
232
- row.dispatchEvent(
233
- new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
234
- );
235
- expect(row.classList.contains("open")).toBe(true);
236
- row.dispatchEvent(
237
- new KeyboardEvent("keydown", { key: " ", bubbles: true }),
238
- );
239
- expect(row.classList.contains("open")).toBe(false);
240
- });
241
-
242
- it("install is idempotent (second call does not double-bind — one click toggles exactly once)", () => {
243
- const { row } = mountCard();
244
- initCertInteractions(document);
245
- initCertInteractions(document); // no-op second install
246
- row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
247
- // A double-bound handler would toggle twice (open → closed); one binding leaves it open.
248
- expect(row.classList.contains("open")).toBe(true);
249
- });
250
- });
@@ -1,398 +0,0 @@
1
- // @vitest-environment jsdom
2
- /**
3
- * Unit tests for the cert-card placement DOM layer (iframe→native parity): the @graph→<head>
4
- * relocate, the TWO-wrap reposition — contributors card to the TOP of the article, expert memo to
5
- * the BOTTOM (before the references list if present) — the tier observability attribute, and
6
- * idempotency (never move twice / detach the badge).
7
- */
8
-
9
- import { beforeEach, describe, expect, it } from "vitest";
10
- import {
11
- CARD_WRAP_SELECTOR,
12
- MEMO_WRAP_SELECTOR,
13
- PLACEMENT_ATTR,
14
- placeCert,
15
- } from "./placement.js";
16
-
17
- /** Stub an element's box so the rail's gutter math is deterministic in jsdom (which does no layout). */
18
- function stubRight(el: Element, right: number): void {
19
- el.getBoundingClientRect = () =>
20
- ({
21
- right,
22
- left: 0,
23
- top: 0,
24
- bottom: 0,
25
- width: right,
26
- height: 0,
27
- x: 0,
28
- y: 0,
29
- toJSON() {},
30
- }) as DOMRect;
31
- }
32
- function setInnerWidth(px: number): void {
33
- Object.defineProperty(window, "innerWidth", {
34
- configurable: true,
35
- value: px,
36
- });
37
- }
38
-
39
- beforeEach(() => {
40
- document.head.innerHTML = "";
41
- document.body.innerHTML = "";
42
- });
43
-
44
- /** Append the @graph script + the two cert wraps at the END of <body> (where a body embed renders). */
45
- function mountEmbed(opts: { graph?: boolean; memo?: boolean } = {}): {
46
- card: HTMLElement;
47
- memo: HTMLElement | null;
48
- } {
49
- if (opts.graph !== false) {
50
- const s = document.createElement("script");
51
- s.setAttribute("type", "application/ld+json");
52
- s.setAttribute("data-certrev-graph", "");
53
- s.textContent = '{"@context":"https://schema.org","@graph":[]}';
54
- document.body.appendChild(s);
55
- }
56
- const card = document.createElement("div");
57
- card.className = "certrev-card-wrap";
58
- card.setAttribute("data-certrev-anchor", "top");
59
- card.innerHTML =
60
- '<certrev-badge revalidate><section class="certrev-badge">card</section></certrev-badge>';
61
- document.body.appendChild(card);
62
-
63
- let memo: HTMLElement | null = null;
64
- if (opts.memo !== false) {
65
- memo = document.createElement("div");
66
- memo.className = "certrev-memo-wrap";
67
- memo.setAttribute("data-certrev-anchor", "bottom");
68
- memo.innerHTML = '<section class="certrev-memo">memo</section>';
69
- document.body.appendChild(memo);
70
- }
71
- return { card, memo };
72
- }
73
-
74
- describe("placeCert — @graph relocation", () => {
75
- it("moves the marked @graph script into <head>", () => {
76
- mountEmbed();
77
- expect(
78
- document.head.querySelector("script[data-certrev-graph]"),
79
- ).toBeNull();
80
- const res = placeCert(document);
81
- expect(res.graph).toBe(true);
82
- const inHead = document.head.querySelector(
83
- 'script[type="application/ld+json"][data-certrev-graph]',
84
- );
85
- expect(inHead).not.toBeNull();
86
- expect(inHead!.textContent).toContain("@graph");
87
- });
88
-
89
- it("leaves an unmarked ld+json script alone (only ours moves)", () => {
90
- const other = document.createElement("script");
91
- other.setAttribute("type", "application/ld+json");
92
- other.textContent = '{"@type":"WebSite"}';
93
- document.body.appendChild(other);
94
- mountEmbed({ graph: false });
95
- placeCert(document);
96
- expect(document.head.querySelector("script")).toBeNull();
97
- expect(
98
- document.body.querySelector('script[type="application/ld+json"]'),
99
- ).toBe(other);
100
- });
101
- });
102
-
103
- describe("placeCert — contributors card → TOP of article", () => {
104
- it("inserts the card as the first child of a semantic <article> (afterbegin)", () => {
105
- const article = document.createElement("article");
106
- article.innerHTML = "<p>body</p>";
107
- document.body.insertBefore(article, document.body.firstChild);
108
- const { card } = mountEmbed();
109
-
110
- const res = placeCert(document);
111
- expect(res.card?.tier).toBe("article");
112
- expect(res.card?.anchored).toBe(true);
113
- expect(article.firstElementChild).toBe(card);
114
- expect(card.getAttribute(PLACEMENT_ATTR)).toBe("article");
115
- });
116
-
117
- it("prefers the body-content container so the card lands above the article text (theme-class)", () => {
118
- // Both a post-level <article> and an inner content container exist; the card should go into
119
- // the CONTENT container (TOP_ANCHORS prefers it) so it sits above the body, not above the title.
120
- const article = document.createElement("article");
121
- const content = document.createElement("div");
122
- content.className = "rte";
123
- content.innerHTML = "<p>summary</p>";
124
- article.appendChild(content);
125
- document.body.insertBefore(article, document.body.firstChild);
126
- const { card } = mountEmbed();
127
-
128
- const res = placeCert(document);
129
- expect(res.card?.tier).toBe("theme-class");
130
- expect(content.firstElementChild).toBe(card);
131
- });
132
- });
133
-
134
- describe("placeCert — skips hidden drawer/popup anchors (IntelliPure cart-drawer regression)", () => {
135
- it("does NOT relocate the card into a closed drawer whose empty-cart message uses .rte", () => {
136
- // A closed cart-drawer (hidden) sits BEFORE the article and carries its own `.rte` (empty-cart
137
- // message) — exactly the shape that put IntelliPure's card into a `<cart-drawer hidden>` and made
138
- // it invisible. The card must skip the hidden `.rte` and anchor to the visible article content.
139
- const article = document.createElement("article");
140
- const content = document.createElement("div");
141
- content.className = "rte";
142
- content.innerHTML = "<p>article body</p>";
143
- article.appendChild(content);
144
- document.body.appendChild(article);
145
-
146
- const drawer = document.createElement("div");
147
- drawer.setAttribute("hidden", ""); // closed drawer → display:none
148
- drawer.innerHTML = '<div class="rte"><p>Your cart is empty</p></div>';
149
- document.body.insertBefore(drawer, document.body.firstChild); // drawer is FIRST in document order
150
-
151
- const { card, memo } = mountEmbed();
152
- const res = placeCert(document);
153
-
154
- expect(drawer.contains(card)).toBe(false); // never in the hidden drawer
155
- expect(content.contains(card)).toBe(true); // anchored to the VISIBLE article .rte
156
- expect(res.card?.tier).toBe("theme-class");
157
- expect(drawer.contains(memo!)).toBe(false); // the shared guard protects the memo too
158
- });
159
-
160
- it("skips a visibility:hidden anchor in favour of the visible one (card + memo)", () => {
161
- const hidden = document.createElement("article");
162
- hidden.style.visibility = "hidden";
163
- hidden.innerHTML = "<p>hidden</p>";
164
- document.body.appendChild(hidden);
165
- const visible = document.createElement("article");
166
- visible.innerHTML = "<p>visible</p>";
167
- document.body.appendChild(visible);
168
- const { card, memo } = mountEmbed();
169
-
170
- placeCert(document);
171
- expect(hidden.contains(card)).toBe(false);
172
- expect(visible.firstElementChild).toBe(card);
173
- expect(hidden.contains(memo!)).toBe(false);
174
- expect(visible.nextElementSibling).toBe(memo); // memo lands after the VISIBLE article
175
- });
176
-
177
- it("memo anchors before #references even when a small hero <article> precedes the content article", () => {
178
- // IntelliPure's theme has TWO <article>s — a small hero banner BEFORE the content article — and
179
- // #references lives in the CONTENT article. The memo must anchor off #references (bottom), NOT
180
- // afterend of the first-matched hero (which stranded it at the TOP, above the card).
181
- const hero = document.createElement("article");
182
- hero.className = "article banner";
183
- hero.innerHTML = "<h1>title</h1>";
184
- document.body.appendChild(hero);
185
- const content = document.createElement("article");
186
- content.className = "article page-width";
187
- // #references lives INSIDE the body .rte (as the html-generator emits it) — the card anchors to
188
- // the .rte that OWNS #references, the memo to just before #references.
189
- content.innerHTML =
190
- '<div class="rte"><p>body</p><div id="references"><p>refs</p></div></div>';
191
- document.body.appendChild(content);
192
- const { card, memo } = mountEmbed();
193
-
194
- const res = placeCert(document);
195
- const refs = content.querySelector("#references")!;
196
- expect(refs.previousElementSibling).toBe(memo); // memo just before references (bottom of content)
197
- expect(hero.contains(memo!)).toBe(false); // NOT stranded at the hero (top)
198
- expect(res.memo?.tier).toBe("references");
199
- expect(content.querySelector(".rte")!.firstElementChild).toBe(card); // card lands in the .rte that owns #references
200
- });
201
- });
202
-
203
- describe("placeCert — skips VISIBLE site-chrome anchors (IntelliPure announcement-bar regression, POR-9809)", () => {
204
- it("does NOT land the card in a VISIBLE .rte announcement bar — anchors to the article that owns #references", () => {
205
- // IntelliPure's announcement bar is `<div class="announcement__content … rte">` — VISIBLE (so the
206
- // isRenderable guard can't reject it) and FIRST in document order. Before the fix the contributors
207
- // card was injected there and "wasn't showing". It must anchor to the article `.rte` that owns
208
- // #references instead.
209
- const announcement = document.createElement("div");
210
- announcement.className = "announcement__content flex items-center rte";
211
- announcement.innerHTML = "<p>Free shipping over $50</p>";
212
- document.body.appendChild(announcement); // FIRST in document order, visible
213
-
214
- const article = document.createElement("article");
215
- const content = document.createElement("div");
216
- content.className = "rte";
217
- content.innerHTML =
218
- '<p>article body</p><div id="references"><p>refs</p></div>';
219
- article.appendChild(content);
220
- document.body.appendChild(article);
221
-
222
- const { card } = mountEmbed();
223
- const res = placeCert(document);
224
-
225
- expect(announcement.contains(card)).toBe(false); // never in the announcement bar
226
- expect(content.firstElementChild).toBe(card); // anchored to the article body that owns #references
227
- expect(res.card?.tier).toBe("theme-class");
228
- });
229
-
230
- it("skips a VISIBLE chrome .rte in the fallback scan when no #references exists", () => {
231
- // Same announcement-bar shape, but the article has NO references list → the card uses the fallback
232
- // anchor scan. The chrome-exclusion must still skip the announcement `.rte` and land in the article.
233
- const announcement = document.createElement("div");
234
- announcement.className = "announcement-bar rte";
235
- announcement.innerHTML = "<p>promo</p>";
236
- document.body.appendChild(announcement);
237
-
238
- const article = document.createElement("article");
239
- const content = document.createElement("div");
240
- content.className = "rte";
241
- content.innerHTML = "<p>article body</p>";
242
- article.appendChild(content);
243
- document.body.appendChild(article);
244
-
245
- const { card } = mountEmbed({ memo: false });
246
- const res = placeCert(document);
247
-
248
- expect(announcement.contains(card)).toBe(false);
249
- expect(content.firstElementChild).toBe(card);
250
- expect(res.card?.tier).toBe("theme-class");
251
- });
252
- });
253
-
254
- describe("placeCert — expert memo → BOTTOM of article", () => {
255
- it("inserts the memo just after a semantic <article> when no references list exists", () => {
256
- const article = document.createElement("article");
257
- article.innerHTML = "<p>body</p>";
258
- document.body.insertBefore(article, document.body.firstChild);
259
- const { memo } = mountEmbed();
260
-
261
- const res = placeCert(document);
262
- expect(res.memo?.tier).toBe("article");
263
- expect(article.nextElementSibling).toBe(memo);
264
- expect(memo!.getAttribute(PLACEMENT_ATTR)).toBe("article");
265
- });
266
-
267
- it("inserts the memo just BEFORE the references list (legacy memo → references order)", () => {
268
- const article = document.createElement("article");
269
- const refs = document.createElement("div");
270
- refs.id = "references";
271
- article.appendChild(document.createElement("p"));
272
- article.appendChild(refs);
273
- document.body.insertBefore(article, document.body.firstChild);
274
- const { memo } = mountEmbed();
275
-
276
- placeCert(document);
277
- expect(refs.previousElementSibling).toBe(memo);
278
- expect(memo!.parentElement).toBe(article);
279
- });
280
- });
281
-
282
- describe("placeCert — fallbacks + idempotency", () => {
283
- it("uses the styled body-bottom fallback for both wraps when nothing matches", () => {
284
- const { card, memo } = mountEmbed();
285
- const res = placeCert(document);
286
- expect(res.card?.tier).toBe("body-fallback");
287
- expect(res.card?.anchored).toBe(false);
288
- expect(res.memo?.tier).toBe("body-fallback");
289
- expect(card.getAttribute(PLACEMENT_ATTR)).toBe("body-fallback");
290
- expect(memo!.getAttribute(PLACEMENT_ATTR)).toBe("body-fallback");
291
- });
292
-
293
- it("is idempotent — a second pass does not move either wrap again", () => {
294
- const article = document.createElement("article");
295
- document.body.insertBefore(article, document.body.firstChild);
296
- const { card, memo } = mountEmbed();
297
- placeCert(document);
298
- const cardParent = card.parentElement;
299
- const memoNext = memo!.previousElementSibling ?? memo!.nextElementSibling;
300
-
301
- const res2 = placeCert(document);
302
- expect(res2.card?.tier).toBe("article");
303
- expect(card.parentElement).toBe(cardParent);
304
- expect(memo!.previousElementSibling ?? memo!.nextElementSibling).toBe(
305
- memoNext,
306
- );
307
- });
308
-
309
- it("no-ops without throwing when the wraps are absent", () => {
310
- expect(() => placeCert(document)).not.toThrow();
311
- expect(document.querySelector(CARD_WRAP_SELECTOR)).toBeNull();
312
- expect(document.querySelector(MEMO_WRAP_SELECTOR)).toBeNull();
313
- });
314
-
315
- it("places the card even when the memo wrap is absent (no memo on this article)", () => {
316
- const article = document.createElement("article");
317
- document.body.insertBefore(article, document.body.firstChild);
318
- const { memo } = mountEmbed({ memo: false });
319
- expect(memo).toBeNull();
320
- const res = placeCert(document);
321
- expect(res.card?.tier).toBe("article");
322
- expect(res.memo).toBeNull();
323
- });
324
- });
325
-
326
- describe('placeCert — sidebar rail (layout "sidebar")', () => {
327
- /** Mount an article that owns #references + a lone rail wrap (the sidebar render's single wrap). */
328
- function mountRail(): { wrap: HTMLElement; content: HTMLElement } {
329
- const article = document.createElement("article");
330
- const content = document.createElement("div");
331
- content.className = "rte";
332
- content.innerHTML = '<p>body</p><div id="references"><p>refs</p></div>';
333
- article.appendChild(content);
334
- document.body.appendChild(article);
335
- const wrap = document.createElement("div");
336
- wrap.className = "certrev-memo-wrap certrev-sidebar-wrap";
337
- wrap.setAttribute("data-certrev-anchor", "rail");
338
- wrap.innerHTML =
339
- '<section class="certrev-badge certrev-memo certrev-sidebar">rail</section>';
340
- document.body.appendChild(wrap);
341
- return { wrap, content };
342
- }
343
-
344
- it("pins the rail (fixed) + sets --certrev-rail-left when the right gutter is wide enough", () => {
345
- const { wrap, content } = mountRail();
346
- stubRight(content, 600);
347
- setInnerWidth(1200); // gutter = 600 ≥ 384
348
-
349
- const res = placeCert(document);
350
- expect(res.memo?.tier).toBe("rail");
351
- expect(res.memo?.anchored).toBe(true);
352
- expect(wrap.getAttribute(PLACEMENT_ATTR)).toBe("rail");
353
- expect(wrap.style.getPropertyValue("--certrev-rail-left")).toBe("624px"); // rect.right + RAIL_GAP
354
- // The rail is FIXED at the embed point — never moved into the article content.
355
- expect(content.contains(wrap)).toBe(false);
356
- });
357
-
358
- it("falls back to the in-flow banner-style TOP placement when the gutter is too narrow", () => {
359
- const { wrap, content } = mountRail();
360
- stubRight(content, 1100);
361
- setInnerWidth(1200); // gutter = 100 < 384
362
-
363
- const res = placeCert(document);
364
- expect(res.memo?.tier).toBe("theme-class"); // the .rte content container
365
- expect(content.firstElementChild).toBe(wrap); // afterbegin, like the banner card
366
- expect(wrap.style.getPropertyValue("--certrev-rail-left")).toBe(""); // never pinned
367
- });
368
-
369
- it("body-fallback when no article owner exists for the rail", () => {
370
- const wrap = document.createElement("div");
371
- wrap.className = "certrev-memo-wrap certrev-sidebar-wrap";
372
- wrap.setAttribute("data-certrev-anchor", "rail");
373
- wrap.innerHTML =
374
- '<section class="certrev-badge certrev-memo certrev-sidebar">rail</section>';
375
- document.body.appendChild(wrap);
376
-
377
- const res = placeCert(document);
378
- expect(res.memo?.tier).toBe("body-fallback");
379
- expect(res.memo?.anchored).toBe(false);
380
- });
381
- });
382
-
383
- describe('placeCert — floating pill (layout "floating")', () => {
384
- it('stamps the pill wrap "pill-fixed" (CSS owns the placement) with no card/memo', () => {
385
- const wrap = document.createElement("div");
386
- wrap.className = "certrev-pill-wrap";
387
- wrap.setAttribute("data-certrev-anchor", "pill");
388
- wrap.innerHTML =
389
- '<section class="certrev-badge certrev-pill">pill</section>';
390
- document.body.appendChild(wrap);
391
-
392
- const res = placeCert(document);
393
- expect(wrap.getAttribute(PLACEMENT_ATTR)).toBe("pill-fixed");
394
- // A floating render carries neither the card nor the memo wrap.
395
- expect(res.card).toBeNull();
396
- expect(res.memo).toBeNull();
397
- });
398
- });