@paroicms/markdown-to-tiptap-json 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @paroicms/markdown-to-tiptap-json
2
+
3
+ A lightweight JavaScript/TypeScript library for converting Markdown to Tiptap JSON format **without loading the Tiptap editor**. This package uses `markdown-it` for parsing, making it efficient and suitable for server-side operations where you need to convert Markdown to Tiptap JSON without the overhead of loading the full editor.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @paroicms/markdown-to-tiptap-json
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { convertMarkdownToTiptap } from "@paroicms/markdown-to-tiptap-json";
15
+
16
+ const markdown = `# Hello World
17
+
18
+ This is a paragraph with **bold** and *italic* text.
19
+
20
+ - List item 1
21
+ - List item 2
22
+ - List item 3`;
23
+
24
+ const { result, issues } = convertMarkdownToTiptap(markdown);
25
+
26
+ console.log(JSON.stringify(result, null, 2));
27
+ // {
28
+ // "type": "doc",
29
+ // "content": [
30
+ // {
31
+ // "type": "heading",
32
+ // "attrs": { "level": 1 },
33
+ // "content": [{ "type": "text", "text": "Hello World" }]
34
+ // },
35
+ // {
36
+ // "type": "paragraph",
37
+ // "content": [
38
+ // { "type": "text", "text": "This is a paragraph with " },
39
+ // { "type": "text", "text": "bold", "marks": [{ "type": "bold" }] },
40
+ // { "type": "text", "text": " and " },
41
+ // { "type": "text", "text": "italic", "marks": [{ "type": "italic" }] },
42
+ // { "type": "text", "text": " text." }
43
+ // ]
44
+ // },
45
+ // {
46
+ // "type": "bulletList",
47
+ // "content": [
48
+ // { "type": "listItem", "content": [...] },
49
+ // { "type": "listItem", "content": [...] },
50
+ // { "type": "listItem", "content": [...] }
51
+ // ]
52
+ // }
53
+ // ]
54
+ // }
55
+
56
+ // Check for any issues during conversion
57
+ if (issues) {
58
+ console.warn("Conversion issues:", issues);
59
+ }
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `convertMarkdownToTiptap(markdown: string): ConversionResult`
65
+
66
+ Converts a Markdown string to Tiptap JSON format.
67
+
68
+ **Parameters:**
69
+
70
+ - `markdown`: The Markdown string to convert
71
+
72
+ **Returns:** `ConversionResult` object containing:
73
+
74
+ - `result`: The Tiptap JSON document (type `TiptapJsonValue`)
75
+ - `issues` (optional): Array of warning messages for skipped or problematic content
76
+
77
+ ## Supported Markdown Features
78
+
79
+ ### Block Elements
80
+
81
+ - **Headings**: `# H1` through `###### H6`
82
+ - **Paragraphs**: Regular text blocks
83
+ - **Blockquotes**: `> Quote text`
84
+ - **Code blocks**: Fenced (` ``` `) and indented
85
+ - **Horizontal rules**: `---`, `***`, or `___`
86
+ - **Lists**: Bullet (`-`, `*`, `+`) and ordered (`1.`, `2.`, etc.)
87
+ - **Nested lists**: Multi-level list structures
88
+ - **Tables**: GitHub-flavored markdown tables
89
+
90
+ ### Inline Elements
91
+
92
+ - **Bold**: `**bold**` or `__bold__`
93
+ - **Italic**: `*italic*` or `_italic_`
94
+ - **Strikethrough**: `~~strikethrough~~`
95
+ - **Inline code**: `` `code` ``
96
+ - **Links**: `[text](url)`
97
+ - **Images**: `![alt](url)`
98
+ - **Hard breaks**: Two spaces at end of line
99
+
100
+ ### Special Handling
101
+
102
+ - **HTML blocks and inline HTML**: Skipped with warnings in `issues` array
103
+ - **Empty documents**: Returns empty doc with no issues
104
+ - **Malformed markdown**: Returns minimal valid doc with error in `issues`
105
+
106
+ ## License
107
+
108
+ MIT
109
+
110
+ ## Related Packages
111
+
112
+ This package can be used as a standalone library. It's also part of the ParoiCMS ecosystem. For more information, visit the [ParoiCMS repository](https://gitlab.com/paroi/opensource/paroicms).
@@ -0,0 +1,6 @@
1
+ import type { TiptapJsonValue } from "./types.js";
2
+ export interface ConversionResult {
3
+ result: TiptapJsonValue;
4
+ issues?: string[];
5
+ }
6
+ export declare function convertMarkdownToTiptap(markdown: string): ConversionResult;
@@ -0,0 +1,291 @@
1
+ import MarkdownIt from "markdown-it";
2
+ export function convertMarkdownToTiptap(markdown) {
3
+ if (markdown === "") {
4
+ return { result: { type: "doc", content: [] } };
5
+ }
6
+ const md = new MarkdownIt({ html: true });
7
+ const issues = [];
8
+ try {
9
+ const tokens = md.parse(markdown, {});
10
+ const content = processTokens(tokens, issues);
11
+ return {
12
+ result: { type: "doc", content },
13
+ issues: issues.length > 0 ? issues : undefined,
14
+ };
15
+ }
16
+ catch (error) {
17
+ issues.push(`Failed to parse markdown: ${error instanceof Error ? error.message : String(error)}`);
18
+ return {
19
+ result: { type: "doc", content: [{ type: "paragraph" }] },
20
+ issues,
21
+ };
22
+ }
23
+ }
24
+ function processTokens(tokens, issues) {
25
+ const nodes = [];
26
+ let i = 0;
27
+ while (i < tokens.length) {
28
+ const token = tokens[i];
29
+ if (token.type === "heading_open") {
30
+ const level = Number.parseInt(token.tag.slice(1), 10);
31
+ const inlineToken = tokens[i + 1];
32
+ const content = inlineToken ? processInlineTokens(inlineToken.children ?? [], issues) : [];
33
+ nodes.push({ type: "heading", attrs: { level }, content });
34
+ i += 3; // Skip heading_open, inline, heading_close
35
+ }
36
+ else if (token.type === "paragraph_open") {
37
+ const inlineToken = tokens[i + 1];
38
+ const content = inlineToken ? processInlineTokens(inlineToken.children ?? [], issues) : [];
39
+ nodes.push({ type: "paragraph", content });
40
+ i += 3; // Skip paragraph_open, inline, paragraph_close
41
+ }
42
+ else if (token.type === "blockquote_open") {
43
+ const { node, consumed } = processBlockquote(tokens, i, issues);
44
+ nodes.push(node);
45
+ i += consumed;
46
+ }
47
+ else if (token.type === "bullet_list_open") {
48
+ const { node, consumed } = processList(tokens, i, "bulletList", issues);
49
+ nodes.push(node);
50
+ i += consumed;
51
+ }
52
+ else if (token.type === "ordered_list_open") {
53
+ const { node, consumed } = processList(tokens, i, "orderedList", issues);
54
+ nodes.push(node);
55
+ i += consumed;
56
+ }
57
+ else if (token.type === "hr") {
58
+ nodes.push({ type: "horizontalRule" });
59
+ ++i;
60
+ }
61
+ else if (token.type === "code_block" || token.type === "fence") {
62
+ const language = token.info || undefined;
63
+ const attrs = language ? { language } : undefined;
64
+ nodes.push({
65
+ type: "codeBlock",
66
+ attrs,
67
+ content: [{ type: "text", text: token.content }],
68
+ });
69
+ ++i;
70
+ }
71
+ else if (token.type === "table_open") {
72
+ const { node, consumed } = processTable(tokens, i, issues);
73
+ nodes.push(node);
74
+ i += consumed;
75
+ }
76
+ else if (token.type === "html_block") {
77
+ const preview = token.content.substring(0, 50);
78
+ issues.push(`Skipped HTML block: ${preview}${token.content.length > 50 ? "..." : ""}`);
79
+ ++i;
80
+ }
81
+ else {
82
+ ++i;
83
+ }
84
+ }
85
+ return nodes;
86
+ }
87
+ function processInlineTokens(tokens, issues) {
88
+ const nodes = [];
89
+ const marksStack = [];
90
+ for (let i = 0; i < tokens.length; ++i) {
91
+ const token = tokens[i];
92
+ if (token.type === "text") {
93
+ // Skip empty text tokens (they occur with nested formatting)
94
+ if (token.content === "")
95
+ continue;
96
+ nodes.push({
97
+ type: "text",
98
+ text: token.content,
99
+ marks: marksStack.length > 0 ? [...marksStack] : undefined,
100
+ });
101
+ }
102
+ else if (token.type === "code_inline") {
103
+ nodes.push({
104
+ type: "text",
105
+ text: token.content,
106
+ marks: [...marksStack, { type: "code" }],
107
+ });
108
+ }
109
+ else if (token.type === "hardbreak") {
110
+ nodes.push({ type: "hardBreak" });
111
+ }
112
+ else if (token.type === "strong_open") {
113
+ marksStack.push({ type: "bold" });
114
+ }
115
+ else if (token.type === "strong_close") {
116
+ removeMarkFromStack(marksStack, "bold");
117
+ }
118
+ else if (token.type === "em_open") {
119
+ marksStack.push({ type: "italic" });
120
+ }
121
+ else if (token.type === "em_close") {
122
+ removeMarkFromStack(marksStack, "italic");
123
+ }
124
+ else if (token.type === "s_open") {
125
+ marksStack.push({ type: "strike" });
126
+ }
127
+ else if (token.type === "s_close") {
128
+ removeMarkFromStack(marksStack, "strike");
129
+ }
130
+ else if (token.type === "link_open") {
131
+ const href = token.attrGet("href");
132
+ if (href) {
133
+ marksStack.push({ type: "link", attrs: { href } });
134
+ }
135
+ }
136
+ else if (token.type === "link_close") {
137
+ removeMarkFromStack(marksStack, "link");
138
+ }
139
+ else if (token.type === "image") {
140
+ const src = token.attrGet("src");
141
+ const alt = token.content || undefined;
142
+ if (src) {
143
+ const attrs = { src };
144
+ if (alt) {
145
+ attrs.alt = alt;
146
+ }
147
+ nodes.push({ type: "image", attrs });
148
+ }
149
+ }
150
+ else if (token.type === "html_inline") {
151
+ const preview = token.content.substring(0, 50);
152
+ issues.push(`Skipped HTML inline: ${preview}${token.content.length > 50 ? "..." : ""}`);
153
+ }
154
+ }
155
+ return nodes;
156
+ }
157
+ function processBlockquote(tokens, startIndex, issues) {
158
+ let i = startIndex + 1; // Skip blockquote_open
159
+ const content = [];
160
+ while (i < tokens.length && tokens[i].type !== "blockquote_close") {
161
+ if (tokens[i].type === "paragraph_open") {
162
+ const inlineToken = tokens[i + 1];
163
+ const paragraphContent = inlineToken
164
+ ? processInlineTokens(inlineToken.children ?? [], issues)
165
+ : [];
166
+ content.push({ type: "paragraph", content: paragraphContent });
167
+ i += 3; // Skip paragraph_open, inline, paragraph_close
168
+ }
169
+ else {
170
+ ++i;
171
+ }
172
+ }
173
+ return {
174
+ node: { type: "blockquote", content },
175
+ consumed: i - startIndex + 1,
176
+ };
177
+ }
178
+ function processList(tokens, startIndex, listType, issues) {
179
+ let i = startIndex + 1; // Skip list_open
180
+ const content = [];
181
+ while (i < tokens.length &&
182
+ tokens[i].type !== "bullet_list_close" &&
183
+ tokens[i].type !== "ordered_list_close") {
184
+ if (tokens[i].type === "list_item_open") {
185
+ const { node, consumed } = processListItem(tokens, i, issues);
186
+ content.push(node);
187
+ i += consumed;
188
+ }
189
+ else {
190
+ ++i;
191
+ }
192
+ }
193
+ return {
194
+ node: { type: listType, content },
195
+ consumed: i - startIndex + 1,
196
+ };
197
+ }
198
+ function processListItem(tokens, startIndex, issues) {
199
+ let i = startIndex + 1; // Skip list_item_open
200
+ const content = [];
201
+ while (i < tokens.length && tokens[i].type !== "list_item_close") {
202
+ const token = tokens[i];
203
+ if (token.type === "paragraph_open") {
204
+ const inlineToken = tokens[i + 1];
205
+ const paragraphContent = inlineToken
206
+ ? processInlineTokens(inlineToken.children ?? [], issues)
207
+ : [];
208
+ content.push({ type: "paragraph", content: paragraphContent });
209
+ i += 3; // Skip paragraph_open, inline, paragraph_close
210
+ }
211
+ else if (token.type === "bullet_list_open") {
212
+ const { node, consumed } = processList(tokens, i, "bulletList", issues);
213
+ content.push(node);
214
+ i += consumed;
215
+ }
216
+ else if (token.type === "ordered_list_open") {
217
+ const { node, consumed } = processList(tokens, i, "orderedList", issues);
218
+ content.push(node);
219
+ i += consumed;
220
+ }
221
+ else {
222
+ ++i;
223
+ }
224
+ }
225
+ return {
226
+ node: { type: "listItem", content },
227
+ consumed: i - startIndex + 1,
228
+ };
229
+ }
230
+ function processTable(tokens, startIndex, issues) {
231
+ let i = startIndex + 1; // Skip table_open
232
+ const content = [];
233
+ while (i < tokens.length && tokens[i].type !== "table_close") {
234
+ if (tokens[i].type === "thead_open" || tokens[i].type === "tbody_open") {
235
+ const isHeader = tokens[i].type === "thead_open";
236
+ ++i; // Skip thead_open or tbody_open
237
+ while (i < tokens.length &&
238
+ tokens[i].type !== "thead_close" &&
239
+ tokens[i].type !== "tbody_close") {
240
+ if (tokens[i].type === "tr_open") {
241
+ const { node, consumed } = processTableRow(tokens, i, isHeader, issues);
242
+ content.push(node);
243
+ i += consumed;
244
+ }
245
+ else {
246
+ ++i;
247
+ }
248
+ }
249
+ ++i; // Skip thead_close or tbody_close
250
+ }
251
+ else {
252
+ ++i;
253
+ }
254
+ }
255
+ return {
256
+ node: { type: "table", content },
257
+ consumed: i - startIndex + 1,
258
+ };
259
+ }
260
+ function processTableRow(tokens, startIndex, isHeader, issues) {
261
+ let i = startIndex + 1; // Skip tr_open
262
+ const content = [];
263
+ while (i < tokens.length && tokens[i].type !== "tr_close") {
264
+ if (tokens[i].type === "th_open" || tokens[i].type === "td_open") {
265
+ const cellType = isHeader || tokens[i].type === "th_open" ? "tableHeader" : "tableCell";
266
+ const inlineToken = tokens[i + 1];
267
+ const cellContent = inlineToken
268
+ ? processInlineTokens(inlineToken.children ?? [], issues)
269
+ : [];
270
+ // Table cells must contain at least one paragraph
271
+ content.push({
272
+ type: cellType,
273
+ content: [{ type: "paragraph", content: cellContent }],
274
+ });
275
+ i += 3; // Skip th_open/td_open, inline, th_close/td_close
276
+ }
277
+ else {
278
+ ++i;
279
+ }
280
+ }
281
+ return {
282
+ node: { type: "tableRow", content },
283
+ consumed: i - startIndex + 1,
284
+ };
285
+ }
286
+ function removeMarkFromStack(marksStack, markType) {
287
+ const index = marksStack.findIndex((mark) => mark.type === markType);
288
+ if (index !== -1) {
289
+ marksStack.splice(index, 1);
290
+ }
291
+ }
@@ -0,0 +1,3 @@
1
+ export { convertMarkdownToTiptap } from "./converter.js";
2
+ export type { ConversionResult } from "./converter.js";
3
+ export type { TiptapJsonMark, TiptapJsonNode, TiptapJsonValue } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { convertMarkdownToTiptap } from "./converter.js";
@@ -0,0 +1,15 @@
1
+ export interface TiptapJsonValue {
2
+ type: "doc";
3
+ content?: TiptapJsonNode[];
4
+ }
5
+ export interface TiptapJsonNode {
6
+ type: string;
7
+ attrs?: Record<string, any>;
8
+ content?: TiptapJsonNode[];
9
+ marks?: TiptapJsonMark[];
10
+ text?: string;
11
+ }
12
+ export interface TiptapJsonMark {
13
+ type: string;
14
+ attrs?: Record<string, any>;
15
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@paroicms/markdown-to-tiptap-json",
3
+ "version": "0.2.0",
4
+ "description": "Convert Markdown to Tiptap JSON",
5
+ "keywords": [
6
+ "markdown",
7
+ "tiptap",
8
+ "json",
9
+ "converter"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://gitlab.com/paroi/opensource/paroicms.git",
14
+ "directory": "packages/markdown-to-tiptap-json"
15
+ },
16
+ "author": "Paroi Team",
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "main": "dist/index.js",
20
+ "typings": "dist/index.d.ts",
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "clear": "rimraf dist/*",
24
+ "dev": "tsc --watch --preserveWatchOutput",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest"
27
+ },
28
+ "dependencies": {
29
+ "markdown-it": "~14.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/markdown-it": "~14.1.2",
33
+ "rimraf": "~6.0.1",
34
+ "typescript": "~5.9.3",
35
+ "vitest": "~3.2.4"
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ]
40
+ }