@vertesia/rich-text 1.5.0-dev.20260725.083715Z

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/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@vertesia/rich-text",
3
+ "version": "1.5.0-dev.20260725.083715Z",
4
+ "description": "Markdown-first rich-text editing primitives for Vertesia",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "files": [
9
+ "lib",
10
+ "src"
11
+ ],
12
+ "license": "Apache-2.0",
13
+ "homepage": "https://docs.vertesiahq.com",
14
+ "keywords": [
15
+ "vertesia",
16
+ "markdown",
17
+ "rich-text",
18
+ "tiptap"
19
+ ],
20
+ "dependencies": {
21
+ "@tiptap/core": "^3.27.3",
22
+ "@tiptap/extension-code-block": "^3.27.3",
23
+ "@tiptap/extension-image": "^3.27.3",
24
+ "@tiptap/extension-link": "^3.27.3",
25
+ "@tiptap/extension-table": "^3.27.3",
26
+ "@tiptap/markdown": "^3.27.3",
27
+ "@tiptap/pm": "^3.27.3",
28
+ "@tiptap/react": "^3.27.3",
29
+ "@tiptap/starter-kit": "^3.27.3",
30
+ "marked": "^17.0.1",
31
+ "react": "19.2.7",
32
+ "react-dom": "19.2.7"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^24.13.3",
36
+ "@types/react": "19.2.17",
37
+ "@types/react-dom": "19.2.3",
38
+ "rolldown": "1.2.0",
39
+ "typescript": "^6.0.3",
40
+ "vitest": "^4.1.9",
41
+ "@vertesia/tsconfig": "0.1.0"
42
+ },
43
+ "exports": {
44
+ ".": {
45
+ "types": "./lib/index.d.ts",
46
+ "default": "./lib/index.js"
47
+ }
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/vertesia/composableai.git",
52
+ "directory": "packages/rich-text"
53
+ },
54
+ "gitHead": "57bcc2f27dd17b3387f27c18e89329c4c37f98ac",
55
+ "scripts": {
56
+ "clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
57
+ "clean": "rimraf ./node_modules ./lib ./tsconfig.tsbuildinfo",
58
+ "build": "pnpm run clean:lib && tsc -p tsconfig.json && pnpm exec rolldown -c rolldown.config.js",
59
+ "lint": "biome lint src",
60
+ "lint:fix": "biome lint --write src",
61
+ "test": "vitest run",
62
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit"
63
+ }
64
+ }
@@ -0,0 +1,48 @@
1
+ import { MarkdownRichTextEditor, type MarkdownRichTextEditorProps } from './MarkdownRichTextEditor.js';
2
+
3
+ interface MarkdownEditorShellProps extends Omit<MarkdownRichTextEditorProps, 'className'> {
4
+ className?: string;
5
+ contentClassName?: string;
6
+ }
7
+
8
+ export interface MarkdownComponentEditorProps extends MarkdownEditorShellProps {}
9
+
10
+ export interface MarkdownDocumentEditorProps extends MarkdownEditorShellProps {}
11
+
12
+ function mergeClasses(...values: Array<string | undefined>): string {
13
+ return values.filter(Boolean).join(' ');
14
+ }
15
+
16
+ export function MarkdownComponentEditor({
17
+ className,
18
+ contentClassName,
19
+ onChangeDebounceMs = 0,
20
+ ...props
21
+ }: MarkdownComponentEditorProps) {
22
+ return (
23
+ <div className={mergeClasses('vertesia-markdown-component-editor', className)}>
24
+ <MarkdownRichTextEditor
25
+ {...props}
26
+ onChangeDebounceMs={onChangeDebounceMs}
27
+ className={mergeClasses('vertesia-markdown-component-editor-content', contentClassName)}
28
+ />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ export function MarkdownDocumentEditor({
34
+ className,
35
+ contentClassName,
36
+ onChangeDebounceMs = 150,
37
+ ...props
38
+ }: MarkdownDocumentEditorProps) {
39
+ return (
40
+ <div className={mergeClasses('vertesia-markdown-document-editor', className)}>
41
+ <MarkdownRichTextEditor
42
+ {...props}
43
+ onChangeDebounceMs={onChangeDebounceMs}
44
+ className={mergeClasses('vertesia-markdown-document-editor-content', contentClassName)}
45
+ />
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,197 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import { EditorContent, useEditor } from '@tiptap/react';
3
+ import { type RefObject, useEffect, useMemo, useRef } from 'react';
4
+ import { createVertesiaMarkdownExtensions, type VertesiaMarkdownKitOptions } from './markdown.js';
5
+ import type { RichTextRenderers } from './types.js';
6
+
7
+ export type ExternalValueSyncPolicy = 'always' | 'when-blurred' | 'manual';
8
+
9
+ export interface MarkdownRichTextEditorProps extends VertesiaMarkdownKitOptions {
10
+ value: string;
11
+ onChange?: (markdown: string) => void;
12
+ onEditor?: (editor: Editor | null) => void;
13
+ editable?: boolean;
14
+ className?: string;
15
+ editorClassName?: string;
16
+ ariaLabel?: string;
17
+ autoFocus?: boolean;
18
+ onFocusChange?: (focused: boolean) => void;
19
+ /**
20
+ * Controls how a new value prop is applied after the editor mounts.
21
+ *
22
+ * - `when-blurred` avoids resetting selection and scroll while the user types.
23
+ * - `always` applies the external value immediately.
24
+ * - `manual` leaves reconciliation to the host.
25
+ *
26
+ * Transaction-level merging remains the host's responsibility for true concurrent editing.
27
+ */
28
+ externalValueSync?: ExternalValueSyncPolicy;
29
+ /** Delay full-document Markdown serialization after an edit. Zero emits synchronously. */
30
+ onChangeDebounceMs?: number;
31
+ }
32
+
33
+ function createStableRendererProxies(renderersRef: RefObject<RichTextRenderers>): RichTextRenderers {
34
+ return {
35
+ codeBlock(props) {
36
+ const Renderer = renderersRef.current?.codeBlock;
37
+ return Renderer ? <Renderer {...props} /> : null;
38
+ },
39
+ image(props) {
40
+ const Renderer = renderersRef.current?.image;
41
+ return Renderer ? <Renderer {...props} /> : null;
42
+ },
43
+ link(props) {
44
+ const Renderer = renderersRef.current?.link;
45
+ return Renderer ? <Renderer {...props} /> : props.children;
46
+ },
47
+ opaqueBlock(props) {
48
+ const Renderer = renderersRef.current?.opaqueBlock;
49
+ return Renderer ? <Renderer {...props} /> : null;
50
+ },
51
+ };
52
+ }
53
+
54
+ export function setEditorMarkdown(editor: Editor, markdown: string, emitUpdate = false): void {
55
+ editor.commands.setContent(markdown, { contentType: 'markdown', emitUpdate });
56
+ }
57
+
58
+ export function MarkdownRichTextEditor({
59
+ value,
60
+ onChange,
61
+ onEditor,
62
+ editable = true,
63
+ className,
64
+ editorClassName,
65
+ ariaLabel,
66
+ autoFocus = false,
67
+ onFocusChange,
68
+ externalValueSync = 'when-blurred',
69
+ onChangeDebounceMs = 0,
70
+ codeBlock,
71
+ image,
72
+ link,
73
+ opaqueBlock,
74
+ tables = true,
75
+ opaqueBlocks = true,
76
+ }: MarkdownRichTextEditorProps) {
77
+ const renderersRef = useRef<RichTextRenderers>({ codeBlock, image, link, opaqueBlock });
78
+ renderersRef.current = { codeBlock, image, link, opaqueBlock };
79
+ const stableRenderers = useMemo(() => createStableRendererProxies(renderersRef), []);
80
+ const onChangeRef = useRef(onChange);
81
+ const onFocusChangeRef = useRef(onFocusChange);
82
+ const debounceRef = useRef(onChangeDebounceMs);
83
+ const externalValueSyncRef = useRef(externalValueSync);
84
+ const lastSerializedMarkdownRef = useRef(value);
85
+ const pendingExternalValueRef = useRef<string | undefined>(undefined);
86
+ const changeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
87
+
88
+ onChangeRef.current = onChange;
89
+ onFocusChangeRef.current = onFocusChange;
90
+ debounceRef.current = onChangeDebounceMs;
91
+ externalValueSyncRef.current = externalValueSync;
92
+
93
+ const extensions = useMemo(
94
+ () =>
95
+ createVertesiaMarkdownExtensions({
96
+ ...stableRenderers,
97
+ tables,
98
+ opaqueBlocks,
99
+ }),
100
+ [opaqueBlocks, stableRenderers, tables],
101
+ );
102
+
103
+ const editor = useEditor(
104
+ {
105
+ extensions,
106
+ content: value,
107
+ contentType: 'markdown',
108
+ editable,
109
+ autofocus: autoFocus,
110
+ immediatelyRender: false,
111
+ editorProps: {
112
+ attributes: {
113
+ class: editorClassName || '',
114
+ role: 'textbox',
115
+ 'aria-multiline': 'true',
116
+ ...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
117
+ },
118
+ },
119
+ onUpdate: ({ editor: updatedEditor }) => {
120
+ if (changeTimeoutRef.current !== undefined) clearTimeout(changeTimeoutRef.current);
121
+
122
+ const emitMarkdown = () => {
123
+ changeTimeoutRef.current = undefined;
124
+ const markdown = updatedEditor.getMarkdown();
125
+ lastSerializedMarkdownRef.current = markdown;
126
+ onChangeRef.current?.(markdown);
127
+ };
128
+ const delay = Math.max(0, debounceRef.current);
129
+ if (delay === 0) {
130
+ emitMarkdown();
131
+ } else {
132
+ changeTimeoutRef.current = setTimeout(emitMarkdown, delay);
133
+ }
134
+ },
135
+ onFocus: () => onFocusChangeRef.current?.(true),
136
+ onBlur: ({ editor: blurredEditor }) => {
137
+ const pendingValue = pendingExternalValueRef.current;
138
+ if (pendingValue !== undefined && externalValueSyncRef.current === 'when-blurred') {
139
+ pendingExternalValueRef.current = undefined;
140
+ if (changeTimeoutRef.current !== undefined) {
141
+ clearTimeout(changeTimeoutRef.current);
142
+ changeTimeoutRef.current = undefined;
143
+ }
144
+ setEditorMarkdown(blurredEditor, pendingValue);
145
+ lastSerializedMarkdownRef.current = pendingValue;
146
+ }
147
+ onFocusChangeRef.current?.(false);
148
+ },
149
+ },
150
+ [extensions],
151
+ );
152
+
153
+ useEffect(() => {
154
+ editor?.setEditable(editable);
155
+ }, [editable, editor]);
156
+
157
+ useEffect(() => {
158
+ if (!editor || value === lastSerializedMarkdownRef.current) {
159
+ pendingExternalValueRef.current = undefined;
160
+ return;
161
+ }
162
+ if (externalValueSync === 'manual') return;
163
+ if (externalValueSync === 'when-blurred' && editor.isFocused) {
164
+ pendingExternalValueRef.current = value;
165
+ return;
166
+ }
167
+
168
+ pendingExternalValueRef.current = undefined;
169
+ if (changeTimeoutRef.current !== undefined) {
170
+ clearTimeout(changeTimeoutRef.current);
171
+ changeTimeoutRef.current = undefined;
172
+ }
173
+ setEditorMarkdown(editor, value);
174
+ lastSerializedMarkdownRef.current = value;
175
+ }, [editor, externalValueSync, value]);
176
+
177
+ useEffect(() => {
178
+ onEditor?.(editor);
179
+ return () => onEditor?.(null);
180
+ }, [editor, onEditor]);
181
+
182
+ useEffect(
183
+ () => () => {
184
+ if (changeTimeoutRef.current === undefined) return;
185
+ clearTimeout(changeTimeoutRef.current);
186
+ changeTimeoutRef.current = undefined;
187
+ if (!editor) return;
188
+
189
+ const markdown = editor.getMarkdown();
190
+ lastSerializedMarkdownRef.current = markdown;
191
+ onChangeRef.current?.(markdown);
192
+ },
193
+ [editor],
194
+ );
195
+
196
+ return <EditorContent editor={editor} className={className} />;
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type { Editor } from '@tiptap/core';
2
+ export {
3
+ MarkdownComponentEditor,
4
+ type MarkdownComponentEditorProps,
5
+ MarkdownDocumentEditor,
6
+ type MarkdownDocumentEditorProps,
7
+ } from './MarkdownEditors.js';
8
+ export {
9
+ type ExternalValueSyncPolicy,
10
+ MarkdownRichTextEditor,
11
+ type MarkdownRichTextEditorProps,
12
+ setEditorMarkdown,
13
+ } from './MarkdownRichTextEditor.js';
14
+ export {
15
+ type CreateMarkdownEditorOptions,
16
+ createMarkdownEditor,
17
+ createVertesiaMarkdownExtensions,
18
+ getMarkdownCompatibility,
19
+ isMarkdownSourcePreserving,
20
+ isVertesiaWidgetLanguage,
21
+ type MarkdownCompatibility,
22
+ parseMarkdown,
23
+ roundTripMarkdown,
24
+ serializeMarkdown,
25
+ VERTESIA_WIDGET_LANGUAGES,
26
+ type VertesiaMarkdownKitOptions,
27
+ } from './markdown.js';
28
+ export type {
29
+ OpaqueMarkdownKind,
30
+ RichTextCodeBlockRendererProps,
31
+ RichTextImageRendererProps,
32
+ RichTextLinkRendererProps,
33
+ RichTextOpaqueBlockRendererProps,
34
+ RichTextRenderers,
35
+ } from './types.js';
@@ -0,0 +1,323 @@
1
+ import type { Editor, JSONContent } from '@tiptap/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ createMarkdownEditor,
5
+ getMarkdownCompatibility,
6
+ isMarkdownSourcePreserving,
7
+ isVertesiaWidgetLanguage,
8
+ parseMarkdown,
9
+ roundTripMarkdown,
10
+ } from './markdown.js';
11
+
12
+ const REPRESENTATIVE_DOCUMENT = `# Aurora launch brief
13
+
14
+ The **Aurora** release includes [the canonical document](store:6a56458923f5c40964a069e5),
15
+ an [artifact with spaces](<artifact:files/review copy.pdf>), and inline \`code\`.
16
+
17
+ ![Architecture](<artifact:files/architecture v2.png> "Aurora architecture")
18
+
19
+ ## Goals
20
+
21
+ - Cut review time by 60%.
22
+ - Preserve source-page provenance.
23
+ - Keep stable anchors for comments.
24
+
25
+ | Tier | Pages | Support |
26
+ | :--- | ---: | :---: |
27
+ | Starter | 1,000 | Community |
28
+ | Scale | Unlimited | Dedicated |
29
+
30
+ > Review changes before publishing.
31
+
32
+ \`\`\`chart
33
+ {"$schema":"https://vega.github.io/schema/vega-lite/v6.json","mark":"bar","encoding":{}}
34
+ \`\`\`
35
+
36
+ \`\`\`mermaid
37
+ flowchart LR
38
+ Draft --> Review --> Publish
39
+ \`\`\`
40
+
41
+ \`\`\`expand:table
42
+ files/pricing.csv
43
+ \`\`\`
44
+ `;
45
+
46
+ const OPAQUE_WIDGET_DOCUMENT = `# Layout and notation
47
+
48
+ :::columns
49
+ :::column{width=50%}
50
+ Left **column**.
51
+ :::
52
+ :::column{width=50%}
53
+ Right column.
54
+ :::
55
+ :::
56
+
57
+ $$
58
+ E = mc^2
59
+ $$
60
+
61
+ > [!NOTE]
62
+ > This alert must retain its Vertesia rendering semantics.
63
+ `;
64
+
65
+ const TASK_LIST_DOCUMENT = `# Release checklist
66
+
67
+ - [ ] Publish the migration guide
68
+ - [x] Verify the conformance suite
69
+ - [ ] Run the browser smoke test
70
+ `;
71
+
72
+ const FRONTMATTER_DOCUMENT = `---
73
+ title: Aurora launch brief
74
+ tags:
75
+ - launch
76
+ - aurora
77
+ ---
78
+
79
+ # Overview
80
+
81
+ Keep the frontmatter exact.
82
+ `;
83
+
84
+ function collectNodes(content: JSONContent, type: string): JSONContent[] {
85
+ const matches: JSONContent[] = [];
86
+ if (content.type === type) matches.push(content);
87
+ for (const child of content.content || []) matches.push(...collectNodes(child, type));
88
+ return matches;
89
+ }
90
+
91
+ function findFirstTextPosition(editor: Editor, nodeType: string): number {
92
+ let textPosition: number | undefined;
93
+ editor.state.doc.descendants((node, position) => {
94
+ if (textPosition === undefined && node.type.name === nodeType) textPosition = position + 2;
95
+ });
96
+ if (textPosition === undefined) throw new Error(`Expected a ${nodeType} node`);
97
+ return textPosition;
98
+ }
99
+
100
+ describe('Vertesia Markdown conformance', () => {
101
+ it('preserves the document structure through parse and serialize', () => {
102
+ const parsed = parseMarkdown(REPRESENTATIVE_DOCUMENT);
103
+ const serialized = roundTripMarkdown(REPRESENTATIVE_DOCUMENT);
104
+
105
+ expect(parseMarkdown(serialized)).toEqual(parsed);
106
+ expect(roundTripMarkdown(serialized)).toBe(serialized);
107
+ });
108
+
109
+ it('preserves custom links, artifact images, tables, and widget code blocks', () => {
110
+ const parsed = parseMarkdown(REPRESENTATIVE_DOCUMENT);
111
+ const serialized = roundTripMarkdown(REPRESENTATIVE_DOCUMENT);
112
+ const codeBlocks = collectNodes(parsed, 'codeBlock');
113
+ const images = collectNodes(parsed, 'image');
114
+ const links = collectNodes(parsed, 'text').flatMap((node) => node.marks || []);
115
+
116
+ expect(codeBlocks.map((node) => node.attrs?.language)).toEqual(['chart', 'mermaid', 'expand:table']);
117
+ expect(images[0]?.attrs).toMatchObject({
118
+ src: 'artifact:files/architecture v2.png',
119
+ alt: 'Architecture',
120
+ title: 'Aurora architecture',
121
+ });
122
+ expect(links.some((mark) => mark.attrs?.href === 'store:6a56458923f5c40964a069e5')).toBe(true);
123
+ expect(links.some((mark) => mark.attrs?.href === 'artifact:files/review copy.pdf')).toBe(true);
124
+ expect(collectNodes(parsed, 'table')).toHaveLength(1);
125
+ expect(serialized).toContain('[artifact with spaces](<artifact:files/review copy.pdf>)');
126
+ expect(serialized).toContain('![Architecture](<artifact:files/architecture v2.png> "Aurora architecture")');
127
+ });
128
+
129
+ it('round-trips table row and column insertions without changing unrelated content', () => {
130
+ const source = `Before the table.
131
+
132
+ | Name | Value |
133
+ | --- | ---: |
134
+ | Aurora | 1 |
135
+
136
+ After the table.
137
+ `;
138
+ const editor = createMarkdownEditor({ content: source });
139
+ try {
140
+ editor.commands.setTextSelection(findFirstTextPosition(editor, 'tableCell'));
141
+
142
+ expect(editor.commands.addRowAfter()).toBe(true);
143
+ expect(editor.commands.addColumnAfter()).toBe(true);
144
+
145
+ const serialized = editor.getMarkdown();
146
+ const parsed = parseMarkdown(serialized);
147
+ const table = collectNodes(parsed, 'table')[0];
148
+
149
+ expect(table?.content).toHaveLength(3);
150
+ expect(table?.content?.every((row) => row.content?.length === 3)).toBe(true);
151
+ expect(serialized).toContain('Before the table.');
152
+ expect(serialized).toContain('After the table.');
153
+ expect(roundTripMarkdown(serialized)).toBe(serialized);
154
+ } finally {
155
+ editor.destroy();
156
+ }
157
+ });
158
+
159
+ it('preserves a valid table when deleting its last row or column is not possible', () => {
160
+ const editor = createMarkdownEditor({
161
+ content: `Before.
162
+
163
+ | Name | Value |
164
+ | --- | --- |
165
+ | Aurora | 1 |
166
+
167
+ After.
168
+ `,
169
+ });
170
+ try {
171
+ editor.commands.setTextSelection(findFirstTextPosition(editor, 'tableCell'));
172
+ expect(editor.commands.deleteColumn()).toBe(true);
173
+ expect(editor.commands.deleteColumn()).toBe(false);
174
+ expect(editor.commands.deleteRow()).toBe(true);
175
+ expect(editor.commands.deleteRow()).toBe(false);
176
+
177
+ const serialized = editor.getMarkdown();
178
+ const table = collectNodes(parseMarkdown(serialized), 'table')[0];
179
+ expect(table?.content).toHaveLength(1);
180
+ expect(table?.content?.[0]?.content).toHaveLength(1);
181
+ expect(serialized).toContain('Before.');
182
+ expect(serialized).toContain('After.');
183
+ expect(roundTripMarkdown(serialized)).toBe(serialized);
184
+ } finally {
185
+ editor.destroy();
186
+ }
187
+ });
188
+
189
+ it('deletes a table without disturbing an adjacent opaque block', () => {
190
+ const opaqueBlock = `:::columns
191
+ :::column{width=100%}
192
+ Keep this **opaque block** intact.
193
+ :::
194
+ :::`;
195
+ const editor = createMarkdownEditor({
196
+ content: `Before.
197
+
198
+ | Name | Value |
199
+ | --- | --- |
200
+ | Aurora | 1 |
201
+
202
+ ${opaqueBlock}
203
+
204
+ After.
205
+ `,
206
+ });
207
+ try {
208
+ editor.commands.setTextSelection(findFirstTextPosition(editor, 'tableCell'));
209
+ expect(editor.commands.deleteTable()).toBe(true);
210
+
211
+ const serialized = editor.getMarkdown();
212
+ const parsed = parseMarkdown(serialized);
213
+ expect(collectNodes(parsed, 'table')).toHaveLength(0);
214
+ expect(collectNodes(parsed, 'opaqueMarkdownBlock')).toHaveLength(1);
215
+ expect(serialized).toContain(opaqueBlock);
216
+ expect(serialized).toContain('Before.');
217
+ expect(serialized).toContain('After.');
218
+ expect(roundTripMarkdown(serialized)).toBe(serialized);
219
+ } finally {
220
+ editor.destroy();
221
+ }
222
+ });
223
+
224
+ it('keeps unsupported block widgets as exact opaque Markdown nodes', () => {
225
+ const parsed = parseMarkdown(OPAQUE_WIDGET_DOCUMENT);
226
+ const opaqueNodes = collectNodes(parsed, 'opaqueMarkdownBlock');
227
+ const serialized = roundTripMarkdown(OPAQUE_WIDGET_DOCUMENT);
228
+
229
+ expect(opaqueNodes.map((node) => node.attrs?.kind)).toEqual(['directive', 'display-math', 'github-alert']);
230
+ for (const node of opaqueNodes) {
231
+ expect(serialized).toContain(node.attrs?.raw);
232
+ }
233
+ expect(parseMarkdown(serialized)).toEqual(parsed);
234
+ expect(roundTripMarkdown(serialized)).toBe(serialized);
235
+ });
236
+
237
+ it('preserves task lists as opaque Markdown until task editing is supported', () => {
238
+ const parsed = parseMarkdown(TASK_LIST_DOCUMENT);
239
+ const taskLists = collectNodes(parsed, 'opaqueMarkdownBlock').filter(
240
+ (node) => node.attrs?.kind === 'task-list',
241
+ );
242
+ const serialized = roundTripMarkdown(TASK_LIST_DOCUMENT);
243
+
244
+ expect(taskLists).toHaveLength(1);
245
+ expect(taskLists[0]?.attrs?.raw).toContain('- [ ] Publish the migration guide');
246
+ expect(serialized).toContain('- [x] Verify the conformance suite');
247
+ expect(parseMarkdown(serialized)).toEqual(parsed);
248
+ });
249
+
250
+ it('preserves YAML frontmatter as an exact opaque Markdown node', () => {
251
+ const parsed = parseMarkdown(FRONTMATTER_DOCUMENT);
252
+ const frontmatter = collectNodes(parsed, 'opaqueMarkdownBlock').find(
253
+ (node) => node.attrs?.kind === 'frontmatter',
254
+ );
255
+ const serialized = roundTripMarkdown(FRONTMATTER_DOCUMENT);
256
+
257
+ expect(frontmatter?.attrs?.raw).toBe(`---
258
+ title: Aurora launch brief
259
+ tags:
260
+ - launch
261
+ - aurora
262
+ ---`);
263
+ expect(serialized).toContain(frontmatter?.attrs?.raw);
264
+ expect(serialized).not.toContain('## title: Aurora launch brief');
265
+ expect(parseMarkdown(serialized)).toEqual(parsed);
266
+ });
267
+
268
+ it('does not mistake horizontal rules inside a document for frontmatter', () => {
269
+ const parsed = parseMarkdown(`# Before
270
+
271
+ ---
272
+
273
+ Content between horizontal rules.
274
+
275
+ ---
276
+
277
+ # After`);
278
+ const frontmatter = collectNodes(parsed, 'opaqueMarkdownBlock').filter(
279
+ (node) => node.attrs?.kind === 'frontmatter',
280
+ );
281
+
282
+ expect(frontmatter).toHaveLength(0);
283
+ expect(collectNodes(parsed, 'horizontalRule')).toHaveLength(2);
284
+ });
285
+
286
+ it('detects source syntax that rich-text serialization would normalize', () => {
287
+ expect(isMarkdownSourcePreserving('# Canonical heading')).toBe(true);
288
+ expect(isMarkdownSourcePreserving('Setext heading\n==============')).toBe(false);
289
+ expect(getMarkdownCompatibility('# Canonical heading')).toBe('exact');
290
+ expect(getMarkdownCompatibility('Setext heading\n==============')).toBe('normalized');
291
+ expect(getMarkdownCompatibility(REPRESENTATIVE_DOCUMENT)).toBe('normalized');
292
+ });
293
+
294
+ it('registers node and mark views for every important Vertesia widget shape', () => {
295
+ const editor = createMarkdownEditor({ content: REPRESENTATIVE_DOCUMENT, editable: false });
296
+ try {
297
+ const extensions = new Map(
298
+ editor.extensionManager.extensions.map((extension) => [extension.name, extension]),
299
+ );
300
+ const hook = (extensionName: string, hookName: string): unknown => {
301
+ const config = extensions.get(extensionName)?.config as Record<string, unknown> | undefined;
302
+ return config?.[hookName];
303
+ };
304
+
305
+ expect(hook('codeBlock', 'addNodeView')).toBeTypeOf('function');
306
+ expect(hook('image', 'addNodeView')).toBeTypeOf('function');
307
+ expect(hook('link', 'addMarkView')).toBeTypeOf('function');
308
+ expect(hook('opaqueMarkdownBlock', 'addNodeView')).toBeTypeOf('function');
309
+ } finally {
310
+ editor.destroy();
311
+ }
312
+ });
313
+
314
+ it('classifies only renderable Vertesia code fences as widgets', () => {
315
+ expect(isVertesiaWidgetLanguage('chart')).toBe(true);
316
+ expect(isVertesiaWidgetLanguage('vega-lite')).toBe(true);
317
+ expect(isVertesiaWidgetLanguage('mermaid')).toBe(true);
318
+ expect(isVertesiaWidgetLanguage('mockup')).toBe(true);
319
+ expect(isVertesiaWidgetLanguage('expand:fusion-fragment')).toBe(true);
320
+ expect(isVertesiaWidgetLanguage('typescript')).toBe(false);
321
+ expect(isVertesiaWidgetLanguage(undefined)).toBe(false);
322
+ });
323
+ });