├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── feature_request.md
│ └── other.md
└── workflows
│ ├── deploy.yml
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── __tests__
├── Markdown.spec.js
├── __snapshots__
│ ├── parse.spec.js.snap
│ └── serialize.spec.js.snap
├── clipboard.spec.js
├── parse.spec.js
├── serialize.spec.js
├── tight-lists.spec.js
├── util.spec.js
└── utils
│ ├── dom.js
│ ├── editor.js
│ ├── index.js
│ ├── parse.js
│ ├── serialize.js
│ ├── setup-dom.js
│ └── setup.js
├── babel.config.js
├── docs
└── migration.md
├── example
├── .gitignore
├── index.html
├── package.json
├── src
│ ├── App.vue
│ ├── app.scss
│ ├── components
│ │ ├── Editor.vue
│ │ └── MenuBar.vue
│ ├── data
│ │ ├── content.md
│ │ └── large-content.js
│ ├── extensions
│ │ ├── container.js
│ │ └── highlight.js
│ └── main.js
└── vite.config.js
├── index.d.ts
├── package-lock.json
├── package.json
├── src
├── Markdown.js
├── extensions
│ ├── index.js
│ ├── marks
│ │ ├── bold.js
│ │ ├── code.js
│ │ ├── html.js
│ │ ├── italic.js
│ │ ├── link.js
│ │ └── strike.js
│ ├── nodes
│ │ ├── blockquote.js
│ │ ├── bullet-list.js
│ │ ├── code-block.js
│ │ ├── hard-break.js
│ │ ├── heading.js
│ │ ├── horizontal-rule.js
│ │ ├── html.js
│ │ ├── image.js
│ │ ├── list-item.js
│ │ ├── ordered-list.js
│ │ ├── paragraph.js
│ │ ├── table.js
│ │ ├── task-item.js
│ │ ├── task-list.js
│ │ └── text.js
│ └── tiptap
│ │ ├── clipboard.js
│ │ └── tight-lists.js
├── index.js
├── parse
│ └── MarkdownParser.js
├── serialize
│ ├── MarkdownSerializer.js
│ └── state.js
└── util
│ ├── dom.js
│ ├── extensions.js
│ ├── markdown.js
│ └── prosemirror.js
└── vite.config.js
/.editorconfig:
--------------------------------------------------------------------------------
1 |
2 | root = true
3 |
4 | [*]
5 | end_of_line = lf
6 | charset = utf-8
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_style = space
10 | indent_size = 4
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | [*.{json,scss}]
16 | indent_size = 2
17 |
18 | [*.yml]
19 | indent_size = 2
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/other.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Other
3 | about: Prefer a discussion
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Please create a [discussion](https://github.com/aguingand/tiptap-markdown/discussions) for general question or troubleshooting.**
11 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ['main']
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 | - name: Set up Node
34 | uses: actions/setup-node@v3
35 | with:
36 | node-version: 18
37 | cache: 'npm'
38 | - name: Install dependencies
39 | run: npm install
40 | - name: Build
41 | run: npm run build:example
42 | - name: Setup Pages
43 | uses: actions/configure-pages@v3
44 | - name: Upload artifact
45 | uses: actions/upload-pages-artifact@v1
46 | with:
47 | # Upload dist repository
48 | path: './example/dist'
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v1
52 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | push:
4 | branches: ['main']
5 | pull_request:
6 | branches: ['main']
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 | - name: Setup Node
14 | uses: actions/setup-node@v2
15 | with:
16 | node-version: 18
17 | cache: 'npm'
18 | - name: Install dependencies
19 | run: npm install
20 | - name: Test
21 | run: npm run test
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
4 | .DS_Store
5 | *.local
6 | .idea
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021, Antoine Guingand
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!WARNING]
2 | As Tiptap have now a solution for markdown (paid [Conversion extension](https://next.tiptap.dev/docs/conversion/import-export/markdown/editor-extensions) and more markdown handling [announced for v3](https://next.tiptap.dev/docs/resources/whats-new#whats-next-in-tiptap-3x)). I don't plan to release [v1](https://github.com/aguingand/tiptap-markdown/pull/67) nor addressing current issues / PR. Feel free to fork the project if you need.
3 |
4 | # Tiptap markdown
5 |
6 | The markdown extension for [Tiptap editor](https://www.tiptap.dev/).
7 |
8 | ## Installation
9 |
10 | ```bash
11 | npm install tiptap-markdown
12 | ```
13 |
14 | ### Requirements
15 | Supports all frameworks handled by Tiptap (Vue 2, Vue 3, React, [see full list](https://www.tiptap.dev/installation#integration-guides)...)
16 |
17 | ## Usage
18 | Basic example:
19 |
20 | ```js
21 | import { Editor } from '@tiptap/core';
22 | import StarterKit from '@tiptap/starter-kit';
23 | import { Markdown } from 'tiptap-markdown';
24 |
25 | const editor = new Editor({
26 | content: "# Title",
27 | extensions: [
28 | StarterKit,
29 | Markdown,
30 | ],
31 | });
32 | const markdownOutput = editor.storage.markdown.getMarkdown();
33 | ```
34 |
35 | ## API
36 |
37 | ### Options
38 | Default options:
39 | ```js
40 | Markdown.configure({
41 | html: true, // Allow HTML input/output
42 | tightLists: true, // No
inside
in markdown output
43 | tightListClass: 'tight', // Add class to allowing you to remove margins when tight
44 | bulletListMarker: '-', //
- prefix in markdown output
45 | linkify: false, // Create links from "https://..." text
46 | breaks: false, // New lines (\n) in markdown input are converted to
47 | transformPastedText: false, // Allow to paste markdown text in the editor
48 | transformCopiedText: false, // Copied text is transformed to markdown
49 | })
50 | ```
51 |
52 | ### Methods
53 | ```js
54 | editor.commands.setContent('**test**') // setContent supports markdown format
55 | editor.storage.markdown.getMarkdown(); // get current content as markdown
56 | ```
57 |
58 | ### Custom extensions
59 | See [examples](https://github.com/aguingand/tiptap-markdown/tree/main/example/src/extensions).
60 | Check out prosemirror-markdown [default serializer](https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L66) for examples of serialize config. Check out markdown-it [plugins](https://github.com/markdown-it/markdown-it#syntax-extensions) for parsing.
61 |
62 | ## Contributing
63 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
64 |
65 | Please make sure to update tests as appropriate.
66 |
67 | ## License
68 | The MIT License (MIT). Please see [License File](LICENSE) for more information.
69 |
--------------------------------------------------------------------------------
/__tests__/Markdown.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { Editor } from "@tiptap/core";
3 | import StarterKit from "@tiptap/starter-kit";
4 | import { Markdown } from "../src/Markdown";
5 | import Bold from "@tiptap/extension-bold";
6 |
7 |
8 | describe('Markdown', () => {
9 | describe('commands', () => {
10 | test('setContent', () => {
11 | const editor = new Editor({
12 | extensions: [
13 | Markdown,
14 | StarterKit,
15 | ],
16 | });
17 | editor.commands.setContent('**example**');
18 | expect(editor.getHTML()).toBe('example
');
19 | })
20 | });
21 | test('getMarkdown', () => {
22 | const editor = new Editor({
23 | content: 'example
',
24 | extensions: [
25 | Markdown,
26 | StarterKit,
27 | ],
28 | });
29 | expect(editor.storage.markdown.getMarkdown()).toBe('**example**');
30 | });
31 | test('override default extension', () => {
32 | const editor = new Editor({
33 | content: 'example
',
34 | extensions: [
35 | Markdown,
36 | StarterKit.configure({ bold: false }),
37 | Bold.extend({
38 | addStorage() {
39 | return {
40 | markdown: {
41 | serialize: { open: '***', close: '***' },
42 | }
43 | }
44 | }
45 | }),
46 | ],
47 | });
48 | expect(editor.storage.markdown.getMarkdown()).toBe('***example***');
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/parse.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`parse > marks > bold > html 1`] = `
4 | [
5 | {
6 | "content": [
7 | {
8 | "marks": [
9 | {
10 | "type": "bold",
11 | },
12 | ],
13 | "text": "example",
14 | "type": "text",
15 | },
16 | ],
17 | "type": "paragraph",
18 | },
19 | ]
20 | `;
21 |
22 | exports[`parse > marks > bold > markdown 1`] = `
23 | [
24 | {
25 | "content": [
26 | {
27 | "marks": [
28 | {
29 | "type": "bold",
30 | },
31 | ],
32 | "text": "example",
33 | "type": "text",
34 | },
35 | ],
36 | "type": "paragraph",
37 | },
38 | ]
39 | `;
40 |
41 | exports[`parse > marks > code > html 1`] = `
42 | [
43 | {
44 | "content": [
45 | {
46 | "marks": [
47 | {
48 | "type": "code",
49 | },
50 | ],
51 | "text": "example",
52 | "type": "text",
53 | },
54 | ],
55 | "type": "paragraph",
56 | },
57 | ]
58 | `;
59 |
60 | exports[`parse > marks > code > markdown 1`] = `
61 | [
62 | {
63 | "content": [
64 | {
65 | "marks": [
66 | {
67 | "type": "code",
68 | },
69 | ],
70 | "text": "example",
71 | "type": "text",
72 | },
73 | ],
74 | "type": "paragraph",
75 | },
76 | ]
77 | `;
78 |
79 | exports[`parse > marks > italic > html 1`] = `
80 | [
81 | {
82 | "content": [
83 | {
84 | "marks": [
85 | {
86 | "type": "italic",
87 | },
88 | ],
89 | "text": "example",
90 | "type": "text",
91 | },
92 | ],
93 | "type": "paragraph",
94 | },
95 | ]
96 | `;
97 |
98 | exports[`parse > marks > italic > markdown 1`] = `
99 | [
100 | {
101 | "content": [
102 | {
103 | "marks": [
104 | {
105 | "type": "italic",
106 | },
107 | ],
108 | "text": "example",
109 | "type": "text",
110 | },
111 | ],
112 | "type": "paragraph",
113 | },
114 | ]
115 | `;
116 |
117 | exports[`parse > marks > link > html 1`] = `
118 | [
119 | {
120 | "content": [
121 | {
122 | "marks": [
123 | {
124 | "attrs": {
125 | "class": null,
126 | "href": "http://example.org",
127 | "rel": "noopener noreferrer nofollow",
128 | "target": "_blank",
129 | },
130 | "type": "link",
131 | },
132 | ],
133 | "text": "example",
134 | "type": "text",
135 | },
136 | ],
137 | "type": "paragraph",
138 | },
139 | ]
140 | `;
141 |
142 | exports[`parse > marks > link > markdown 1`] = `
143 | [
144 | {
145 | "content": [
146 | {
147 | "marks": [
148 | {
149 | "attrs": {
150 | "class": null,
151 | "href": "http://example.org",
152 | "rel": "noopener noreferrer nofollow",
153 | "target": "_blank",
154 | },
155 | "type": "link",
156 | },
157 | ],
158 | "text": "example",
159 | "type": "text",
160 | },
161 | ],
162 | "type": "paragraph",
163 | },
164 | ]
165 | `;
166 |
167 | exports[`parse > marks > link > markdown with linkify 1`] = `
168 | [
169 | {
170 | "content": [
171 | {
172 | "marks": [
173 | {
174 | "attrs": {
175 | "class": null,
176 | "href": "http://example.org",
177 | "rel": "noopener noreferrer nofollow",
178 | "target": "_blank",
179 | },
180 | "type": "link",
181 | },
182 | ],
183 | "text": "http://example.org",
184 | "type": "text",
185 | },
186 | ],
187 | "type": "paragraph",
188 | },
189 | ]
190 | `;
191 |
192 | exports[`parse > marks > strike > html 1`] = `
193 | [
194 | {
195 | "content": [
196 | {
197 | "marks": [
198 | {
199 | "type": "strike",
200 | },
201 | ],
202 | "text": "example",
203 | "type": "text",
204 | },
205 | ],
206 | "type": "paragraph",
207 | },
208 | ]
209 | `;
210 |
211 | exports[`parse > marks > strike > markdown 1`] = `
212 | [
213 | {
214 | "content": [
215 | {
216 | "marks": [
217 | {
218 | "type": "strike",
219 | },
220 | ],
221 | "text": "example",
222 | "type": "text",
223 | },
224 | ],
225 | "type": "paragraph",
226 | },
227 | ]
228 | `;
229 |
230 | exports[`parse > marks > text > link 1`] = `
231 | [
232 | {
233 | "content": [
234 | {
235 | "text": "http://example.org",
236 | "type": "text",
237 | },
238 | ],
239 | "type": "paragraph",
240 | },
241 | ]
242 | `;
243 |
244 | exports[`parse > marks > text > soft break 1`] = `
245 | [
246 | {
247 | "content": [
248 | {
249 | "text": "example1 example2",
250 | "type": "text",
251 | },
252 | ],
253 | "type": "paragraph",
254 | },
255 | ]
256 | `;
257 |
258 | exports[`parse > marks > text > text 1`] = `
259 | [
260 | {
261 | "content": [
262 | {
263 | "text": "example",
264 | "type": "text",
265 | },
266 | ],
267 | "type": "paragraph",
268 | },
269 | ]
270 | `;
271 |
272 | exports[`parse > nodes > bullet list > html 1`] = `
273 | [
274 | {
275 | "attrs": {
276 | "tight": true,
277 | },
278 | "content": [
279 | {
280 | "content": [
281 | {
282 | "content": [
283 | {
284 | "text": "example1",
285 | "type": "text",
286 | },
287 | ],
288 | "type": "paragraph",
289 | },
290 | ],
291 | "type": "listItem",
292 | },
293 | {
294 | "content": [
295 | {
296 | "content": [
297 | {
298 | "text": "example2",
299 | "type": "text",
300 | },
301 | ],
302 | "type": "paragraph",
303 | },
304 | ],
305 | "type": "listItem",
306 | },
307 | ],
308 | "type": "bulletList",
309 | },
310 | ]
311 | `;
312 |
313 | exports[`parse > nodes > bullet list > markdown marker \`*\` 1`] = `
314 | [
315 | {
316 | "attrs": {
317 | "tight": false,
318 | },
319 | "content": [
320 | {
321 | "content": [
322 | {
323 | "content": [
324 | {
325 | "text": "example1",
326 | "type": "text",
327 | },
328 | ],
329 | "type": "paragraph",
330 | },
331 | ],
332 | "type": "listItem",
333 | },
334 | {
335 | "content": [
336 | {
337 | "content": [
338 | {
339 | "text": "example2",
340 | "type": "text",
341 | },
342 | ],
343 | "type": "paragraph",
344 | },
345 | ],
346 | "type": "listItem",
347 | },
348 | ],
349 | "type": "bulletList",
350 | },
351 | ]
352 | `;
353 |
354 | exports[`parse > nodes > bullet list > markdown marker \`-\` 1`] = `
355 | [
356 | {
357 | "attrs": {
358 | "tight": false,
359 | },
360 | "content": [
361 | {
362 | "content": [
363 | {
364 | "content": [
365 | {
366 | "text": "example1",
367 | "type": "text",
368 | },
369 | ],
370 | "type": "paragraph",
371 | },
372 | ],
373 | "type": "listItem",
374 | },
375 | {
376 | "content": [
377 | {
378 | "content": [
379 | {
380 | "text": "example2",
381 | "type": "text",
382 | },
383 | ],
384 | "type": "paragraph",
385 | },
386 | ],
387 | "type": "listItem",
388 | },
389 | ],
390 | "type": "bulletList",
391 | },
392 | ]
393 | `;
394 |
395 | exports[`parse > nodes > code block > html 1`] = `
396 | [
397 | {
398 | "attrs": {
399 | "language": null,
400 | },
401 | "content": [
402 | {
403 | "text": "example",
404 | "type": "text",
405 | },
406 | ],
407 | "type": "codeBlock",
408 | },
409 | ]
410 | `;
411 |
412 | exports[`parse > nodes > code block > markdown 1`] = `
413 | [
414 | {
415 | "attrs": {
416 | "language": null,
417 | },
418 | "content": [
419 | {
420 | "text": "example",
421 | "type": "text",
422 | },
423 | ],
424 | "type": "codeBlock",
425 | },
426 | ]
427 | `;
428 |
429 | exports[`parse > nodes > fence > markdown 1`] = `
430 | [
431 | {
432 | "attrs": {
433 | "language": null,
434 | },
435 | "content": [
436 | {
437 | "text": "example",
438 | "type": "text",
439 | },
440 | ],
441 | "type": "codeBlock",
442 | },
443 | ]
444 | `;
445 |
446 | exports[`parse > nodes > fence > markdown with lang 1`] = `
447 | [
448 | {
449 | "attrs": {
450 | "language": "js",
451 | },
452 | "content": [
453 | {
454 | "text": "example",
455 | "type": "text",
456 | },
457 | ],
458 | "type": "codeBlock",
459 | },
460 | ]
461 | `;
462 |
463 | exports[`parse > nodes > hard break > html 1`] = `
464 | [
465 | {
466 | "content": [
467 | {
468 | "text": "example1",
469 | "type": "text",
470 | },
471 | {
472 | "type": "hardBreak",
473 | },
474 | {
475 | "text": "example2",
476 | "type": "text",
477 | },
478 | ],
479 | "type": "paragraph",
480 | },
481 | ]
482 | `;
483 |
484 | exports[`parse > nodes > hard break > markdown 1`] = `
485 | [
486 | {
487 | "content": [
488 | {
489 | "text": "example1",
490 | "type": "text",
491 | },
492 | {
493 | "type": "hardBreak",
494 | },
495 | {
496 | "text": "example2",
497 | "type": "text",
498 | },
499 | ],
500 | "type": "paragraph",
501 | },
502 | ]
503 | `;
504 |
505 | exports[`parse > nodes > hard break > markdown with breaks option + inline 1`] = `
506 | [
507 | {
508 | "text": "example1",
509 | "type": "text",
510 | },
511 | {
512 | "type": "hardBreak",
513 | },
514 | {
515 | "text": "example2",
516 | "type": "text",
517 | },
518 | ]
519 | `;
520 |
521 | exports[`parse > nodes > hard break > markdown with breaks option 1`] = `
522 | [
523 | {
524 | "content": [
525 | {
526 | "text": "example1",
527 | "type": "text",
528 | },
529 | {
530 | "type": "hardBreak",
531 | },
532 | {
533 | "text": "example2",
534 | "type": "text",
535 | },
536 | ],
537 | "type": "paragraph",
538 | },
539 | ]
540 | `;
541 |
542 | exports[`parse > nodes > headings > html h1 1`] = `
543 | [
544 | {
545 | "attrs": {
546 | "level": 1,
547 | },
548 | "content": [
549 | {
550 | "text": "example",
551 | "type": "text",
552 | },
553 | ],
554 | "type": "heading",
555 | },
556 | ]
557 | `;
558 |
559 | exports[`parse > nodes > headings > markdown h1 1`] = `
560 | [
561 | {
562 | "attrs": {
563 | "level": 1,
564 | },
565 | "content": [
566 | {
567 | "text": "example",
568 | "type": "text",
569 | },
570 | ],
571 | "type": "heading",
572 | },
573 | ]
574 | `;
575 |
576 | exports[`parse > nodes > headings > markdown h2 1`] = `
577 | [
578 | {
579 | "attrs": {
580 | "level": 2,
581 | },
582 | "content": [
583 | {
584 | "text": "example",
585 | "type": "text",
586 | },
587 | ],
588 | "type": "heading",
589 | },
590 | ]
591 | `;
592 |
593 | exports[`parse > nodes > headings > markdown h3 1`] = `
594 | [
595 | {
596 | "attrs": {
597 | "level": 3,
598 | },
599 | "content": [
600 | {
601 | "text": "example",
602 | "type": "text",
603 | },
604 | ],
605 | "type": "heading",
606 | },
607 | ]
608 | `;
609 |
610 | exports[`parse > nodes > headings > markdown h4 1`] = `
611 | [
612 | {
613 | "attrs": {
614 | "level": 4,
615 | },
616 | "content": [
617 | {
618 | "text": "example",
619 | "type": "text",
620 | },
621 | ],
622 | "type": "heading",
623 | },
624 | ]
625 | `;
626 |
627 | exports[`parse > nodes > headings > markdown h5 1`] = `
628 | [
629 | {
630 | "attrs": {
631 | "level": 5,
632 | },
633 | "content": [
634 | {
635 | "text": "example",
636 | "type": "text",
637 | },
638 | ],
639 | "type": "heading",
640 | },
641 | ]
642 | `;
643 |
644 | exports[`parse > nodes > headings > markdown h6 1`] = `
645 | [
646 | {
647 | "attrs": {
648 | "level": 6,
649 | },
650 | "content": [
651 | {
652 | "text": "example",
653 | "type": "text",
654 | },
655 | ],
656 | "type": "heading",
657 | },
658 | ]
659 | `;
660 |
661 | exports[`parse > nodes > hr > html 1`] = `
662 | [
663 | {
664 | "type": "horizontalRule",
665 | },
666 | ]
667 | `;
668 |
669 | exports[`parse > nodes > hr > markdown 1`] = `
670 | [
671 | {
672 | "type": "horizontalRule",
673 | },
674 | ]
675 | `;
676 |
677 | exports[`parse > nodes > html > block 1`] = `
678 | [
679 | {
680 | "content": [
681 | {
682 | "text": "example",
683 | "type": "text",
684 | },
685 | ],
686 | "type": "html-node",
687 | },
688 | ]
689 | `;
690 |
691 | exports[`parse > nodes > html > disabled 1`] = `
692 | [
693 | {
694 | "content": [
695 | {
696 | "text":
697 | ,
698 | "type": "text",
699 | },
700 | ],
701 | "type": "paragraph",
702 | },
703 | ]
704 | `;
705 |
706 | exports[`parse > nodes > html > inline 1`] = `
707 | [
708 | {
709 | "content": [
710 | {
711 | "type": "html-node",
712 | },
713 | ],
714 | "type": "paragraph",
715 | },
716 | ]
717 | `;
718 |
719 | exports[`parse > nodes > image > html 1`] = `
720 | [
721 | {
722 | "attrs": {
723 | "alt": "example",
724 | "src": "example.jpg",
725 | "title": null,
726 | },
727 | "type": "image",
728 | },
729 | ]
730 | `;
731 |
732 | exports[`parse > nodes > image > markdown 1`] = `
733 | [
734 | {
735 | "attrs": {
736 | "alt": "example",
737 | "src": "example.jpg",
738 | "title": null,
739 | },
740 | "type": "image",
741 | },
742 | ]
743 | `;
744 |
745 | exports[`parse > nodes > image > markdown inline 1`] = `
746 | [
747 | {
748 | "content": [
749 | {
750 | "attrs": {
751 | "alt": "example",
752 | "src": "example.jpg",
753 | "title": null,
754 | },
755 | "type": "image",
756 | },
757 | ],
758 | "type": "paragraph",
759 | },
760 | ]
761 | `;
762 |
763 | exports[`parse > nodes > ordered list > html 1`] = `
764 | [
765 | {
766 | "attrs": {
767 | "start": 1,
768 | "tight": true,
769 | },
770 | "content": [
771 | {
772 | "content": [
773 | {
774 | "content": [
775 | {
776 | "text": "example1",
777 | "type": "text",
778 | },
779 | ],
780 | "type": "paragraph",
781 | },
782 | ],
783 | "type": "listItem",
784 | },
785 | {
786 | "content": [
787 | {
788 | "content": [
789 | {
790 | "text": "example2",
791 | "type": "text",
792 | },
793 | ],
794 | "type": "paragraph",
795 | },
796 | ],
797 | "type": "listItem",
798 | },
799 | ],
800 | "type": "orderedList",
801 | },
802 | ]
803 | `;
804 |
805 | exports[`parse > nodes > ordered list > markdown 1`] = `
806 | [
807 | {
808 | "attrs": {
809 | "start": 1,
810 | "tight": true,
811 | },
812 | "content": [
813 | {
814 | "content": [
815 | {
816 | "content": [
817 | {
818 | "text": "example1",
819 | "type": "text",
820 | },
821 | ],
822 | "type": "paragraph",
823 | },
824 | ],
825 | "type": "listItem",
826 | },
827 | {
828 | "content": [
829 | {
830 | "content": [
831 | {
832 | "text": "example2",
833 | "type": "text",
834 | },
835 | ],
836 | "type": "paragraph",
837 | },
838 | ],
839 | "type": "listItem",
840 | },
841 | ],
842 | "type": "orderedList",
843 | },
844 | ]
845 | `;
846 |
847 | exports[`parse > nodes > paragraph > html 1`] = `
848 | [
849 | {
850 | "content": [
851 | {
852 | "text": "example1",
853 | "type": "text",
854 | },
855 | ],
856 | "type": "paragraph",
857 | },
858 | {
859 | "content": [
860 | {
861 | "text": "example2",
862 | "type": "text",
863 | },
864 | ],
865 | "type": "paragraph",
866 | },
867 | ]
868 | `;
869 |
870 | exports[`parse > nodes > paragraph > markdown 1`] = `
871 | [
872 | {
873 | "content": [
874 | {
875 | "text": "example1",
876 | "type": "text",
877 | },
878 | ],
879 | "type": "paragraph",
880 | },
881 | {
882 | "content": [
883 | {
884 | "text": "example2",
885 | "type": "text",
886 | },
887 | ],
888 | "type": "paragraph",
889 | },
890 | ]
891 | `;
892 |
893 | exports[`parse > nodes > table > html 1`] = `
894 | [
895 | {
896 | "content": [
897 | {
898 | "content": [
899 | {
900 | "attrs": {
901 | "colspan": 1,
902 | "colwidth": null,
903 | "rowspan": 1,
904 | },
905 | "content": [
906 | {
907 | "content": [
908 | {
909 | "text": "example1",
910 | "type": "text",
911 | },
912 | ],
913 | "type": "paragraph",
914 | },
915 | ],
916 | "type": "tableHeader",
917 | },
918 | {
919 | "attrs": {
920 | "colspan": 1,
921 | "colwidth": null,
922 | "rowspan": 1,
923 | },
924 | "content": [
925 | {
926 | "content": [
927 | {
928 | "text": "example2",
929 | "type": "text",
930 | },
931 | ],
932 | "type": "paragraph",
933 | },
934 | ],
935 | "type": "tableHeader",
936 | },
937 | ],
938 | "type": "tableRow",
939 | },
940 | {
941 | "content": [
942 | {
943 | "attrs": {
944 | "colspan": 1,
945 | "colwidth": null,
946 | "rowspan": 1,
947 | },
948 | "content": [
949 | {
950 | "content": [
951 | {
952 | "text": "example3",
953 | "type": "text",
954 | },
955 | ],
956 | "type": "paragraph",
957 | },
958 | ],
959 | "type": "tableCell",
960 | },
961 | {
962 | "attrs": {
963 | "colspan": 1,
964 | "colwidth": null,
965 | "rowspan": 1,
966 | },
967 | "content": [
968 | {
969 | "content": [
970 | {
971 | "text": "example4",
972 | "type": "text",
973 | },
974 | ],
975 | "type": "paragraph",
976 | },
977 | ],
978 | "type": "tableCell",
979 | },
980 | ],
981 | "type": "tableRow",
982 | },
983 | ],
984 | "type": "table",
985 | },
986 | ]
987 | `;
988 |
989 | exports[`parse > nodes > table > markdown 1`] = `
990 | [
991 | {
992 | "content": [
993 | {
994 | "content": [
995 | {
996 | "attrs": {
997 | "colspan": 1,
998 | "colwidth": null,
999 | "rowspan": 1,
1000 | },
1001 | "content": [
1002 | {
1003 | "content": [
1004 | {
1005 | "text": "example1",
1006 | "type": "text",
1007 | },
1008 | ],
1009 | "type": "paragraph",
1010 | },
1011 | ],
1012 | "type": "tableHeader",
1013 | },
1014 | {
1015 | "attrs": {
1016 | "colspan": 1,
1017 | "colwidth": null,
1018 | "rowspan": 1,
1019 | },
1020 | "content": [
1021 | {
1022 | "content": [
1023 | {
1024 | "text": "example2",
1025 | "type": "text",
1026 | },
1027 | ],
1028 | "type": "paragraph",
1029 | },
1030 | ],
1031 | "type": "tableHeader",
1032 | },
1033 | ],
1034 | "type": "tableRow",
1035 | },
1036 | {
1037 | "content": [
1038 | {
1039 | "attrs": {
1040 | "colspan": 1,
1041 | "colwidth": null,
1042 | "rowspan": 1,
1043 | },
1044 | "content": [
1045 | {
1046 | "content": [
1047 | {
1048 | "text": "example3",
1049 | "type": "text",
1050 | },
1051 | ],
1052 | "type": "paragraph",
1053 | },
1054 | ],
1055 | "type": "tableCell",
1056 | },
1057 | {
1058 | "attrs": {
1059 | "colspan": 1,
1060 | "colwidth": null,
1061 | "rowspan": 1,
1062 | },
1063 | "content": [
1064 | {
1065 | "content": [
1066 | {
1067 | "text": "example4",
1068 | "type": "text",
1069 | },
1070 | ],
1071 | "type": "paragraph",
1072 | },
1073 | ],
1074 | "type": "tableCell",
1075 | },
1076 | ],
1077 | "type": "tableRow",
1078 | },
1079 | ],
1080 | "type": "table",
1081 | },
1082 | ]
1083 | `;
1084 |
1085 | exports[`parse > options > inline > text 1`] = `
1086 | [
1087 | {
1088 | "text": "example",
1089 | "type": "text",
1090 | },
1091 | ]
1092 | `;
1093 |
1094 | exports[`parse > options > inline > text with spaces 1`] = `
1095 | [
1096 | {
1097 | "text": " example ",
1098 | "type": "text",
1099 | },
1100 | ]
1101 | `;
1102 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/serialize.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`serialize > nodes > table > header in body 1`] = `
4 |
5 |
6 |
7 |
8 |
9 |
10 |
13 |
14 | example1
15 |
16 | |
17 |
18 |
19 |
22 |
23 | example3
24 |
25 | |
26 |
27 |
28 |
29 | `;
30 |
31 | exports[`serialize > nodes > table > multiline cell 1`] = `
32 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 | example1
43 |
44 |
45 | example2
46 |
47 | |
48 |
49 |
50 |
51 | `;
52 |
53 | exports[`serialize > nodes > table > no header 1`] = `
54 |
55 |
56 |
57 |
58 |
59 |
60 |
63 |
64 | example1
65 |
66 | |
67 |
68 |
69 |
72 |
73 | example3
74 |
75 | |
76 |
77 |
78 |
79 | `;
80 |
81 | exports[`serialize > nodes > table > with colspan 1`] = `
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
92 |
93 | example1
94 |
95 | |
96 |
97 |
98 |
99 | `;
100 |
101 | exports[`serialize > nodes > table > with rowspan 1`] = `
102 |
103 |
104 |
105 |
106 |
107 |
108 |
111 |
112 | example1
113 |
114 | |
115 |
116 |
117 |
118 | `;
119 |
--------------------------------------------------------------------------------
/__tests__/clipboard.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { Editor } from "@tiptap/core";
3 | import StarterKit from "@tiptap/starter-kit";
4 | import { Markdown } from "../src";
5 | import { clipboardEvent } from "./utils/dom";
6 |
7 |
8 | describe('clipboard', () => {
9 | describe('paste', () => {
10 | test('transform', () => {
11 | const editor = new Editor({
12 | extensions: [
13 | StarterKit,
14 | Markdown.configure({
15 | transformPastedText: true,
16 | }),
17 | ],
18 | });
19 |
20 | const event = clipboardEvent('paste');
21 | event.clipboardData.setData('text/plain', `# My title`);
22 |
23 | editor.view.dom.dispatchEvent(event);
24 |
25 | expect(editor.getHTML()).toContain('My title
')
26 | });
27 |
28 | test('does not transform', () => {
29 | const editor = new Editor({
30 | extensions: [
31 | StarterKit,
32 | Markdown.configure({
33 | transformPastedText: false,
34 | }),
35 | ],
36 | });
37 |
38 | const event = clipboardEvent('paste');
39 | event.clipboardData.setData('text/plain', `# My title`);
40 |
41 | editor.view.dom.dispatchEvent(event);
42 |
43 | expect(editor.getHTML()).not.toContain('My title
')
44 | });
45 | });
46 |
47 | describe('copy', () => {
48 | test('transform', () => {
49 | const editor = new Editor({
50 | content: '# My title',
51 | extensions: [
52 | StarterKit,
53 | Markdown.configure({
54 | transformCopiedText: true,
55 | }),
56 | ],
57 | });
58 |
59 | const event = clipboardEvent('copy');
60 |
61 | editor.commands.selectAll();
62 | editor.view.dom.dispatchEvent(event);
63 |
64 | expect(event.clipboardData.getData('text/plain')).toBe('# My title');
65 | });
66 |
67 | test('does not transform', () => {
68 | const editor = new Editor({
69 | content: '# My title',
70 | extensions: [
71 | StarterKit,
72 | Markdown.configure({
73 | transformCopiedText: false,
74 | }),
75 | ],
76 | });
77 |
78 | const event = clipboardEvent('copy');
79 |
80 | editor.commands.selectAll();
81 | editor.view.dom.dispatchEvent(event);
82 |
83 | expect(event.clipboardData.getData('text/plain')).toBe('My title');
84 | });
85 | });
86 | })
87 |
--------------------------------------------------------------------------------
/__tests__/parse.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { parse, dedent } from './utils';
3 |
4 | describe('parse', () => {
5 | describe('marks', () => {
6 | describe('text', () => {
7 | test('text', () => {
8 | expect(parse('example')).toMatchSnapshot();
9 | });
10 | test('link', () => {
11 | expect(parse('http://example.org')).toMatchSnapshot();
12 | });
13 | test('soft break', () => {
14 | expect(parse('example1\nexample2')).toMatchSnapshot();
15 | });
16 | })
17 | describe('bold', () => {
18 | test('markdown', () => {
19 | expect(parse('**example**')).toMatchSnapshot();
20 | });
21 | test('html', () => {
22 | expect(parse('example')).toMatchSnapshot();
23 | });
24 | });
25 | describe('italic', () => {
26 | test('markdown', () => {
27 | expect(parse('*example*')).toMatchSnapshot();
28 | });
29 | test('html', () => {
30 | expect(parse('example')).toMatchSnapshot();
31 | });
32 | });
33 | describe('strike', () => {
34 | test('markdown', () => {
35 | expect(parse('~~example~~')).toMatchSnapshot();
36 | });
37 | test('html', () => {
38 | expect(parse('example')).toMatchSnapshot();
39 | });
40 | });
41 | describe('code', () => {
42 | test('markdown', () => {
43 | expect(parse('`example`')).toMatchSnapshot();
44 | });
45 | test('html', () => {
46 | expect(parse('example
')).toMatchSnapshot();
47 | });
48 | });
49 | describe('link', () => {
50 | test('markdown', () => {
51 | expect(parse('[example](http://example.org)')).toMatchSnapshot();
52 | });
53 | test('markdown with linkify', () => {
54 | expect(parse('http://example.org', { linkify:true })).toMatchSnapshot();
55 | });
56 | test('html', () => {
57 | expect(parse('example')).toMatchSnapshot();
58 | });
59 | });
60 | });
61 | describe('nodes', () => {
62 | describe('paragraph', () => {
63 | test('markdown', () => {
64 | expect(parse('example1\n\nexample2')).toMatchSnapshot();
65 | });
66 | test('html', () => {
67 | expect(parse('example1
example2
')).toMatchSnapshot();
68 | });
69 | });
70 | describe('headings', () => {
71 | test('markdown h1', () => {
72 | expect(parse('# example')).toMatchSnapshot();
73 | });
74 | test('markdown h2', () => {
75 | expect(parse('## example')).toMatchSnapshot();
76 | });
77 | test('markdown h3', () => {
78 | expect(parse('### example')).toMatchSnapshot();
79 | });
80 | test('markdown h4', () => {
81 | expect(parse('#### example')).toMatchSnapshot();
82 | });
83 | test('markdown h5', () => {
84 | expect(parse('##### example')).toMatchSnapshot();
85 | });
86 | test('markdown h6', () => {
87 | expect(parse('###### example')).toMatchSnapshot();
88 | });
89 | test('html h1', () => {
90 | expect(parse('example
')).toMatchSnapshot();
91 | });
92 | });
93 | describe('bullet list', () => {
94 | test('markdown marker `-`', () => {
95 | expect(parse('- example1\n\n- example2')).toMatchSnapshot();
96 | });
97 | test('markdown marker `*`', () => {
98 | expect(parse('* example1\n\n* example2')).toMatchSnapshot();
99 | });
100 | test('html', () => {
101 | expect(parse('')).toMatchSnapshot();
102 | });
103 | });
104 | describe('ordered list', () => {
105 | test('markdown', () => {
106 | expect(parse('1. example1\n2. example2')).toMatchSnapshot();
107 | });
108 | test('html', () => {
109 | expect(parse('- example1
- example2
')).toMatchSnapshot();
110 | });
111 | });
112 | describe('fence', () => {
113 | test('markdown', () => {
114 | expect(parse('```\nexample\n```')).toMatchSnapshot();
115 | });
116 | test('markdown with lang', () => {
117 | expect(parse('```js\nexample\n```')).toMatchSnapshot();
118 | });
119 | test('markdown with languageClassPrefix', () => {
120 | expect(parse('```js\nexample\n```', { codeBlock: { languageClassPrefix: 'lang--' } }, true))
121 | .toEqual('example
');
122 | })
123 | });
124 | describe('code block', () => {
125 | test('markdown', () => {
126 | expect(parse(' example')).toMatchSnapshot();
127 | });
128 | test('html', () => {
129 | expect(parse('example
')).toMatchSnapshot();
130 | });
131 | });
132 | describe('image', () => {
133 | test('markdown', () => {
134 | expect(parse('')).toMatchSnapshot();
135 | });
136 | test('markdown inline', () => {
137 | expect(parse('', { image: { inline: true } })).toMatchSnapshot();
138 | });
139 | test('html', () => {
140 | expect(parse('
')).toMatchSnapshot();
141 | });
142 | });
143 | describe('hr', () => {
144 | test('markdown', () => {
145 | expect(parse('---')).toMatchSnapshot();
146 | })
147 | test('html', () => {
148 | expect(parse('
')).toMatchSnapshot();
149 | });
150 | });
151 | describe('hard break', () => {
152 | test('markdown', () => {
153 | expect(parse('example1 \nexample2')).toMatchSnapshot();
154 | })
155 | test('markdown with breaks option', () => {
156 | expect(parse('example1\nexample2', { breaks: true })).toMatchSnapshot();
157 | })
158 | test('markdown with breaks option + inline', () => {
159 | expect(parse('example1\nexample2', { breaks: true, inline: true })).toMatchSnapshot();
160 | })
161 | test('html', () => {
162 | expect(parse('example1
example2')).toMatchSnapshot();
163 | });
164 | });
165 | describe('table', () => {
166 | test('markdown', () => {
167 | expect(parse(dedent`
168 | example1 | example2
169 | --- | ---
170 | example3 | example4
171 | `)).toMatchSnapshot();
172 | });
173 |
174 | test('html', () => {
175 | expect(parse(dedent`
176 |
177 |
178 |
179 | example1 |
180 | example2 |
181 |
182 |
183 |
184 |
185 | example3 |
186 | example4 |
187 |
188 |
189 |
190 | `)).toMatchSnapshot();
191 | });
192 | });
193 | describe('html', () => {
194 | test('block', () => {
195 | expect(parse('example', {
196 | htmlNode: {
197 | group: 'block',
198 | content: 'inline*',
199 | parseHTML: () => [{
200 | tag: 'custom-element',
201 | }],
202 | },
203 | })).toMatchSnapshot();
204 | });
205 | test('inline', () => {
206 | expect(parse('', {
207 | htmlNode: {
208 | group: 'inline',
209 | inline: true,
210 | parseHTML: () => [{
211 | tag: 'custom-element',
212 | }],
213 | },
214 | })).toMatchSnapshot();
215 | });
216 | test('disabled', () => {
217 | expect(parse('', {
218 | html: false,
219 | htmlNode: {
220 | group: 'block',
221 | parseHTML: () => [{
222 | tag: 'custom-element',
223 | }],
224 | },
225 | })).toMatchSnapshot();
226 | })
227 | });
228 | });
229 | describe('options', () => {
230 | describe('inline', () => {
231 | test('text', () => {
232 | expect(parse('example', { inline: true })).toMatchSnapshot();
233 | });
234 | test('text with spaces', () => {
235 | expect(parse(' example ', { inline: true })).toMatchSnapshot();
236 | });
237 | });
238 | });
239 | });
240 |
241 |
--------------------------------------------------------------------------------
/__tests__/serialize.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect, vi } from "vitest";
2 | import { serialize, dedent } from './utils';
3 |
4 | describe('serialize', () => {
5 | describe('marks', () => {
6 | test('text', () => {
7 | expect(serialize('example')).toEqual('example');
8 | });
9 | test('text escaped', () => {
10 | expect(serialize('example <><>')).toEqual('example <><>');
11 | });
12 | test('bold', () => {
13 | expect(serialize('example')).toEqual('**example**');
14 | });
15 | test('italic', () => {
16 | expect(serialize('example')).toEqual('*example*');
17 | });
18 | test('strike', () => {
19 | expect(serialize('example')).toEqual('~~example~~');
20 | });
21 | test('code', () => {
22 | expect(serialize('example
')).toEqual('`example`');
23 | });
24 | test('link', () => {
25 | expect(serialize('example')).toEqual('[example](http://example.org)');
26 | });
27 | test('underline', () => {
28 | vi.spyOn(console, 'warn').mockImplementation();
29 |
30 | expect(serialize('example', { html: false })).toEqual('example');
31 | expect(console.warn).toHaveBeenCalledWith(
32 | `Tiptap Markdown: "underline" mark is only available in html mode`
33 | );
34 | });
35 | test('underline html', () => {
36 | expect(serialize('example', { html: true })).toEqual('example');
37 | });
38 | test('html', () => {
39 | expect(serialize('example', {
40 | html: true,
41 | htmlMark: {
42 | parseHTML: () => [{
43 | tag: 'sup',
44 | }],
45 | renderHTML: () => ['sup', 0],
46 | },
47 | })).toEqual('example');
48 | });
49 | test('expels whitespaces', () => {
50 | expect(serialize('My example ')).toEqual('My **example** ');
51 | expect(serialize('My example ')).toEqual('My *example* ');
52 | });
53 | test('trim inline', () => {
54 | expect(serialize('My, example')).toEqual('My, **example**');
55 | expect(serialize('My. example')).toEqual('My. *example*');
56 | });
57 | });
58 | describe('nodes', () => {
59 | test('paragraph', () => {
60 | expect(serialize('example1
example2
')).toEqual('example1\n\nexample2');
61 | });
62 | test('headings', () => {
63 | expect(serialize('example
')).toEqual('# example');
64 | expect(serialize('example
')).toEqual('## example');
65 | expect(serialize('example
')).toEqual('### example');
66 | expect(serialize('example
')).toEqual('#### example');
67 | expect(serialize('example
')).toEqual('##### example');
68 | expect(serialize('example
')).toEqual('###### example');
69 | });
70 | test('bullet list', () => {
71 | expect(serialize(''))
72 | .toEqual('- example1\n- example2');
73 |
74 | expect(serialize('', { bulletListMarker: '*' }))
75 | .toEqual('* example1\n* example2');
76 | });
77 | test('ordered list', () => {
78 | expect(serialize('- example1
- example2
'))
79 | .toEqual('1. example1\n2. example2');
80 | expect(serialize('- example1
- example2
'))
81 | .toEqual('10. example1\n11. example2');
82 | });
83 | test('adjacent ordered list', () => {
84 | expect(serialize('- example1
- example2
- example3
'))
85 | .toEqual('1. example1\n\n\n1) example2\n\n\n1. example3'); // prosemirror-markdown insert 3 \n, only 2 are needed
86 | })
87 | test('fence', () => {
88 | expect(serialize('example
')).toEqual('```js\nexample\n```');
89 | })
90 | test('code block', () => {
91 | expect(serialize('example
')).toEqual('```\nexample\n```');
92 | });
93 | test('image', () => {
94 | expect(serialize('
')).toEqual('');
95 | });
96 | test('hr', () => {
97 | expect(serialize('
')).toEqual('---')
98 | });
99 | test('hard break', () => {
100 | expect(serialize('example1
example2')).toEqual('example1\\\nexample2');
101 | });
102 | test('hard break with mark wrap', () => {
103 | expect(serialize('example1
example2')).toEqual('example1\\\nexample2');
104 | });
105 | describe('table', () => {
106 | test('filled', () => {
107 | expect(serialize(dedent`
108 |
109 |
110 | example1 |
111 | example2 |
112 |
113 |
114 | example3 |
115 | example4 |
116 |
117 |
118 | `)).toEqual(dedent`
119 | | example1 | example2 |
120 | | --- | --- |
121 | | example3 | example4 |
122 | `);
123 | });
124 | test('empty', () => {
125 | expect(serialize(dedent`
126 |
127 |
128 | |
129 | |
130 |
131 |
132 | |
133 | |
134 |
135 |
136 | `)).toEqual(dedent`
137 | | | |
138 | | --- | --- |
139 | | | |
140 | `);
141 | });
142 | test('single column', () => {
143 | expect(serialize(dedent`
144 |
145 |
146 | example1 |
147 |
148 |
149 | example3 |
150 |
151 |
152 | `)).toEqual(dedent`
153 | | example1 |
154 | | --- |
155 | | example3 |
156 | `);
157 | });
158 | test('header only', () => {
159 | expect(serialize(dedent`
160 |
161 |
162 | example1 |
163 | example2 |
164 |
165 |
166 | `)).toEqual(dedent`
167 | | example1 | example2 |
168 | | --- | --- |
169 | `);
170 | });
171 | test('cell with hard break', () => {
172 | expect(serialize(dedent`
173 |
174 |
175 | example1 example2 |
176 |
177 |
178 | `, { html: true })).toEqual(dedent`
179 | | example1
example2 |
180 | | --- |
181 | `);
182 | });
183 | test('no header', () => {
184 | expect(serialize(dedent`
185 |
186 |
187 | example1 |
188 |
189 |
190 | example3 |
191 |
192 |
193 | `, { html: true })).toMatchSnapshot();
194 | });
195 | test('header in body', () => {
196 | expect(serialize(dedent`
197 |
198 |
199 | example1 |
200 |
201 |
202 | example3 |
203 |
204 |
205 | `, { html: true })).toMatchSnapshot();
206 | });
207 | test('with colspan', () => {
208 | expect(serialize(dedent`
209 |
210 |
211 | example1 |
212 |
213 |
214 | `, { html: true })).toMatchSnapshot();
215 | });
216 | test('with rowspan', () => {
217 | expect(serialize(dedent`
218 |
219 |
220 | example1 |
221 |
222 |
223 | `, { html: true })).toMatchSnapshot();
224 | });
225 | test('multiline cell', () => {
226 | expect(serialize(dedent`
227 |
228 |
229 | example1 example2 |
230 |
231 |
232 | `, { html: true })).toMatchSnapshot();
233 | });
234 | })
235 | test('html', () => {
236 | expect(serialize(' example2', {
237 | html: true,
238 | htmlNode: {
239 | group: 'block',
240 | content: 'inline*',
241 | parseHTML: () => [{
242 | tag: 'block-element',
243 | }],
244 | renderHTML: () => [
245 | 'block-element',
246 | 0,
247 | ],
248 | },
249 | })).toEqual('\n\n\n\nexample2\n');
250 | });
251 | test('html with hard break', () => {
252 | expect(serialize('a
b', {
253 | html: true,
254 | htmlNode: {
255 | group: 'block',
256 | content: 'inline*',
257 | parseHTML: () => [{
258 | tag: 'block-element',
259 | }],
260 | renderHTML: () => [
261 | 'block-element',
262 | 0,
263 | ],
264 | },
265 | })).toEqual('\na
b\n');
266 | });
267 | test('html inline', () => {
268 | expect(serialize('example1 example2
', {
269 | html: true,
270 | htmlNode: {
271 | group: 'inline',
272 | inline: true,
273 | content: 'text*',
274 | parseHTML: () => [{
275 | tag: 'inline-element',
276 | }],
277 | renderHTML: () => [
278 | 'inline-element',
279 | 0,
280 | ],
281 | },
282 | })).toEqual('example1 example2');
283 | });
284 | test('html disabled', () => {
285 | vi.spyOn(console, 'warn').mockImplementation();
286 |
287 | expect(serialize('', {
288 | html: false,
289 | htmlNode: {
290 | name: 'customElement',
291 | group: 'block',
292 | parseHTML: () => [{
293 | tag: 'custom-element',
294 | }],
295 | renderHTML: () => [
296 | 'custom-element'
297 | ],
298 | },
299 | })).toEqual('[customElement]');
300 |
301 | expect(console.warn).toHaveBeenCalledWith(
302 | `Tiptap Markdown: "customElement" node is only available in html mode`
303 | );
304 | });
305 | });
306 | })
307 |
--------------------------------------------------------------------------------
/__tests__/tight-lists.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { Editor } from "@tiptap/core";
3 | import StarterKit from "@tiptap/starter-kit";
4 | import { Markdown } from "../src";
5 |
6 | function getParsedNode(content) {
7 | const editor = new Editor({
8 | content,
9 | extensions: [
10 | StarterKit,
11 | Markdown,
12 | ],
13 | });
14 | return editor.state.doc.firstChild;
15 | }
16 |
17 | describe('Tight lists extension', () => {
18 | describe('bullet list', () => {
19 | test('tight', () => {
20 | expect(getParsedNode(`* example1\n* example2`).attrs)
21 | .toMatchObject({
22 | tight: true,
23 | });
24 | });
25 |
26 | test('tight html', () => {
27 | expect(getParsedNode(``).attrs)
28 | .toMatchObject({
29 | tight: true,
30 | });
31 | });
32 |
33 | test('loose', () => {
34 | expect(getParsedNode(`* example1\n\n* example2`).attrs)
35 | .toMatchObject({
36 | tight: false,
37 | });
38 | });
39 |
40 | test('loose html', () => {
41 | expect(getParsedNode(``).attrs)
42 | .toMatchObject({
43 | tight: false,
44 | });
45 | });
46 | });
47 | describe('ordered list', () => {
48 | test('tight', () => {
49 | expect(getParsedNode(`1. example1\n2. example2`).attrs).toMatchObject({
50 | tight: true,
51 | });
52 | });
53 |
54 | test('loose', () => {
55 | expect(getParsedNode(`1. example1\n\n2. example2`).attrs).toMatchObject({
56 | tight: false,
57 | });
58 | });
59 | });
60 | })
61 |
--------------------------------------------------------------------------------
/__tests__/util.spec.js:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { shiftDelim, trimInline } from "../src/util/markdown";
3 |
4 | describe('util' ,() => {
5 | describe('trimInline', () => {
6 | test('full', () => {
7 | expect(trimInline('*abcde*', 0, 6)).toEqual('*abcde*');
8 | });
9 | test('left', () => {
10 | expect(trimInline('a*, bcde*', '*', 1, 8)).toEqual('a, *bcde*');
11 | });
12 | test('right', () => {
13 | expect(trimInline('*abcd ,*e', '*', 0, 7)).toEqual('*abcd* ,e');
14 | });
15 | test('meet', () => {
16 | expect(trimInline('e*,,*e', '*', 1, 4)).toEqual('e,,e');
17 | });
18 | });
19 | test('shiftDelim', () => {
20 | expect(shiftDelim('e**abcd', '**', 1, 1)).toEqual('ea**bcd');
21 | expect(shiftDelim('e**abcd', '**', 1, -1)).toEqual('**eabcd');
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/__tests__/utils/dom.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | export function clipboardEvent(name) {
4 | const clipboardData = {
5 | data: {},
6 | getData(format) { return this.data[format] },
7 | setData(format, content) { return this.data[format] = content },
8 | clearData(format) { delete this.data[format] },
9 | };
10 | const event = new Event(name);
11 | event.clipboardData = clipboardData;
12 |
13 | return event;
14 | }
15 |
--------------------------------------------------------------------------------
/__tests__/utils/editor.js:
--------------------------------------------------------------------------------
1 | import { Editor, Node, Mark } from "@tiptap/core";
2 | import StarterKit from "@tiptap/starter-kit";
3 | import Table from "@tiptap/extension-table";
4 | import TableRow from "@tiptap/extension-table-row";
5 | import TableHeader from "@tiptap/extension-table-header";
6 | import TableCell from "@tiptap/extension-table-cell";
7 | import Link from '@tiptap/extension-link';
8 | import Image from '@tiptap/extension-image';
9 | import Underline from '@tiptap/extension-underline';
10 | import CodeBlock from "@tiptap/extension-code-block";
11 | import { Markdown } from "../../src/Markdown";
12 |
13 | export function createEditor({
14 | image,
15 | codeBlock,
16 | htmlNode,
17 | htmlMark,
18 | markdownOptions,
19 | } = {}) {
20 | return new Editor({
21 | extensions: [
22 | Markdown.configure({
23 | ...markdownOptions,
24 | }),
25 | StarterKit.configure({
26 | codeBlock: false,
27 | }),
28 | Table,
29 | TableRow,
30 | TableHeader,
31 | TableCell,
32 | Link,
33 | Underline,
34 | CodeBlock.configure({
35 | ...codeBlock,
36 | }),
37 | Image.configure({
38 | ...image,
39 | }),
40 | Node.create({
41 | name: 'html-node',
42 | ...htmlNode,
43 | }),
44 | Mark.create({
45 | name: 'html-mark',
46 | ...htmlMark,
47 | }),
48 | ],
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/__tests__/utils/index.js:
--------------------------------------------------------------------------------
1 |
2 | export { parse } from './parse';
3 | export { serialize } from './serialize';
4 |
5 | export function dedent(str) {
6 | return str[0].replace(/^\s*/gm, '');
7 | }
8 |
--------------------------------------------------------------------------------
/__tests__/utils/parse.js:
--------------------------------------------------------------------------------
1 | import { createEditor } from "./editor";
2 | import { DOMParser } from "prosemirror-model";
3 | import { elementFromString } from "../../src/util/dom";
4 | import { getHTMLFromFragment } from "@tiptap/core";
5 |
6 | export function parse(content, options = {}, asHTML = false) {
7 | const {
8 | inline,
9 | image,
10 | codeBlock,
11 | htmlNode,
12 | ...markdownOptions
13 | } = options;
14 |
15 | const editor = createEditor({
16 | image,
17 | htmlNode,
18 | codeBlock,
19 | markdownOptions,
20 | });
21 |
22 | const parsed = editor.storage.markdown.parser.parse(content, { inline });
23 | const fragment = DOMParser.fromSchema(editor.schema)
24 | .parseSlice(elementFromString(parsed), {
25 | preserveWhitespace: inline ? 'full' : false,
26 | })
27 | .content;
28 |
29 | if(asHTML) {
30 | return getHTMLFromFragment(fragment, editor.schema);
31 | }
32 |
33 | return fragment.toJSON();
34 | }
35 |
--------------------------------------------------------------------------------
/__tests__/utils/serialize.js:
--------------------------------------------------------------------------------
1 | import { DOMParser } from "prosemirror-model";
2 | import { createEditor } from "./editor";
3 | import { elementFromString } from "../../src/util/dom";
4 |
5 | export function serialize(content, { htmlNode, htmlMark, ...markdownOptions } = {}) {
6 | const editor = createEditor({
7 | htmlNode,
8 | htmlMark,
9 | markdownOptions,
10 | });
11 | const doc = DOMParser.fromSchema(editor.schema)
12 | .parse(elementFromString(content), {
13 | preserveWhitespace: true, // to ensure whitespaces handling
14 | });
15 |
16 | return editor.storage.markdown.serializer.serialize(doc);
17 | }
18 |
--------------------------------------------------------------------------------
/__tests__/utils/setup-dom.js:
--------------------------------------------------------------------------------
1 |
2 | document.createRange = () => {
3 | return Object.assign(new Range(), {
4 | getClientRects: () => [],
5 | getBoundingClientRect: () => ({}),
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/__tests__/utils/setup.js:
--------------------------------------------------------------------------------
1 | import { expect } from "vitest";
2 | import serializeHtml from 'jest-serializer-html';
3 |
4 | expect.addSnapshotSerializer(serializeHtml);
5 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 |
2 | export default {
3 | presets: [
4 | '@babel/preset-env',
5 | ],
6 | plugins: [
7 | '@babel/plugin-transform-nullish-coalescing-operator',
8 | '@babel/plugin-transform-optional-chaining',
9 | ],
10 | };
11 |
--------------------------------------------------------------------------------
/docs/migration.md:
--------------------------------------------------------------------------------
1 | ## Migrate to 0.7.0
2 |
3 | ### Simple
4 | ```diff
5 | import { Editor } from '@tiptap/core'
6 | - import { createMarkdownEditor } from 'tiptap-markdown'
7 | + import { Markdown } from 'tiptap-markdown'
8 |
9 | - const MarkdownEditor = createMarkdownEditor(Editor)
10 | - const editor = new MarkdownEditor({
11 | + const editor = new Editor({
12 | extensions: [
13 | + Markdown,
14 | ],
15 | })
16 | - const markdownOutput = editor.getMarkdown()
17 | + const markdownOutput = editor.storage.markdown.getMarkdown()
18 | ```
19 |
20 | ### With options
21 | ```diff
22 | const editor = new Editor({
23 | extensions: [
24 | + Markdown.configure({
25 | + breaks: true,
26 | + })
27 | ],
28 | - markdown: {
29 | - breaks: true,
30 | - },
31 | })
32 | ```
33 |
34 | ### Advanced: Custom extension
35 | `createMarkdownExtension()` has been dropped in favor Tiptap extension `addStorage()`. Existing Tiptap node/mark can be configured by using `addStorage()` in *`Node`*`.extend({ ... })`
36 |
37 |
38 |
39 | See example
40 |
41 |
42 | ```diff
43 | - import { createMarkdownExtension } from 'tiptap-markdown'
44 |
45 | const CustomNode = Node.create({
46 | + addStorage() {
47 | + return {
48 | + markdown: {
49 | + serialize() {},
50 | + parse: {},
51 | + }
52 | + }
53 | + }
54 | })
55 |
56 |
57 | new Editor({
58 | extensions: [
59 | CustomNode,
60 | ]
61 | - markdown: {
62 | - extensions: [
63 | - createMarkdownExtension(CustomNode, {
64 | - serialize() {},
65 | - parse: {},
66 | - })
67 | - ]
68 | - }
69 | })
70 | ```
71 |
72 |
73 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 | .idea
7 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Vite App
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "example",
4 | "version": "0.0.0",
5 | "type": "module",
6 | "dependencies": {
7 | "@tiptap/extension-code-block-lowlight": "^2.1.12",
8 | "@tiptap/extension-youtube": "^2.1.12",
9 | "bootstrap": "^5.0.0-beta3",
10 | "lowlight": "^3.1.0",
11 | "markdown-it-container": "^3.0.0",
12 | "markdown-it-mark": "^3.0.1",
13 | "vue": "^3.2.47"
14 | },
15 | "devDependencies": {
16 | "@vitejs/plugin-vue": "^5.0.4",
17 | "@vue/compiler-sfc": "^3.4.21",
18 | "rollup-plugin-visualizer": "^5.9.0",
19 | "sass": "^1.32.10"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/example/src/App.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
14 |
15 |
17 |
--------------------------------------------------------------------------------
/example/src/app.scss:
--------------------------------------------------------------------------------
1 |
2 | $color-black: #000000;
3 | $color-white: #ffffff;
4 | $color-grey: #dddddd;
5 |
6 | .editor {
7 | position: relative;
8 |
9 | &__content {
10 |
11 | overflow-wrap: break-word;
12 | word-wrap: break-word;
13 | word-break: break-word;
14 |
15 | * {
16 | caret-color: currentColor;
17 | }
18 |
19 | .warning {
20 | color: #664d03;
21 | background: #fff3cd;
22 | border: 1px solid #ffecb5;
23 | padding: .75rem 1.25rem;
24 | border-radius: .25rem;
25 |
26 | > :last-child {
27 | margin-bottom: 0;
28 | }
29 | }
30 |
31 | pre {
32 | padding: 0.7rem 1rem;
33 | border-radius: 5px;
34 | background: $color-black;
35 | color: $color-white;
36 | font-size: 0.8rem;
37 | overflow-x: auto;
38 |
39 | code {
40 | display: block;
41 | }
42 |
43 | .hljs-comment,
44 | .hljs-quote {
45 | color: #616161;
46 | }
47 |
48 | .hljs-variable,
49 | .hljs-template-variable,
50 | .hljs-attribute,
51 | .hljs-tag,
52 | .hljs-name,
53 | .hljs-regexp,
54 | .hljs-link,
55 | .hljs-name,
56 | .hljs-selector-id,
57 | .hljs-selector-class {
58 | color: #F98181;
59 | }
60 |
61 | .hljs-number,
62 | .hljs-meta,
63 | .hljs-built_in,
64 | .hljs-builtin-name,
65 | .hljs-literal,
66 | .hljs-type,
67 | .hljs-params {
68 | color: #FBBC88;
69 | }
70 |
71 | .hljs-string,
72 | .hljs-symbol,
73 | .hljs-bullet {
74 | color: #B9F18D;
75 | }
76 |
77 | .hljs-title,
78 | .hljs-section {
79 | color: #FAF594;
80 | }
81 |
82 | .hljs-keyword,
83 | .hljs-selector-tag {
84 | color: #70CFF8;
85 | }
86 |
87 | .hljs-emphasis {
88 | font-style: italic;
89 | }
90 |
91 | .hljs-strong {
92 | font-weight: 700;
93 | }
94 | }
95 |
96 | p code {
97 | padding: 0.2rem 0.4rem;
98 | border-radius: 5px;
99 | font-size: 0.8rem;
100 | font-weight: bold;
101 | background: rgba($color-black, 0.1);
102 | color: rgba($color-black, 0.8);
103 | }
104 |
105 | ul,
106 | ol {
107 | padding-left: 1rem;
108 |
109 | &.tight > li > p {
110 | margin: 0;
111 | }
112 | }
113 |
114 | li,
115 | li > ol,
116 | li > ul {
117 | margin: 0;
118 | }
119 |
120 | a {
121 | color: inherit;
122 | }
123 |
124 | blockquote {
125 | border-left: 3px solid rgba($color-black, 0.1);
126 | color: rgba($color-black, 0.8);
127 | padding-left: 0.8rem;
128 | font-style: italic;
129 |
130 | p {
131 | margin: 0;
132 | }
133 | }
134 |
135 | img {
136 | max-width: 100%;
137 | border-radius: 3px;
138 | }
139 |
140 | table {
141 | border-collapse: collapse;
142 | table-layout: fixed;
143 | width: 100%;
144 | margin: 0;
145 | overflow: hidden;
146 |
147 | td, th {
148 | min-width: 1em;
149 | border: 2px solid $color-grey;
150 | padding: 3px 5px;
151 | vertical-align: top;
152 | box-sizing: border-box;
153 | position: relative;
154 | > * {
155 | margin-bottom: 0;
156 | }
157 | }
158 |
159 | th {
160 | font-weight: bold;
161 | text-align: left;
162 | }
163 |
164 | .selectedCell:after {
165 | z-index: 2;
166 | position: absolute;
167 | content: "";
168 | left: 0; right: 0; top: 0; bottom: 0;
169 | background: rgba(200, 200, 255, 0.4);
170 | pointer-events: none;
171 | }
172 |
173 | .column-resize-handle {
174 | position: absolute;
175 | right: -2px; top: 0; bottom: 0;
176 | width: 4px;
177 | z-index: 20;
178 | background-color: #adf;
179 | pointer-events: none;
180 | }
181 | }
182 |
183 | .tableWrapper {
184 | margin: 1em 0;
185 | overflow-x: auto;
186 | }
187 |
188 | .resize-cursor {
189 | cursor: ew-resize;
190 | cursor: col-resize;
191 | }
192 |
193 | }
194 | }
195 |
196 | .menubar {
197 |
198 | //margin-bottom: 1rem;
199 | transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
200 |
201 | &.is-hidden {
202 | visibility: hidden;
203 | opacity: 0;
204 | }
205 |
206 | &.is-focused {
207 | visibility: visible;
208 | opacity: 1;
209 | transition: visibility 0.2s, opacity 0.2s;
210 | }
211 |
212 | &__button {
213 | font-weight: bold;
214 | //display: inline-flex;
215 | //background: transparent;
216 | //border: 0;
217 | //color: $color-black;
218 | //padding: 0.2rem 0.5rem;
219 | //margin-right: 0.2rem;
220 | //border-radius: 3px;
221 | //cursor: pointer;
222 | //
223 | &:hover {
224 | background-color: rgba($color-black, 0.05);
225 | }
226 |
227 | &.is-active {
228 | background-color: rgba($color-black, 0.1);
229 | }
230 | }
231 |
232 | span#{&}__button {
233 | font-size: 13.3333px;
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/example/src/components/Editor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
111 |
--------------------------------------------------------------------------------
/example/src/components/MenuBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
9 |
12 |
15 |
21 |
24 |
27 |
30 |
33 |
36 |
39 |
42 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
109 |
--------------------------------------------------------------------------------
/example/src/data/content.md:
--------------------------------------------------------------------------------
1 | ## Paragraph
2 |
3 | Proin pretium, leo ac pellentesque mollis, felis nunc ultrices eros, sed gravida augue augue mollis justo. Duis leo. Fusce neque. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris.
4 |
5 | Nulla consequat massa quis enim. Phasellus blandit leo ut odio. Phasellus dolor. Proin pretium, leo ac pellentesque mollis, felis nunc ultrices eros, sed gravida augue augue mollis justo.
6 |
7 | ## Task lists
8 |
9 | - [ ] fzfzefz
10 | - [x] fzfezfezf
11 |
12 |
13 | # h1 Heading 8-)
14 | ## h2 Heading
15 | ### h3 Heading
16 | #### h4 Heading
17 | ##### h5 Heading
18 | ###### h6 Heading
19 |
20 |
21 | ## Horizontal Rules
22 |
23 | ___
24 |
25 | ---
26 |
27 | ***
28 |
29 | ## Emphasis
30 |
31 | **This is bold text**
32 |
33 | __This is bold text__
34 |
35 | *This is italic text*
36 |
37 | _This is italic text_
38 |
39 | ~~Strikethrough~~
40 |
41 |
42 | ## Blockquotes
43 |
44 |
45 | > Blockquotes can also be nested...
46 | >> ...by using additional greater-than signs right next to each other...
47 | > > > ...or with spaces between arrows.
48 |
49 |
50 | ## Lists
51 |
52 | Unordered
53 |
54 | + Create a list by starting a line with `+`, `-`, or `*`
55 | + Sub-lists are made by indenting 2 spaces:
56 | - Marker character change forces new list start:
57 | * Ac tristique libero volutpat at
58 | + Facilisis in pretium nisl aliquet
59 | - Nulla volutpat aliquam velit
60 | + Very easy!
61 |
62 | Ordered
63 |
64 | 1. Lorem ipsum dolor sit amet
65 | 2. Consectetur adipiscing elit
66 | 3. Integer molestie lorem at massa
67 |
68 |
69 | 1. You can use sequential numbers...
70 | 1. ...or keep all the numbers as `1.`
71 |
72 | Start numbering with offset:
73 |
74 | 57. foo
75 | 1. bar
76 |
77 |
78 | ## Code
79 |
80 | Inline `code`
81 |
82 | Indented code
83 |
84 | // Some comments
85 | line 1 of code
86 | line 2 of code
87 | line 3 of code
88 |
89 |
90 | Block code "fences"
91 |
92 | ```
93 | Sample text here...
94 | ```
95 |
96 | Syntax highlighting
97 |
98 | ```js
99 | var foo = function (bar) {
100 | return bar++;
101 | };
102 |
103 | console.log(foo(5));
104 | ```
105 |
106 | ## Tables
107 |
108 | | Option | Description |
109 | | ------ | ----------- |
110 | | data | path to data files to supply the data that will be passed into templates. |
111 | | engine | engine to be used for processing templates. Handlebars is the default. |
112 | | ext | extension to be used for dest files. |
113 |
114 | Right aligned columns
115 |
116 | | Option | Description |
117 | | ------:| -----------:|
118 | | data | path to data files to supply the data that will be passed into templates. |
119 | | engine | engine to be used for processing templates. Handlebars is the default. |
120 | | ext | extension to be used for dest files. |
121 |
122 |
123 | ## Links
124 |
125 | [link text](http://dev.nodeca.com)
126 |
127 | [link with title](http://nodeca.github.io/pica/demo/ "title text!")
128 |
129 | Autoconverted link https://github.com/nodeca/pica (enable linkify to see)
130 |
131 |
132 | ## Images
133 |
134 | 
135 | 
136 |
137 | Like links, Images also have a footnote style syntax
138 |
139 | ![Alt text][id]
140 |
141 | With a reference later in the document defining the URL location:
142 |
143 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat"
144 |
145 |
146 | ## Plugins
147 |
148 | ### [\](https://github.com/markdown-it/markdown-it-mark)
149 |
150 | ==Marked text==
151 |
152 | ### [Custom containers](https://github.com/markdown-it/markdown-it-container)
153 |
154 | ::: warning
155 | *here be dragons*
156 | :::
157 |
--------------------------------------------------------------------------------
/example/src/extensions/container.js:
--------------------------------------------------------------------------------
1 | import { Node } from '@tiptap/core';
2 | import markdownitContainer from "markdown-it-container";
3 |
4 | export default Node.create({
5 | name: 'container',
6 |
7 | group: 'block',
8 |
9 | content: 'block+',
10 |
11 | defining: true,
12 |
13 | addOptions() {
14 | return {
15 | classes: ['info', 'warning', 'danger'],
16 | }
17 | },
18 |
19 | addStorage() {
20 | return {
21 | markdown: {
22 | serialize(state, node) {
23 | state.write("::: " + (node.attrs.containerClass || "") + "\n");
24 | state.renderContent(node);
25 | state.flushClose(1);
26 | state.write(":::");
27 | state.closeBlock(node);
28 | },
29 | parse: {
30 | setup(markdownit) {
31 | this.options.classes.forEach(className => {
32 | markdownit.use(markdownitContainer, className);
33 | });
34 | },
35 | }
36 | }
37 | }
38 | },
39 |
40 | addAttributes() {
41 | return {
42 | containerClass: {
43 | default: null,
44 | parseHTML: element => [...element.classList].find(className => this.options.classes.includes(className)),
45 | renderHTML: attributes => ({
46 | 'class': attributes.containerClass,
47 | }),
48 | }
49 | }
50 | },
51 |
52 | parseHTML() {
53 | return [
54 | {
55 | tag: 'div',
56 | getAttrs: element => {
57 | return [...element.classList].find(className => this.options.classes.includes(className)) ? null : false
58 | },
59 | },
60 | ]
61 | },
62 |
63 | renderHTML({ HTMLAttributes }) {
64 | return ['div', HTMLAttributes, 0]
65 | },
66 | });
67 |
--------------------------------------------------------------------------------
/example/src/extensions/highlight.js:
--------------------------------------------------------------------------------
1 | import { Highlight } from "@tiptap/extension-highlight";
2 | import markdownitMark from 'markdown-it-mark';
3 |
4 |
5 | export default Highlight.extend({
6 | addStorage() {
7 | return {
8 | markdown: {
9 | serialize: {
10 | open: '==',
11 | close: '==',
12 | },
13 | parse: {
14 | setup(markdownit) {
15 | markdownit.use(markdownitMark);
16 | },
17 | updateDOM() {
18 | // here you can update HTML generated by markdown-it
19 | }
20 | }
21 | }
22 | }
23 | }
24 | });
25 |
--------------------------------------------------------------------------------
/example/src/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 |
4 | createApp(App).mount('#app')
5 |
--------------------------------------------------------------------------------
/example/vite.config.js:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import { defineConfig } from 'vite';
3 | import vue from '@vitejs/plugin-vue';
4 | import analyzer from 'rollup-plugin-visualizer';
5 |
6 | export default defineConfig({
7 | plugins: [vue()],
8 | resolve: {
9 | alias: {
10 | 'tiptap-markdown': path.resolve(__dirname, '../src/index.js'),
11 | }
12 | },
13 | build: {
14 | rollupOptions: {
15 | plugins: [
16 | // analyzer(),
17 | ]
18 | }
19 | },
20 | })
21 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 | import { Editor, Extension } from "@tiptap/core";
4 | import { MarkdownSerializer, MarkdownSerializerState } from "prosemirror-markdown";
5 | import * as Prosemirror from "prosemirror-model";
6 | import * as MarkdownIt from "markdown-it";
7 |
8 | export interface MarkdownOptions {
9 | html?: Boolean,
10 | tightLists?: Boolean,
11 | tightListClass?: String,
12 | bulletListMarker?: String,
13 | linkify?: Boolean,
14 | breaks?: Boolean,
15 | transformPastedText?: Boolean,
16 | transformCopiedText?: Boolean,
17 | }
18 |
19 | export interface MarkdownStorage {
20 | options: MarkdownOptions,
21 | getMarkdown(): string,
22 | }
23 |
24 | type SpecContext = {
25 | options: Options,
26 | editor: Editor,
27 | }
28 |
29 | export type MarkdownNodeSpec = {
30 | serialize(this: SpecContext, state: MarkdownSerializerState, node: Prosemirror.Node, parent: Prosemirror.Node, index: number): void,
31 | parse?: {
32 | setup?(this: SpecContext, markdownit: MarkdownIt): void,
33 | updateDOM?(this: SpecContext, element: HTMLElement): void
34 | },
35 | }
36 |
37 | export type MarkdownMarkSpec = {
38 | serialize: typeof MarkdownSerializer.prototype.marks[string] & {
39 | open: string | ((this: SpecContext, state: MarkdownSerializerState, mark: Prosemirror.Mark, parent: Prosemirror.Node, index: number) => string);
40 | close: string | ((this: SpecContext, state: MarkdownSerializerState, mark: Prosemirror.Mark, parent: Prosemirror.Node, index: number) => string);
41 | },
42 | parse?: {
43 | setup?(this: SpecContext, markdownit: MarkdownIt): void,
44 | updateDOM?(this: SpecContext, element: HTMLElement): void
45 | },
46 | }
47 |
48 | export declare const Markdown: Extension;
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tiptap-markdown",
3 | "version": "0.8.10",
4 | "description": "Edit markdown content in tiptap editor.",
5 | "scripts": {
6 | "test": "vitest",
7 | "dev": "vite example",
8 | "build": "vite build",
9 | "build:example": "vite build example",
10 | "preview": "vite preview example",
11 | "preversion": "npm run build",
12 | "update:tiptap": "update-by-scope @tiptap"
13 | },
14 | "workspaces": [
15 | "example"
16 | ],
17 | "main": "dist/tiptap-markdown.umd.js",
18 | "module": "dist/tiptap-markdown.es.js",
19 | "type": "module",
20 | "sideEffects": false,
21 | "exports": {
22 | ".": {
23 | "import": "./dist/tiptap-markdown.es.js",
24 | "require": "./dist/tiptap-markdown.umd.js",
25 | "types": "./index.d.ts"
26 | }
27 | },
28 | "files": [
29 | "src",
30 | "dist",
31 | "index.d.ts"
32 | ],
33 | "browserslist": [
34 | "defaults",
35 | "not IE 11"
36 | ],
37 | "repository": {
38 | "type": "git",
39 | "url": "git+https://github.com/aguingand/tiptap-markdown.git"
40 | },
41 | "bugs": {
42 | "url": "https://github.com/aguingand/tiptap-markdown/issues"
43 | },
44 | "types": "index.d.ts",
45 | "author": "Antoine Guingand",
46 | "license": "MIT",
47 | "dependencies": {
48 | "@types/markdown-it": "^13.0.7",
49 | "markdown-it": "^14.1.0",
50 | "markdown-it-task-lists": "^2.1.1",
51 | "prosemirror-markdown": "^1.11.1"
52 | },
53 | "peerDependencies": {
54 | "@tiptap/core": "^2.0.3"
55 | },
56 | "devDependencies": {
57 | "@babel/core": "^7.23.2",
58 | "@babel/preset-env": "^7.23.2",
59 | "@rollup/plugin-babel": "^5.3.1",
60 | "@tiptap/core": "^2.2.6",
61 | "@tiptap/extension-highlight": "^2.2.6",
62 | "@tiptap/extension-image": "^2.2.6",
63 | "@tiptap/extension-link": "^2.2.6",
64 | "@tiptap/extension-table": "^2.2.6",
65 | "@tiptap/extension-table-cell": "^2.2.6",
66 | "@tiptap/extension-table-header": "^2.2.6",
67 | "@tiptap/extension-table-row": "^2.2.6",
68 | "@tiptap/extension-task-item": "^2.2.6",
69 | "@tiptap/extension-task-list": "^2.2.6",
70 | "@tiptap/extension-underline": "^2.2.6",
71 | "@tiptap/pm": "^2.2.6",
72 | "@tiptap/starter-kit": "^2.2.6",
73 | "@tiptap/vue-3": "^2.2.6",
74 | "@types/jest": "^28.1.7",
75 | "jest-serializer-html": "^7.1.0",
76 | "jsdom": "^22.1.0",
77 | "terser": "^5.24.0",
78 | "update-by-scope": "^1.1.3",
79 | "vite": "^5.2.8",
80 | "vitest": "^1.4.0"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Markdown.js:
--------------------------------------------------------------------------------
1 | import { Extension, extensions } from '@tiptap/core';
2 | import { MarkdownTightLists } from "./extensions/tiptap/tight-lists";
3 | import { MarkdownSerializer } from "./serialize/MarkdownSerializer";
4 | import { MarkdownParser } from "./parse/MarkdownParser";
5 | import { MarkdownClipboard } from "./extensions/tiptap/clipboard";
6 |
7 | export const Markdown = Extension.create({
8 | name: 'markdown',
9 | priority: 50,
10 | addOptions() {
11 | return {
12 | html: true,
13 | tightLists: true,
14 | tightListClass: 'tight',
15 | bulletListMarker: '-',
16 | linkify: false,
17 | breaks: false,
18 | transformPastedText: false,
19 | transformCopiedText: false,
20 | }
21 | },
22 | addCommands() {
23 | const commands = extensions.Commands.config.addCommands();
24 | return {
25 | setContent: (content, emitUpdate, parseOptions) => (props) => {
26 | return commands.setContent(
27 | props.editor.storage.markdown.parser.parse(content),
28 | emitUpdate,
29 | parseOptions
30 | )(props);
31 | },
32 | insertContentAt: (range, content, options) => (props) => {
33 | return commands.insertContentAt(
34 | range,
35 | props.editor.storage.markdown.parser.parse(content, { inline: true }),
36 | options
37 | )(props);
38 | },
39 | }
40 | },
41 | onBeforeCreate() {
42 | this.editor.storage.markdown = {
43 | options: { ...this.options },
44 | parser: new MarkdownParser(this.editor, this.options),
45 | serializer: new MarkdownSerializer(this.editor),
46 | getMarkdown: () => {
47 | return this.editor.storage.markdown.serializer.serialize(this.editor.state.doc);
48 | },
49 | }
50 | this.editor.options.initialContent = this.editor.options.content;
51 | this.editor.options.content = this.editor.storage.markdown.parser.parse(this.editor.options.content);
52 | },
53 | onCreate() {
54 | this.editor.options.content = this.editor.options.initialContent;
55 | delete this.editor.options.initialContent;
56 | },
57 | addStorage() {
58 | return {
59 | /// storage will be defined in onBeforeCreate() to prevent initial object overriding
60 | }
61 | },
62 | addExtensions() {
63 | return [
64 | MarkdownTightLists.configure({
65 | tight: this.options.tightLists,
66 | tightClass: this.options.tightListClass,
67 | }),
68 | MarkdownClipboard.configure({
69 | transformPastedText: this.options.transformPastedText,
70 | transformCopiedText: this.options.transformCopiedText,
71 | }),
72 | ]
73 | },
74 | });
75 |
--------------------------------------------------------------------------------
/src/extensions/index.js:
--------------------------------------------------------------------------------
1 | import Blockquote from "./nodes/blockquote";
2 | import BulletList from "./nodes/bullet-list";
3 | import CodeBlock from "./nodes/code-block";
4 | import HardBreak from "./nodes/hard-break";
5 | import Heading from "./nodes/heading";
6 | import HorizontalRule from "./nodes/horizontal-rule";
7 | import HTMLNode from "./nodes/html";
8 | import Image from "./nodes/image";
9 | import ListItem from "./nodes/list-item";
10 | import OrderedList from "./nodes/ordered-list";
11 | import Paragraph from "./nodes/paragraph";
12 | import Table from "./nodes/table";
13 | import TaskItem from "./nodes/task-item";
14 | import TaskList from "./nodes/task-list";
15 | import Text from "./nodes/text";
16 |
17 | import Bold from "./marks/bold";
18 | import Code from "./marks/code";
19 | import HTMLMark from "./marks/html";
20 | import Italic from "./marks/italic";
21 | import Link from "./marks/link";
22 | import Strike from "./marks/strike";
23 |
24 |
25 | export default [
26 | Blockquote,
27 | BulletList,
28 | CodeBlock,
29 | HardBreak,
30 | Heading,
31 | HorizontalRule,
32 | HTMLNode,
33 | Image,
34 | ListItem,
35 | OrderedList,
36 | Paragraph,
37 | Table,
38 | TaskItem,
39 | TaskList,
40 | Text,
41 |
42 | Bold,
43 | Code,
44 | HTMLMark,
45 | Italic,
46 | Link,
47 | Strike,
48 | ]
49 |
--------------------------------------------------------------------------------
/src/extensions/marks/bold.js:
--------------------------------------------------------------------------------
1 | import { Mark } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Bold = Mark.create({
6 | name: 'bold',
7 | });
8 |
9 | export default Bold.extend({
10 | /**
11 | * @return {{markdown: MarkdownMarkSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.marks.strong,
17 | parse: {
18 | // handled by markdown-it
19 | }
20 | },
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/marks/code.js:
--------------------------------------------------------------------------------
1 | import { Mark } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Code = Mark.create({
6 | name: 'code',
7 | });
8 |
9 | export default Code.extend({
10 | /**
11 | * @return {{markdown: MarkdownMarkSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.marks.code,
17 | parse: {
18 | // handled by markdown-it
19 | }
20 | }
21 | }
22 | }
23 | })
24 |
--------------------------------------------------------------------------------
/src/extensions/marks/html.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from "@tiptap/pm/model";
2 | import { getHTMLFromFragment, Mark } from "@tiptap/core";
3 |
4 |
5 | export default Mark.create({
6 | name: 'markdownHTMLMark',
7 | /**
8 | * @return {{markdown: MarkdownMarkSpec}}
9 | */
10 | addStorage() {
11 | return {
12 | markdown: {
13 | serialize: {
14 | open(state, mark) {
15 | if(!this.editor.storage.markdown.options.html) {
16 | console.warn(`Tiptap Markdown: "${mark.type.name}" mark is only available in html mode`);
17 | return '';
18 | }
19 | return getMarkTags(mark)?.[0] ?? '';
20 | },
21 | close(state, mark) {
22 | if(!this.editor.storage.markdown.options.html) {
23 | return '';
24 | }
25 | return getMarkTags(mark)?.[1] ?? '';
26 | },
27 | },
28 | parse: {
29 | // handled by markdown-it
30 | }
31 | }
32 | }
33 | }
34 | });
35 |
36 | function getMarkTags(mark) {
37 | const schema = mark.type.schema;
38 | const node = schema.text(' ', [mark]);
39 | const html = getHTMLFromFragment(Fragment.from(node), schema);
40 | const match = html.match(/^(<.*?>) (<\/.*?>)$/);
41 | return match ? [match[1], match[2]] : null;
42 | }
43 |
--------------------------------------------------------------------------------
/src/extensions/marks/italic.js:
--------------------------------------------------------------------------------
1 | import { Mark } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Italic = Mark.create({
6 | name: 'italic',
7 | });
8 |
9 | export default Italic.extend({
10 | /**
11 | * @return {{markdown: MarkdownMarkSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.marks.em,
17 | parse: {
18 | // handled by markdown-it
19 | }
20 | }
21 | }
22 | }
23 | })
24 |
--------------------------------------------------------------------------------
/src/extensions/marks/link.js:
--------------------------------------------------------------------------------
1 | import { Mark } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Link = Mark.create({
6 | name: 'link',
7 | });
8 |
9 | export default Link.extend({
10 | /**
11 | * @return {{markdown: MarkdownMarkSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.marks.link,
17 | parse: {
18 | // handled by markdown-it
19 | }
20 | }
21 | }
22 | }
23 | })
24 |
--------------------------------------------------------------------------------
/src/extensions/marks/strike.js:
--------------------------------------------------------------------------------
1 | import { Mark } from "@tiptap/core";
2 |
3 |
4 | const Strike = Mark.create({
5 | name: 'strike',
6 | });
7 |
8 | export default Strike.extend({
9 | /**
10 | * @return {{markdown: MarkdownMarkSpec}}
11 | */
12 | addStorage() {
13 | return {
14 | markdown: {
15 | serialize: {open: '~~', close: '~~', expelEnclosingWhitespace: true},
16 | parse: {
17 | // handled by markdown-it
18 | },
19 | },
20 | }
21 | }
22 | });
23 |
--------------------------------------------------------------------------------
/src/extensions/nodes/blockquote.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Blockquote = Node.create({
6 | name: 'blockquote',
7 | });
8 |
9 | export default Blockquote.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.blockquote,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | }
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/bullet-list.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 |
3 |
4 | const BulletList = Node.create({
5 | name: 'bulletList',
6 | });
7 |
8 | export default BulletList.extend({
9 | /**
10 | * @return {{markdown: MarkdownNodeSpec}}
11 | */
12 | addStorage() {
13 | return {
14 | markdown: {
15 | serialize(state, node) {
16 | return state.renderList(node, " ", () => (this.editor.storage.markdown.options.bulletListMarker || "-") + " ");
17 | },
18 | parse: {
19 | // handled by markdown-it
20 | },
21 | }
22 | }
23 | }
24 | });
25 |
--------------------------------------------------------------------------------
/src/extensions/nodes/code-block.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 |
3 |
4 | const CodeBlock = Node.create({
5 | name: 'codeBlock',
6 | });
7 |
8 | export default CodeBlock.extend({
9 | /**
10 | * @return {{markdown: MarkdownNodeSpec}}
11 | */
12 | addStorage() {
13 | return {
14 | markdown: {
15 | serialize(state, node) {
16 | state.write("```" + (node.attrs.language || "") + "\n");
17 | state.text(node.textContent, false);
18 | state.ensureNewLine();
19 | state.write("```");
20 | state.closeBlock(node);
21 | },
22 | parse: {
23 | setup(markdownit) {
24 | markdownit.set({
25 | langPrefix: this.options.languageClassPrefix ?? 'language-',
26 | });
27 | },
28 | updateDOM(element) {
29 | element.innerHTML = element.innerHTML.replace(/\n<\/code><\/pre>/g, '')
30 | },
31 | },
32 | }
33 | }
34 | }
35 | });
36 |
--------------------------------------------------------------------------------
/src/extensions/nodes/hard-break.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import HTMLNode from './html';
3 |
4 | const HardBreak = Node.create({
5 | name: 'hardBreak',
6 | });
7 |
8 | export default HardBreak.extend({
9 | /**
10 | * @return {{markdown: MarkdownNodeSpec}}
11 | */
12 | addStorage() {
13 | return {
14 | markdown: {
15 | serialize(state, node, parent, index) {
16 | for (let i = index + 1; i < parent.childCount; i++)
17 | if (parent.child(i).type != node.type) {
18 | state.write(
19 | state.inTable
20 | ? HTMLNode.storage.markdown.serialize.call(this, state, node, parent)
21 | : "\\\n"
22 | );
23 | return;
24 | }
25 | },
26 | parse: {
27 | // handled by markdown-it
28 | },
29 | }
30 | }
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/src/extensions/nodes/heading.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Heading = Node.create({
6 | name: 'heading',
7 | });
8 |
9 | export default Heading.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.heading,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | }
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/horizontal-rule.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const HorizontalRule = Node.create({
6 | name: 'horizontalRule',
7 | });
8 |
9 | export default HorizontalRule.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.horizontal_rule,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | }
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/html.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from "@tiptap/pm/model";
2 | import { getHTMLFromFragment, Node } from "@tiptap/core";
3 | import { elementFromString } from "../../util/dom";
4 |
5 |
6 | export default Node.create({
7 | name: 'markdownHTMLNode',
8 | addStorage() {
9 | return {
10 | markdown: {
11 | serialize(state, node, parent) {
12 | if(this.editor.storage.markdown.options.html) {
13 | state.write(serializeHTML(node, parent));
14 | } else {
15 | console.warn(`Tiptap Markdown: "${node.type.name}" node is only available in html mode`);
16 | state.write(`[${node.type.name}]`);
17 | }
18 | if(node.isBlock) {
19 | state.closeBlock(node);
20 | }
21 | },
22 | parse: {
23 | // handled by markdown-it
24 | },
25 | },
26 | }
27 | }
28 | });
29 |
30 | function serializeHTML(node, parent) {
31 | const schema = node.type.schema;
32 | const html = getHTMLFromFragment(Fragment.from(node), schema);
33 |
34 | if(node.isBlock && (parent instanceof Fragment || parent.type.name === schema.topNodeType.name)) {
35 | return formatBlock(html);
36 | }
37 |
38 | return html;
39 | }
40 |
41 | /**
42 | * format html block as per the commonmark spec
43 | */
44 | function formatBlock(html) {
45 | const dom = elementFromString(html);
46 | const element = dom.firstElementChild;
47 |
48 | element.innerHTML = element.innerHTML.trim()
49 | ? `\n${element.innerHTML}\n`
50 | : `\n`;
51 |
52 | return element.outerHTML;
53 | }
54 |
--------------------------------------------------------------------------------
/src/extensions/nodes/image.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Image = Node.create({
6 | name: 'image',
7 | });
8 |
9 | export default Image.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.image,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | }
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/list-item.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const ListItem = Node.create({
6 | name: 'listItem',
7 | });
8 |
9 | export default ListItem.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.list_item,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | }
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/ordered-list.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 |
3 |
4 | const OrderedList = Node.create({
5 | name: 'orderedList',
6 | });
7 |
8 | function findIndexOfAdjacentNode(node, parent, index) {
9 | let i = 0;
10 | for (; index - i > 0; i++) {
11 | if (parent.child(index - i - 1).type.name !== node.type.name) {
12 | break;
13 | }
14 | }
15 | return i;
16 | }
17 |
18 | export default OrderedList.extend({
19 | /**
20 | * @return {{markdown: MarkdownNodeSpec}}
21 | */
22 | addStorage() {
23 | return {
24 | markdown: {
25 | serialize(state, node, parent, index) {
26 | const start = node.attrs.start || 1
27 | const maxW = String(start + node.childCount - 1).length
28 | const space = state.repeat(" ", maxW + 2)
29 | const adjacentIndex = findIndexOfAdjacentNode(node, parent, index);
30 | const separator = adjacentIndex % 2 ? ') ' : '. ';
31 | state.renderList(node, space, i => {
32 | const nStr = String(start + i)
33 | return state.repeat(" ", maxW - nStr.length) + nStr + separator;
34 | })
35 | },
36 | parse: {
37 | // handled by markdown-it
38 | },
39 | }
40 | }
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/src/extensions/nodes/paragraph.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { defaultMarkdownSerializer } from "prosemirror-markdown";
3 |
4 |
5 | const Paragraph = Node.create({
6 | name: 'paragraph',
7 | });
8 |
9 | export default Paragraph.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize: defaultMarkdownSerializer.nodes.paragraph,
17 | parse: {
18 | // handled by markdown-it
19 | },
20 | },
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/extensions/nodes/table.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { childNodes } from "../../util/prosemirror";
3 | import HTMLNode from './html';
4 |
5 | const Table = Node.create({
6 | name: 'table',
7 | });
8 |
9 | export default Table.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize(state, node, parent) {
17 | if(!isMarkdownSerializable(node)) {
18 | HTMLNode.storage.markdown.serialize.call(this, state, node, parent);
19 | return;
20 | }
21 | state.inTable = true;
22 | node.forEach((row, p, i) => {
23 | state.write('| ');
24 | row.forEach((col, p, j) => {
25 | if(j) {
26 | state.write(' | ');
27 | }
28 | const cellContent = col.firstChild;
29 | if(cellContent.textContent.trim()) {
30 | state.renderInline(cellContent);
31 | }
32 | });
33 | state.write(' |')
34 | state.ensureNewLine();
35 | if(!i) {
36 | const delimiterRow = Array.from({length: row.childCount}).map(() => '---').join(' | ');
37 | state.write(`| ${delimiterRow} |`);
38 | state.ensureNewLine();
39 | }
40 | });
41 | state.closeBlock(node);
42 | state.inTable = false;
43 | },
44 | parse: {
45 | // handled by markdown-it
46 | },
47 | }
48 | }
49 | }
50 | })
51 |
52 |
53 | function hasSpan(node) {
54 | return node.attrs.colspan > 1 || node.attrs.rowspan > 1;
55 | }
56 |
57 | function isMarkdownSerializable(node) {
58 | const rows = childNodes(node);
59 | const firstRow = rows[0];
60 | const bodyRows = rows.slice(1);
61 |
62 | if(childNodes(firstRow).some(cell => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1)) {
63 | return false;
64 | }
65 |
66 | if(bodyRows.some(row =>
67 | childNodes(row).some(cell => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1)
68 | )) {
69 | return false;
70 | }
71 |
72 | return true;
73 | }
74 |
--------------------------------------------------------------------------------
/src/extensions/nodes/task-item.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 |
3 |
4 | const TaskItem = Node.create({
5 | name: 'taskItem',
6 | });
7 |
8 | export default TaskItem.extend({
9 | /**
10 | * @return {{markdown: MarkdownNodeSpec}}
11 | */
12 | addStorage() {
13 | return {
14 | markdown: {
15 | serialize(state, node) {
16 | const check = node.attrs.checked ? '[x]' : '[ ]';
17 | state.write(`${check} `);
18 | state.renderContent(node);
19 | },
20 | parse: {
21 | updateDOM(element) {
22 | [...element.querySelectorAll('.task-list-item')]
23 | .forEach(item => {
24 | const input = item.querySelector('input');
25 | item.setAttribute('data-type', 'taskItem');
26 | if(input) {
27 | item.setAttribute('data-checked', input.checked);
28 | input.remove();
29 | }
30 | });
31 | },
32 | }
33 | }
34 | }
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/src/extensions/nodes/task-list.js:
--------------------------------------------------------------------------------
1 | import taskListPlugin from "markdown-it-task-lists";
2 | import { Node } from "@tiptap/core";
3 | import BulletList from "./bullet-list";
4 |
5 |
6 | const TaskList = Node.create({
7 | name: 'taskList',
8 | });
9 |
10 | export default TaskList.extend({
11 | /**
12 | * @return {{markdown: MarkdownNodeSpec}}
13 | */
14 | addStorage() {
15 | return {
16 | markdown: {
17 | serialize: BulletList.storage.markdown.serialize,
18 | parse: {
19 | setup(markdownit) {
20 | markdownit.use(taskListPlugin);
21 | },
22 | updateDOM(element) {
23 | [...element.querySelectorAll('.contains-task-list')]
24 | .forEach(list => {
25 | list.setAttribute('data-type', 'taskList');
26 | });
27 | },
28 | }
29 | }
30 | }
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/src/extensions/nodes/text.js:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/core";
2 | import { escapeHTML } from "../../util/dom";
3 |
4 |
5 | const Text = Node.create({
6 | name: 'text',
7 | });
8 |
9 | export default Text.extend({
10 | /**
11 | * @return {{markdown: MarkdownNodeSpec}}
12 | */
13 | addStorage() {
14 | return {
15 | markdown: {
16 | serialize(state, node) {
17 | state.text(escapeHTML(node.text));
18 | },
19 | parse: {
20 | // handled by markdown-it
21 | },
22 | }
23 | }
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/src/extensions/tiptap/clipboard.js:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 | import { Plugin, PluginKey } from '@tiptap/pm/state';
3 | import { DOMParser } from '@tiptap/pm/model';
4 | import { elementFromString } from "../../util/dom";
5 |
6 | export const MarkdownClipboard = Extension.create({
7 | name: 'markdownClipboard',
8 | addOptions() {
9 | return {
10 | transformPastedText: false,
11 | transformCopiedText: false,
12 | }
13 | },
14 | addProseMirrorPlugins() {
15 | return [
16 | new Plugin({
17 | key: new PluginKey('markdownClipboard'),
18 | props: {
19 | clipboardTextParser: (text, context, plainText) => {
20 | if(plainText || !this.options.transformPastedText) {
21 | return null; // pasting with shift key prevents formatting
22 | }
23 | const parsed = this.editor.storage.markdown.parser.parse(text, { inline: true });
24 | return DOMParser.fromSchema(this.editor.schema)
25 | .parseSlice(elementFromString(parsed), {
26 | preserveWhitespace: true,
27 | context,
28 | });
29 | },
30 | /**
31 | * @param {import('prosemirror-model').Slice} slice
32 | */
33 | clipboardTextSerializer: (slice) => {
34 | if(!this.options.transformCopiedText) {
35 | return null;
36 | }
37 | return this.editor.storage.markdown.serializer.serialize(slice.content);
38 | },
39 | },
40 | })
41 | ]
42 | }
43 | })
44 |
--------------------------------------------------------------------------------
/src/extensions/tiptap/tight-lists.js:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 |
3 | export const MarkdownTightLists = Extension.create({
4 | name: 'markdownTightLists',
5 | addOptions: () => ({
6 | tight: true,
7 | tightClass: 'tight',
8 | listTypes: [
9 | 'bulletList',
10 | 'orderedList',
11 | ],
12 | }),
13 | addGlobalAttributes() {
14 | return [
15 | {
16 | types: this.options.listTypes,
17 | attributes: {
18 | tight: {
19 | default: this.options.tight,
20 | parseHTML: element =>
21 | element.getAttribute('data-tight') === 'true' || !element.querySelector('p'),
22 | renderHTML: attributes => ({
23 | class: attributes.tight ? this.options.tightClass : null,
24 | 'data-tight': attributes.tight ? 'true' : null,
25 | }),
26 | },
27 | },
28 | },
29 | ]
30 | },
31 | addCommands() {
32 | return {
33 | toggleTight: (tight = null) => ({ editor, commands }) => {
34 | function toggleTight(name) {
35 | if(!editor.isActive(name)) {
36 | return false;
37 | }
38 | const attrs = editor.getAttributes(name);
39 | return commands.updateAttributes(name, {
40 | tight: tight ?? !attrs?.tight,
41 | });
42 | }
43 | return this.options.listTypes
44 | .some(name => toggleTight(name));
45 | }
46 | }
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | export { Markdown } from './Markdown';
5 |
--------------------------------------------------------------------------------
/src/parse/MarkdownParser.js:
--------------------------------------------------------------------------------
1 | import markdownit from "markdown-it";
2 | import { elementFromString, extractElement, unwrapElement } from "../util/dom";
3 | import { getMarkdownSpec } from "../util/extensions";
4 |
5 | export class MarkdownParser {
6 | /**
7 | * @type {import('@tiptap/core').Editor}
8 | */
9 | editor = null;
10 | /**
11 | * @type {markdownit}
12 | */
13 | md = null;
14 |
15 | constructor(editor, { html, linkify, breaks }) {
16 | this.editor = editor;
17 | this.md = this.withPatchedRenderer(markdownit({
18 | html,
19 | linkify,
20 | breaks,
21 | }));
22 | }
23 |
24 | parse(content, { inline } = {}) {
25 | if(typeof content === 'string') {
26 | this.editor.extensionManager.extensions.forEach(extension =>
27 | getMarkdownSpec(extension)?.parse?.setup?.call({ editor:this.editor, options:extension.options }, this.md)
28 | );
29 |
30 | const renderedHTML = this.md.render(content);
31 | const element = elementFromString(renderedHTML);
32 |
33 | this.editor.extensionManager.extensions.forEach(extension =>
34 | getMarkdownSpec(extension)?.parse?.updateDOM?.call({ editor:this.editor, options:extension.options }, element)
35 | );
36 |
37 | this.normalizeDOM(element, { inline, content });
38 |
39 | return element.innerHTML;
40 | }
41 |
42 | return content;
43 | }
44 |
45 | normalizeDOM(node, { inline, content }) {
46 | this.normalizeBlocks(node);
47 |
48 | // remove all \n appended by markdown-it
49 | node.querySelectorAll('*').forEach(el => {
50 | if(el.nextSibling?.nodeType === Node.TEXT_NODE && !el.closest('pre')) {
51 | el.nextSibling.textContent = el.nextSibling.textContent.replace(/^\n/, '');
52 | }
53 | });
54 |
55 | if(inline) {
56 | this.normalizeInline(node, content);
57 | }
58 |
59 | return node;
60 | }
61 |
62 | normalizeBlocks(node) {
63 | const blocks = Object.values(this.editor.schema.nodes)
64 | .filter(node => node.isBlock);
65 |
66 | const selector = blocks
67 | .map(block => block.spec.parseDOM?.map(spec => spec.tag))
68 | .flat()
69 | .filter(Boolean)
70 | .join(',');
71 |
72 | if(!selector) {
73 | return;
74 | }
75 |
76 | [...node.querySelectorAll(selector)].forEach(el => {
77 | if(el.parentElement.matches('p')) {
78 | extractElement(el);
79 | }
80 | });
81 | }
82 |
83 | normalizeInline(node, content) {
84 | if(node.firstElementChild?.matches('p')) {
85 | const firstParagraph = node.firstElementChild;
86 | const { nextElementSibling } = firstParagraph;
87 | const startSpaces = content.match(/^\s+/)?.[0] ?? '';
88 | const endSpaces = !nextElementSibling
89 | ? content.match(/\s+$/)?.[0] ?? ''
90 | : '';
91 |
92 | if(content.match(/^\n\n/)) {
93 | firstParagraph.innerHTML = `${firstParagraph.innerHTML}${endSpaces}`;
94 | return;
95 | }
96 |
97 | unwrapElement(firstParagraph);
98 |
99 | node.innerHTML = `${startSpaces}${node.innerHTML}${endSpaces}`;
100 | }
101 | }
102 |
103 | /**
104 | * @param {markdownit} md
105 | */
106 | withPatchedRenderer(md) {
107 | const withoutNewLine = (renderer) => (...args) => {
108 | const rendered = renderer(...args);
109 | if(rendered === '\n') {
110 | return rendered; // keep soft breaks
111 | }
112 | if(rendered[rendered.length - 1] === '\n') {
113 | return rendered.slice(0, -1);
114 | }
115 | return rendered;
116 | }
117 |
118 | md.renderer.rules.hardbreak = withoutNewLine(md.renderer.rules.hardbreak);
119 | md.renderer.rules.softbreak = withoutNewLine(md.renderer.rules.softbreak);
120 | md.renderer.rules.fence = withoutNewLine(md.renderer.rules.fence);
121 | md.renderer.rules.code_block = withoutNewLine(md.renderer.rules.code_block);
122 | md.renderer.renderToken = withoutNewLine(md.renderer.renderToken.bind(md.renderer));
123 |
124 | return md;
125 | }
126 | }
127 |
128 |
--------------------------------------------------------------------------------
/src/serialize/MarkdownSerializer.js:
--------------------------------------------------------------------------------
1 | import { MarkdownSerializerState } from './state';
2 | import HTMLMark from "../extensions/marks/html";
3 | import HTMLNode from "../extensions/nodes/html";
4 | import { getMarkdownSpec } from "../util/extensions";
5 | import HardBreak from "../extensions/nodes/hard-break";
6 |
7 |
8 | export class MarkdownSerializer {
9 | /**
10 | * @type {import('@tiptap/core').Editor}
11 | */
12 | editor = null;
13 |
14 | constructor(editor) {
15 | this.editor = editor;
16 | }
17 |
18 | serialize(content) {
19 | const state = new MarkdownSerializerState(this.nodes, this.marks, {
20 | hardBreakNodeName: HardBreak.name,
21 | });
22 |
23 | state.renderContent(content);
24 |
25 | return state.out;
26 | }
27 |
28 | get nodes() {
29 | return {
30 | ...Object.fromEntries(
31 | Object.keys(this.editor.schema.nodes)
32 | .map(name => [name, this.serializeNode(HTMLNode)])
33 | ),
34 | ...Object.fromEntries(
35 | this.editor.extensionManager.extensions
36 | .filter(extension => extension.type === 'node' && this.serializeNode(extension))
37 | .map(extension => [extension.name, this.serializeNode(extension)])
38 | ?? []
39 | ),
40 | };
41 | }
42 |
43 | get marks() {
44 | return {
45 | ...Object.fromEntries(
46 | Object.keys(this.editor.schema.marks)
47 | .map(name => [name, this.serializeMark(HTMLMark)])
48 | ),
49 | ...Object.fromEntries(
50 | this.editor.extensionManager.extensions
51 | .filter(extension => extension.type === 'mark' && this.serializeMark(extension))
52 | .map(extension => [extension.name, this.serializeMark(extension)])
53 | ?? []
54 | ),
55 | };
56 | }
57 |
58 | serializeNode(node) {
59 | return getMarkdownSpec(node)?.serialize?.bind({ editor: this.editor, options: node.options });
60 | }
61 |
62 | serializeMark(mark) {
63 | const serialize = getMarkdownSpec(mark)?.serialize;
64 | return serialize
65 | ? {
66 | ...serialize,
67 | open: typeof serialize.open === 'function' ? serialize.open.bind({ editor: this.editor, options: mark.options }) : serialize.open,
68 | close: typeof serialize.close === 'function' ? serialize.close.bind({ editor: this.editor, options: mark.options }) : serialize.close,
69 | }
70 | : null
71 | }
72 | }
73 |
74 |
--------------------------------------------------------------------------------
/src/serialize/state.js:
--------------------------------------------------------------------------------
1 | import { MarkdownSerializerState as BaseMarkdownSerializerState } from "prosemirror-markdown";
2 | import { trimInline } from "../util/markdown";
3 |
4 |
5 | /**
6 | * Override default MarkdownSerializerState to:
7 | * - handle commonmark delimiters (https://spec.commonmark.org/0.29/#left-flanking-delimiter-run)
8 | */
9 | export class MarkdownSerializerState extends BaseMarkdownSerializerState {
10 |
11 | inTable = false;
12 |
13 | constructor(nodes, marks, options) {
14 | super(nodes, marks, options ?? {});
15 | this.inlines = [];
16 | }
17 |
18 | render(node, parent, index) {
19 | super.render(node, parent, index);
20 | const top = this.inlines[this.inlines.length - 1];
21 | if(top?.start && top?.end) {
22 | const { delimiter, start, end } = this.normalizeInline(top);
23 | this.out = trimInline(this.out, delimiter, start, end);
24 | this.inlines.pop();
25 | }
26 | }
27 |
28 | markString(mark, open, parent, index) {
29 | const info = this.marks[mark.type.name]
30 | if(info.expelEnclosingWhitespace) {
31 | if(open) {
32 | this.inlines.push({
33 | start: this.out.length,
34 | delimiter: info.open,
35 | });
36 | } else {
37 | const top = this.inlines.pop();
38 | this.inlines.push({
39 | ...top,
40 | end: this.out.length,
41 | });
42 | }
43 | }
44 | return super.markString(mark, open, parent, index);
45 | }
46 |
47 | normalizeInline(inline) {
48 | let { start, end } = inline;
49 | while(this.out.charAt(start).match(/\s/)) {
50 | start++;
51 | }
52 | return {
53 | ...inline,
54 | start,
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/util/dom.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | export function elementFromString(value) {
4 | // add a wrapper to preserve leading and trailing whitespace
5 | const wrappedValue = `${value}`
6 |
7 | return new window.DOMParser().parseFromString(wrappedValue, 'text/html').body
8 | }
9 |
10 | export function escapeHTML(value) {
11 | return value
12 | ?.replace(//g, '>');
14 | }
15 |
16 | export function extractElement(node) {
17 | const parent = node.parentElement;
18 | const prepend = parent.cloneNode();
19 |
20 | while(parent.firstChild && parent.firstChild !== node) {
21 | prepend.appendChild(parent.firstChild);
22 | }
23 |
24 | if(prepend.childNodes.length > 0) {
25 | parent.parentElement.insertBefore(prepend, parent);
26 | }
27 | parent.parentElement.insertBefore(node, parent);
28 | if(parent.childNodes.length === 0) {
29 | parent.remove();
30 | }
31 | }
32 |
33 | export function unwrapElement(node) {
34 | const parent = node.parentNode;
35 |
36 | while (node.firstChild) parent.insertBefore(node.firstChild, node);
37 |
38 | parent.removeChild(node);
39 | }
40 |
--------------------------------------------------------------------------------
/src/util/extensions.js:
--------------------------------------------------------------------------------
1 | import markdownExtensions from "../extensions";
2 |
3 |
4 | export function getMarkdownSpec(extension) {
5 | const markdownSpec = extension.storage?.markdown;
6 | const defaultMarkdownSpec = markdownExtensions.find(e => e.name === extension.name)?.storage.markdown;
7 |
8 | if(markdownSpec || defaultMarkdownSpec) {
9 | return {
10 | ...defaultMarkdownSpec,
11 | ...markdownSpec,
12 | };
13 | }
14 |
15 | return null;
16 | }
17 |
--------------------------------------------------------------------------------
/src/util/markdown.js:
--------------------------------------------------------------------------------
1 | import markdownit from 'markdown-it';
2 |
3 | const md = markdownit();
4 |
5 | function scanDelims(text, pos) {
6 | md.inline.State.prototype.scanDelims.call({ src: text, posMax: text.length })
7 | const state = new (md.inline.State)(text, null, null, []);
8 | return state.scanDelims(pos, true);
9 | }
10 |
11 | export function shiftDelim(text, delim, start, offset) {
12 | let res = text.substring(0, start) + text.substring(start + delim.length);
13 | res = res.substring(0, start + offset) + delim + res.substring(start + offset);
14 | return res;
15 | }
16 |
17 | function trimStart(text, delim, from, to) {
18 | let pos = from, res = text;
19 | while(pos < to) {
20 | if(scanDelims(res, pos).can_open) {
21 | break;
22 | }
23 | res = shiftDelim(res, delim, pos, 1);
24 | pos++;
25 | }
26 | return { text: res, from: pos, to }
27 | }
28 |
29 | function trimEnd(text, delim, from, to) {
30 | let pos = to, res = text;
31 | while(pos > from) {
32 | if(scanDelims(res, pos).can_close) {
33 | break;
34 | }
35 | res = shiftDelim(res, delim, pos, -1);
36 | pos--;
37 | }
38 | return { text: res, from, to: pos }
39 | }
40 |
41 | export function trimInline(text, delim, from, to) {
42 | let state = {
43 | text,
44 | from,
45 | to,
46 | }
47 |
48 | state = trimStart(state.text, delim, state.from, state.to);
49 | state = trimEnd(state.text, delim, state.from, state.to);
50 |
51 | if(state.to - state.from < delim.length + 1) {
52 | state.text = state.text.substring(0, state.from) + state.text.substring(state.to + delim.length);
53 | }
54 |
55 | return state.text;
56 | }
57 |
--------------------------------------------------------------------------------
/src/util/prosemirror.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | export function childNodes(node) {
4 | return node?.content?.content ?? [];
5 | }
6 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | ///
2 | import * as path from 'node:path';
3 | import { defineConfig } from 'vite';
4 | import { babel } from '@rollup/plugin-babel';
5 | import packageJson from './package.json'
6 |
7 | export default defineConfig(() => {
8 | const deps = {
9 | ...(packageJson.dependencies || {}),
10 | ...(packageJson.peerDependencies || {}),
11 | }
12 | return {
13 | build: {
14 | lib: {
15 | entry: path.resolve(__dirname, 'src/index.js'),
16 | name: 'tiptap-markdown',
17 | fileName: (format) => `tiptap-markdown.${format}.js`,
18 | formats: ['es', 'umd'],
19 | },
20 | rollupOptions: {
21 | external: [
22 | ...Object.keys(deps),
23 | /^@tiptap/,
24 | ],
25 | },
26 | sourcemap: true,
27 | minify: false,
28 | },
29 | plugins: [
30 | babel({
31 | babelHelpers: 'bundled',
32 | exclude: 'node_modules/**',
33 | }),
34 | ],
35 | test: {
36 | environment: 'jsdom',
37 | setupFiles: [
38 | path.resolve(__dirname, '__tests__/utils/setup.js'),
39 | path.resolve(__dirname, '__tests__/utils/setup-dom.js'),
40 | ],
41 | }
42 | }
43 | });
44 |
--------------------------------------------------------------------------------