File
Blob: tests/sites/static-renderer.test.ts
| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { URL } from "node:url"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import { describe, expect, it } from "vitest"; |
| 5 | import { runWithSitesReactRenderContext } from "@/sites/react-render-context"; |
| 6 | import { renderBlandSitesDocumentToReactElement } from "@/sites/static-renderer"; |
| 7 | import { collectSitesOutline } from "@/sites/static-renderer/outline"; |
| 8 | import { createTestSitesPageRenderContext, type TestSitesPageRenderContext } from "./render-context"; |
| 9 | |
| 10 | function render(content: Parameters<typeof renderBlandSitesDocumentToReactElement>[0]): string { |
| 11 | return renderToStaticMarkup(renderBlandSitesDocumentToReactElement(content)); |
| 12 | } |
| 13 | |
| 14 | function renderWithSitesContext( |
| 15 | content: Parameters<typeof renderBlandSitesDocumentToReactElement>[0], |
| 16 | context: TestSitesPageRenderContext, |
| 17 | ): string { |
| 18 | return runWithSitesReactRenderContext(context, () => render(content)); |
| 19 | } |
| 20 | |
| 21 | describe("renderBlandSitesDocumentToReactElement", () => { |
| 22 | it("renders static heading anchors from the collected Sites outline", () => { |
| 23 | const content = { |
| 24 | type: "doc", |
| 25 | content: [ |
| 26 | { |
| 27 | type: "heading", |
| 28 | attrs: { level: 1, bid: "H1" }, |
| 29 | content: [{ type: "text", text: "Intro" }], |
| 30 | }, |
| 31 | { |
| 32 | type: "heading", |
| 33 | attrs: { level: 2, bid: "H2", textAlign: "center" }, |
| 34 | content: [{ type: "text", text: "Intro" }], |
| 35 | }, |
| 36 | { |
| 37 | type: "heading", |
| 38 | attrs: { level: 3, bid: "EMPTY" }, |
| 39 | }, |
| 40 | ], |
| 41 | }; |
| 42 | const outline = collectSitesOutline(content); |
| 43 | const html = renderWithSitesContext( |
| 44 | content, |
| 45 | createTestSitesPageRenderContext({ headingAnchorIds: outline.headingAnchorIds }), |
| 46 | ); |
| 47 | |
| 48 | expect(outline.items).toEqual([ |
| 49 | { id: "intro", text: "Intro", level: 1, href: "#intro" }, |
| 50 | { id: "intro-2", text: "Intro", level: 2, href: "#intro-2" }, |
| 51 | ]); |
| 52 | expect(outline.headingAnchorIds).toEqual(["intro", "intro-2", null]); |
| 53 | expect(html).toContain('<h1 id="intro" data-bid="H1">Intro</h1>'); |
| 54 | expect(html).toContain('<h2 id="intro-2" data-bid="H2" style="text-align:center">Intro</h2>'); |
| 55 | expect(html).toContain('<h3 data-bid="EMPTY"></h3>'); |
| 56 | }); |
| 57 | |
| 58 | it("excludes headings inside closed details while keeping heading id alignment", () => { |
| 59 | const content = { |
| 60 | type: "doc", |
| 61 | content: [ |
| 62 | { |
| 63 | type: "heading", |
| 64 | attrs: { level: 1, bid: "TOP" }, |
| 65 | content: [{ type: "text", text: "Top" }], |
| 66 | }, |
| 67 | { |
| 68 | type: "details", |
| 69 | attrs: { open: false }, |
| 70 | content: [ |
| 71 | { type: "detailsSummary", content: [{ type: "text", text: "Closed" }] }, |
| 72 | { |
| 73 | type: "detailsContent", |
| 74 | content: [ |
| 75 | { |
| 76 | type: "heading", |
| 77 | attrs: { level: 2, bid: "CLOSED" }, |
| 78 | content: [{ type: "text", text: "Closed heading" }], |
| 79 | }, |
| 80 | ], |
| 81 | }, |
| 82 | ], |
| 83 | }, |
| 84 | { |
| 85 | type: "details", |
| 86 | attrs: { open: true }, |
| 87 | content: [ |
| 88 | { type: "detailsSummary", content: [{ type: "text", text: "Open" }] }, |
| 89 | { |
| 90 | type: "detailsContent", |
| 91 | content: [ |
| 92 | { |
| 93 | type: "heading", |
| 94 | attrs: { level: 2, bid: "OPEN" }, |
| 95 | content: [{ type: "text", text: "Open heading" }], |
| 96 | }, |
| 97 | ], |
| 98 | }, |
| 99 | ], |
| 100 | }, |
| 101 | ], |
| 102 | }; |
| 103 | const outline = collectSitesOutline(content); |
| 104 | const html = renderWithSitesContext( |
| 105 | content, |
| 106 | createTestSitesPageRenderContext({ headingAnchorIds: outline.headingAnchorIds }), |
| 107 | ); |
| 108 | |
| 109 | expect(outline.items).toEqual([ |
| 110 | { id: "top", text: "Top", level: 1, href: "#top" }, |
| 111 | { id: "open-heading", text: "Open heading", level: 2, href: "#open-heading" }, |
| 112 | ]); |
| 113 | expect(outline.headingAnchorIds).toEqual(["top", null, "open-heading"]); |
| 114 | expect(html).toContain('<h1 id="top" data-bid="TOP">Top</h1>'); |
| 115 | expect(html).toContain('<h2 data-bid="CLOSED">Closed heading</h2>'); |
| 116 | expect(html).toContain('<h2 id="open-heading" data-bid="OPEN">Open heading</h2>'); |
| 117 | }); |
| 118 | |
| 119 | it("emits a trailing <br> for empty paragraphs so they preserve block rhythm", () => { |
| 120 | const html = render({ |
| 121 | type: "doc", |
| 122 | content: [ |
| 123 | { type: "paragraph", content: [{ type: "text", text: "before" }] }, |
| 124 | { type: "paragraph" }, |
| 125 | { type: "paragraph", content: [{ type: "text", text: "after" }] }, |
| 126 | ], |
| 127 | }); |
| 128 | expect(html).toContain("<p><br/></p>"); |
| 129 | expect(html).toContain(">before<"); |
| 130 | expect(html).toContain(">after<"); |
| 131 | }); |
| 132 | |
| 133 | it("renders task list checkboxes as disabled controls with the saved checked state", () => { |
| 134 | const html = render({ |
| 135 | type: "doc", |
| 136 | content: [ |
| 137 | { |
| 138 | type: "taskList", |
| 139 | attrs: { bid: "TASKS" }, |
| 140 | content: [ |
| 141 | { |
| 142 | type: "taskItem", |
| 143 | attrs: { checked: true }, |
| 144 | content: [ |
| 145 | { |
| 146 | type: "paragraph", |
| 147 | content: [{ type: "text", text: "Ship published checkbox" }], |
| 148 | }, |
| 149 | ], |
| 150 | }, |
| 151 | { |
| 152 | type: "taskItem", |
| 153 | attrs: { checked: false }, |
| 154 | content: [ |
| 155 | { |
| 156 | type: "paragraph", |
| 157 | content: [{ type: "text", text: "Keep visitor local" }], |
| 158 | }, |
| 159 | ], |
| 160 | }, |
| 161 | ], |
| 162 | }, |
| 163 | ], |
| 164 | }); |
| 165 | const checkedInput = findInputByLabel(html, "Task item checkbox for Ship published checkbox, checked"); |
| 166 | const uncheckedInput = findInputByLabel(html, "Task item checkbox for Keep visitor local, unchecked"); |
| 167 | |
| 168 | expect(html).toMatch(/<ul(?=[^>]*data-type="taskList")(?=[^>]*data-bid="TASKS")[^>]*>/); |
| 169 | expect(html).toContain('<li data-type="taskItem" data-checked="true">'); |
| 170 | expect(html).toContain('<li data-type="taskItem" data-checked="false">'); |
| 171 | expect(html).toContain(">Ship published checkbox<"); |
| 172 | expect(html).toContain(">Keep visitor local<"); |
| 173 | expect(checkedInput).toContain('type="checkbox"'); |
| 174 | expect(checkedInput).toMatch(/\schecked(?:=""|(?=\s|>))/); |
| 175 | expect(checkedInput).toMatch(/\sdisabled(?:=""|(?=\s|>))/); |
| 176 | expect(uncheckedInput).toContain('type="checkbox"'); |
| 177 | expect(uncheckedInput).not.toMatch(/\schecked(?:=""|(?=\s|>))/); |
| 178 | expect(uncheckedInput).toMatch(/\sdisabled(?:=""|(?=\s|>))/); |
| 179 | }); |
| 180 | |
| 181 | it("renders emoji nodes through the shared schema's renderHTML so the <img> chain matches the live editor", () => { |
| 182 | const html = render({ |
| 183 | type: "doc", |
| 184 | content: [ |
| 185 | { |
| 186 | type: "paragraph", |
| 187 | content: [{ type: "emoji", attrs: { name: "smile" } }], |
| 188 | }, |
| 189 | ], |
| 190 | }); |
| 191 | expect(html).toContain('class="tiptap-emoji"'); |
| 192 | expect(html).toContain('data-type="emoji"'); |
| 193 | expect(html).toContain('data-name="smile"'); |
| 194 | expect(html).toMatch(/<img[^>]+src="https:\/\/cdn\.jsdelivr\.net\/[^"]+emoji-datasource-apple[^"]+\.png"/); |
| 195 | }); |
| 196 | |
| 197 | it("falls back to the shortcode literal when the schema does not recognize the emoji name", () => { |
| 198 | const html = render({ |
| 199 | type: "doc", |
| 200 | content: [ |
| 201 | { |
| 202 | type: "paragraph", |
| 203 | content: [{ type: "emoji", attrs: { name: "made_up_emoji" } }], |
| 204 | }, |
| 205 | ], |
| 206 | }); |
| 207 | expect(html).not.toContain("emoji-datasource-apple"); |
| 208 | expect(html).toContain(":made_up_emoji:"); |
| 209 | }); |
| 210 | |
| 211 | it("emits a native <details> element with summary + content for the details block", () => { |
| 212 | const html = render({ |
| 213 | type: "doc", |
| 214 | content: [ |
| 215 | { |
| 216 | type: "details", |
| 217 | attrs: { open: true, bid: "D1" }, |
| 218 | content: [ |
| 219 | { |
| 220 | type: "detailsSummary", |
| 221 | content: [{ type: "text", text: "Notes" }], |
| 222 | }, |
| 223 | { |
| 224 | type: "detailsContent", |
| 225 | content: [ |
| 226 | { |
| 227 | type: "paragraph", |
| 228 | content: [{ type: "text", text: "body" }], |
| 229 | }, |
| 230 | ], |
| 231 | }, |
| 232 | ], |
| 233 | }, |
| 234 | ], |
| 235 | }); |
| 236 | expect(html).toContain('<details class="tiptap-details" data-bid="D1" open'); |
| 237 | expect(html).toContain('class="tiptap-details-summary"'); |
| 238 | expect(html).toContain('data-placeholder="Summary"'); |
| 239 | expect(html).toContain(">Notes<"); |
| 240 | expect(html).toContain('class="tiptap-details-content" data-type="detailsContent"'); |
| 241 | expect(html).toContain(">body<"); |
| 242 | }); |
| 243 | |
| 244 | it("renders a closed details element without the open attribute", () => { |
| 245 | const html = render({ |
| 246 | type: "doc", |
| 247 | content: [ |
| 248 | { |
| 249 | type: "details", |
| 250 | attrs: { open: false }, |
| 251 | content: [ |
| 252 | { type: "detailsSummary", content: [{ type: "text", text: "Title" }] }, |
| 253 | { type: "detailsContent", content: [{ type: "paragraph" }] }, |
| 254 | ], |
| 255 | }, |
| 256 | ], |
| 257 | }); |
| 258 | expect(html).toContain('<details class="tiptap-details">'); |
| 259 | expect(html).not.toContain('open=""'); |
| 260 | expect(html).not.toContain("open>"); |
| 261 | }); |
| 262 | |
| 263 | it("uses escaped CSS content for details chevrons so Sites CSS does not depend on charset inference", () => { |
| 264 | const css = readFileSync(new URL("../../src/styles/editor/details.css", import.meta.url), "utf8"); |
| 265 | expect(css.match(/content: "\\25B8";/g)).toHaveLength(2); |
| 266 | }); |
| 267 | |
| 268 | it("marks static callout kind chrome as read-only", () => { |
| 269 | const html = render({ |
| 270 | type: "doc", |
| 271 | content: [ |
| 272 | { |
| 273 | type: "callout", |
| 274 | attrs: { kind: "info", bid: "C1" }, |
| 275 | content: [ |
| 276 | { |
| 277 | type: "paragraph", |
| 278 | content: [{ type: "text", text: "that should work" }], |
| 279 | }, |
| 280 | ], |
| 281 | }, |
| 282 | ], |
| 283 | }); |
| 284 | expect(html).toContain('class="tiptap-callout"'); |
| 285 | expect(html).toContain('data-callout-kind="info"'); |
| 286 | expect(html).toContain('data-bid="C1"'); |
| 287 | expect(html).toContain('class="tiptap-callout-kind-btn"'); |
| 288 | expect(html).toContain('data-read-only=""'); |
| 289 | }); |
| 290 | |
| 291 | it("renders static code blocks with syntax highlighting, copy island, and language chip", () => { |
| 292 | const html = render({ |
| 293 | type: "doc", |
| 294 | content: [ |
| 295 | { |
| 296 | type: "codeBlock", |
| 297 | attrs: { bid: "CODE1", language: "ts" }, |
| 298 | content: [{ type: "text", text: "const answer: number = 42;" }], |
| 299 | }, |
| 300 | ], |
| 301 | }); |
| 302 | |
| 303 | expect(html).toContain('class="tiptap-code-block-wrapper"'); |
| 304 | expect(html).toContain('data-language="typescript"'); |
| 305 | expect(html).toContain('data-bid="CODE1"'); |
| 306 | expect(html).toContain('data-island="copy-code"'); |
| 307 | expect(html).toContain('class="tiptap-code-block-copy-btn"'); |
| 308 | expect(html).toContain('aria-label="Copy code"'); |
| 309 | expect(html).toContain('aria-label="Language: TypeScript"'); |
| 310 | expect(html).toContain(">TypeScript<"); |
| 311 | expect(html).toContain("hljs-keyword"); |
| 312 | expect(html).toContain(">const<"); |
| 313 | expect(html).toContain("hljs-number"); |
| 314 | expect(html).toContain(">42<"); |
| 315 | // The legacy event-delegation hook is replaced by the island marker. |
| 316 | expect(html).not.toContain("data-sites-copy-code"); |
| 317 | }); |
| 318 | |
| 319 | it("does NOT expose pageId when the page mention attr is null (restricted via pre-walk)", () => { |
| 320 | const html = render({ |
| 321 | type: "doc", |
| 322 | content: [ |
| 323 | { |
| 324 | type: "paragraph", |
| 325 | content: [ |
| 326 | { |
| 327 | type: "pageMention", |
| 328 | attrs: { pageId: null }, |
| 329 | }, |
| 330 | ], |
| 331 | }, |
| 332 | ], |
| 333 | }); |
| 334 | expect(html).not.toContain("data-page-id="); |
| 335 | expect(html).toContain("Restricted"); |
| 336 | }); |
| 337 | |
| 338 | it("emits an accessible <a> mention when the resolver returns an href", () => { |
| 339 | const html = renderWithSitesContext( |
| 340 | { |
| 341 | type: "doc", |
| 342 | content: [ |
| 343 | { |
| 344 | type: "paragraph", |
| 345 | content: [ |
| 346 | { |
| 347 | type: "pageMention", |
| 348 | attrs: { pageId: "01ABC" }, |
| 349 | }, |
| 350 | ], |
| 351 | }, |
| 352 | ], |
| 353 | }, |
| 354 | createTestSitesPageRenderContext({ |
| 355 | resolvePageMention: () => ({ |
| 356 | label: "Welcome", |
| 357 | href: "/welcome-01ABC", |
| 358 | kind: "accessible", |
| 359 | }), |
| 360 | }), |
| 361 | ); |
| 362 | expect(html).toContain('<a class="tiptap-page-mention" data-page-id="01ABC"'); |
| 363 | expect(html).toContain('href="/welcome-01ABC"'); |
| 364 | expect(html).toContain(">Welcome<"); |
| 365 | }); |
| 366 | |
| 367 | it("keeps heading anchors and page mentions isolated across concurrent ALS renders", async () => { |
| 368 | const content = { |
| 369 | type: "doc", |
| 370 | content: [ |
| 371 | { |
| 372 | type: "heading", |
| 373 | attrs: { level: 1 }, |
| 374 | content: [{ type: "text", text: "Title" }], |
| 375 | }, |
| 376 | { |
| 377 | type: "paragraph", |
| 378 | content: [{ type: "pageMention", attrs: { pageId: "PAGE" } }], |
| 379 | }, |
| 380 | ], |
| 381 | }; |
| 382 | |
| 383 | async function renderIsolated(anchorId: string, label: string) { |
| 384 | return runWithSitesReactRenderContext( |
| 385 | createTestSitesPageRenderContext({ |
| 386 | headingAnchorIds: [anchorId], |
| 387 | resolvePageMention: () => ({ label, href: `/${label.toLowerCase()}`, kind: "accessible" }), |
| 388 | }), |
| 389 | async () => { |
| 390 | await Promise.resolve(); |
| 391 | return render(content); |
| 392 | }, |
| 393 | ); |
| 394 | } |
| 395 | |
| 396 | const [first, second] = await Promise.all([ |
| 397 | renderIsolated("first-heading", "First"), |
| 398 | renderIsolated("second-heading", "Second"), |
| 399 | ]); |
| 400 | |
| 401 | expect(first).toContain('<h1 id="first-heading">Title</h1>'); |
| 402 | expect(first).toContain(">First<"); |
| 403 | expect(first).not.toContain("second-heading"); |
| 404 | expect(first).not.toContain(">Second<"); |
| 405 | expect(second).toContain('<h1 id="second-heading">Title</h1>'); |
| 406 | expect(second).toContain(">Second<"); |
| 407 | expect(second).not.toContain("first-heading"); |
| 408 | expect(second).not.toContain(">First<"); |
| 409 | }); |
| 410 | }); |
| 411 | |
| 412 | function findInputByLabel(html: string, label: string): string { |
| 413 | const match = html.match(new RegExp(`<input[^>]+aria-label="${escapeRegExp(label)}"[^>]*>`)); |
| 414 | expect(match, `expected input with aria-label "${label}"`).not.toBeNull(); |
| 415 | return match?.[0] ?? ""; |
| 416 | } |
| 417 | |
| 418 | function escapeRegExp(value: string): string { |
| 419 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 420 | } |