@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/LICENSE +201 -0
- package/lib/MarkdownEditors.d.ts +13 -0
- package/lib/MarkdownEditors.d.ts.map +1 -0
- package/lib/MarkdownEditors.js +12 -0
- package/lib/MarkdownEditors.js.map +1 -0
- package/lib/MarkdownRichTextEditor.d.ts +29 -0
- package/lib/MarkdownRichTextEditor.d.ts.map +1 -0
- package/lib/MarkdownRichTextEditor.js +134 -0
- package/lib/MarkdownRichTextEditor.js.map +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -0
- package/lib/markdown.d.ts +30 -0
- package/lib/markdown.d.ts.map +1 -0
- package/lib/markdown.js +347 -0
- package/lib/markdown.js.map +1 -0
- package/lib/types.d.ts +30 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/vertesia-rich-text.js +230 -0
- package/lib/vertesia-rich-text.js.map +1 -0
- package/package.json +64 -0
- package/src/MarkdownEditors.tsx +48 -0
- package/src/MarkdownRichTextEditor.tsx +197 -0
- package/src/index.ts +35 -0
- package/src/markdown.test.ts +323 -0
- package/src/markdown.tsx +466 -0
- package/src/types.ts +35 -0
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
|
+

|
|
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('');
|
|
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
|
+
});
|