Skip to content
File

Blob: tests/sites/static-renderer.test.ts

typescript421 lines
1import { readFileSync } from "node:fs";
2import { URL } from "node:url";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it } from "vitest";
5import { runWithSitesReactRenderContext } from "@/sites/react-render-context";
6import { renderBlandSitesDocumentToReactElement } from "@/sites/static-renderer";
7import { collectSitesOutline } from "@/sites/static-renderer/outline";
8import { createTestSitesPageRenderContext, type TestSitesPageRenderContext } from "./render-context";
9 
10function render(content: Parameters<typeof renderBlandSitesDocumentToReactElement>[0]): string {
11 return renderToStaticMarkup(renderBlandSitesDocumentToReactElement(content));
12}
13 
14function renderWithSitesContext(
15 content: Parameters<typeof renderBlandSitesDocumentToReactElement>[0],
16 context: TestSitesPageRenderContext,
17): string {
18 return runWithSitesReactRenderContext(context, () => render(content));
19}
20 
21describe("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 
412function 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 
418function escapeRegExp(value: string): string {
419 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
420}