Skip to content
File

Blob: tests/client/components/editor/extensions/schema-parity.test.ts

typescript183 lines
1import { describe, expect, it } from "vitest";
2import { getSchema, type AnyExtension, type Extensions } from "@tiptap/core";
3import CharacterCount from "@tiptap/extension-character-count";
4import { TaskItem } from "@tiptap/extension-task-item";
5import { TaskList } from "@tiptap/extension-task-list";
6import { TextAlign } from "@tiptap/extension-text-align";
7import { BackgroundColor, Color, TextStyle } from "@tiptap/extension-text-style";
8import Typography from "@tiptap/extension-typography";
9import { StarterKit } from "@tiptap/starter-kit";
10import type { Schema } from "@tiptap/pm/model";
11import { countCharacters, countWords, createHeadlessEditorExtensions } from "@/shared/editor/schema";
12import { CalloutExtension } from "@/client/components/editor/extensions/callout";
13import { HighlightedCodeBlock } from "@/client/components/editor/extensions/code-block/extension";
14import { DetailsBlockExtensions } from "@/client/components/editor/extensions/details-block";
15import { EditorEmoji } from "@/client/components/editor/extensions/emoji";
16import { ShareAwareImage } from "@/client/components/editor/extensions/image/node";
17import { PageMentionNode } from "@/client/components/editor/extensions/page-mention/node";
18import { createTableExtensions } from "@/client/components/editor/extensions/table-extensions";
19import { TopLevelBlockIdentity } from "@/client/components/editor/extensions/top-level-block-identity";
20 
21const CUSTOM_NODE_NAMES = [
22 "callout",
23 "codeBlock",
24 "details",
25 "detailsSummary",
26 "detailsContent",
27 "image",
28 "pageMention",
29 "table",
30 "tableRow",
31 "tableCell",
32 "tableHeader",
33] as const;
34 
35const MARK_NAMES = ["bold", "italic", "strike", "code", "link", "textStyle"] as const;
36 
37function createClientSchemaExtensions(): Extensions {
38 return [
39 StarterKit.configure({
40 undoRedo: false,
41 dropcursor: false,
42 link: { openOnClick: false, autolink: true },
43 codeBlock: false,
44 }),
45 Typography.configure({
46 emDash: false,
47 openDoubleQuote: false,
48 closeDoubleQuote: false,
49 openSingleQuote: false,
50 closeSingleQuote: false,
51 leftArrow: false,
52 rightArrow: false,
53 copyright: false,
54 trademark: false,
55 servicemark: false,
56 registeredTrademark: false,
57 oneHalf: false,
58 plusMinus: false,
59 notEqual: false,
60 laquo: false,
61 raquo: false,
62 multiplication: false,
63 superscriptTwo: false,
64 superscriptThree: false,
65 oneQuarter: false,
66 threeQuarters: false,
67 }),
68 EditorEmoji,
69 CharacterCount.configure({
70 textCounter: countCharacters,
71 wordCounter: countWords,
72 }),
73 TextStyle,
74 Color,
75 BackgroundColor,
76 TextAlign.configure({ types: ["heading", "paragraph"] }),
77 ...DetailsBlockExtensions,
78 CalloutExtension,
79 HighlightedCodeBlock.configure({
80 defaultLanguage: "text",
81 enableTabIndentation: true,
82 }),
83 TopLevelBlockIdentity,
84 ShareAwareImage.configure({
85 inline: false,
86 allowBase64: false,
87 getRuntime: () => ({ workspaceId: undefined, pageId: "", shareToken: undefined }),
88 }),
89 TaskList,
90 TaskItem.configure({ nested: true }),
91 PageMentionNode,
92 ...createTableExtensions(),
93 ] as AnyExtension[];
94}
95 
96function summarizeNode(schema: Schema, name: string) {
97 const type = schema.nodes[name];
98 expect(type, `${name} node should exist`).toBeDefined();
99 return {
100 name: type.name,
101 attrs: Object.fromEntries(Object.entries(type.spec.attrs ?? {}).map(([attr, spec]) => [attr, spec.default])),
102 group: type.spec.group ?? null,
103 content: type.spec.content ?? null,
104 marks: type.spec.marks ?? null,
105 inline: type.spec.inline ?? null,
106 atom: type.spec.atom ?? null,
107 selectable: type.spec.selectable ?? null,
108 draggable: type.spec.draggable ?? null,
109 isolating: type.spec.isolating ?? null,
110 defining: type.spec.defining ?? null,
111 code: type.spec.code ?? null,
112 };
113}
114 
115function summarizeMark(schema: Schema, name: string) {
116 const type = schema.marks[name];
117 expect(type, `${name} mark should exist`).toBeDefined();
118 return {
119 name: type.name,
120 attrs: Object.fromEntries(Object.entries(type.spec.attrs ?? {}).map(([attr, spec]) => [attr, spec.default])),
121 inclusive: type.spec.inclusive ?? null,
122 excludes: type.spec.excludes ?? null,
123 group: type.spec.group ?? null,
124 code: type.spec.code ?? null,
125 };
126}
127 
128function toDomSpec(schema: Schema, content: Record<string, unknown>) {
129 const node = schema.nodeFromJSON({ type: "doc", content: [content] }).firstChild;
130 expect(node).toBeTruthy();
131 return node?.type.spec.toDOM?.(node);
132}
133 
134function inlineToDomSpec(schema: Schema, content: Record<string, unknown>) {
135 const paragraph = schema.nodeFromJSON({
136 type: "doc",
137 content: [{ type: "paragraph", content: [content] }],
138 }).firstChild;
139 const node = paragraph?.firstChild;
140 expect(node).toBeTruthy();
141 return node?.type.spec.toDOM?.(node);
142}
143 
144describe("headless editor schema parity", () => {
145 const headlessSchema = getSchema(createHeadlessEditorExtensions());
146 const clientSchema = getSchema(createClientSchemaExtensions());
147 
148 it("keeps custom schema node specs aligned with the client adapters", () => {
149 for (const name of CUSTOM_NODE_NAMES) {
150 expect(summarizeNode(headlessSchema, name)).toEqual(summarizeNode(clientSchema, name));
151 }
152 });
153 
154 it("keeps shared marks aligned with the client adapters", () => {
155 for (const name of MARK_NAMES) {
156 expect(summarizeMark(headlessSchema, name)).toEqual(summarizeMark(clientSchema, name));
157 }
158 });
159 
160 it("keeps schema render specs aligned for custom nodes and bid attrs", () => {
161 const blockNodes = [
162 { type: "paragraph", attrs: { bid: "paragraph-bid" }, content: [{ type: "text", text: "Paragraph" }] },
163 { type: "callout", attrs: { bid: "callout-bid", kind: "tip" }, content: [{ type: "paragraph" }] },
164 { type: "codeBlock", attrs: { bid: "code-bid", language: "typescript" }, content: [{ type: "text", text: "x" }] },
165 { type: "image", attrs: { bid: "image-bid", src: "/uploads/a.png", align: "center", width: 320 } },
166 { type: "image", attrs: { bid: "dimmed-bid", src: "/uploads/b.png", brightness: 75 } },
167 {
168 type: "details",
169 attrs: { bid: "details-bid", open: true },
170 content: [{ type: "detailsSummary" }, { type: "detailsContent", content: [{ type: "paragraph" }] }],
171 },
172 ];
173 
174 for (const node of blockNodes) {
175 expect(toDomSpec(headlessSchema, node)).toEqual(toDomSpec(clientSchema, node));
176 }
177 
178 const mention = { type: "pageMention", attrs: { pageId: "page-1" } };
179 expect(inlineToDomSpec(headlessSchema, mention)).toEqual(inlineToDomSpec(clientSchema, mention));
180 expect(JSON.stringify(toDomSpec(headlessSchema, blockNodes[0]!))).toContain("data-bid");
181 });
182});