Move markdown to prosemirror parser in packages

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-03 11:46:12 +04:00
parent 3561a6e0b7
commit a850b0f77c
7 changed files with 350 additions and 298 deletions

View File

@@ -2,308 +2,13 @@
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { hasMarkdown, parseMarkdown } from "@probo/prosemirror";
import { Extension } from "@tiptap/core";
import {
Fragment,
type Node as ProseMirrorNode,
type Schema,
Slice,
} from "@tiptap/pm/model";
import { Fragment, Slice } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
const markdownPasteKey = new PluginKey("markdownPaste");
const codeBlockOpenPattern = /^```(\w*)$/;
const tableSeparatorPattern
= /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/m;
const markdownLinePattern
= /(?:^```\w*$|^#{1,6}\s|^>\s|^[-+*]\s|^\d+\.\s|^(?:---|___|\*\*\*)\s*$)/m;
function parseCells(line: string): string[] {
return line
.replace(/^\|/, "")
.replace(/\|\s*$/, "")
.split("|")
.map(cell => cell.trim());
}
function applyMarks(
schema: Schema,
markNames: string[],
inner: string,
): ProseMirrorNode[] {
const marks = markNames.map(name => schema.marks[name].create());
return parseInlineContent(schema, inner).map((node) => {
let combined = node.marks;
for (const mark of marks) {
combined = mark.addToSet(combined);
}
return node.mark(combined);
});
}
function parseInlineContent(
schema: Schema,
text: string,
): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
const pattern
= /`([^`]+)`|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|~~(.+?)~~|<u>(.+?)<\/u>|\[([^\]]+)\]\(([^)]+)\)|\*(.+?)\*/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(schema.text(text.slice(lastIndex, match.index)));
}
if (match[1] !== undefined) {
nodes.push(schema.text(match[1], [schema.marks.code.create()]));
} else if (match[2] !== undefined) {
nodes.push(...applyMarks(schema, ["bold", "italic"], match[2]));
} else if (match[3] !== undefined) {
nodes.push(...applyMarks(schema, ["bold"], match[3]));
} else if (match[4] !== undefined) {
nodes.push(...applyMarks(schema, ["strike"], match[4]));
} else if (match[5] !== undefined) {
nodes.push(...applyMarks(schema, ["underline"], match[5]));
} else if (match[6] !== undefined) {
const linkMark = schema.marks.link.create({ href: match[7] });
for (const node of parseInlineContent(schema, match[6])) {
nodes.push(node.mark(linkMark.addToSet(node.marks)));
}
} else if (match[8] !== undefined) {
nodes.push(...applyMarks(schema, ["italic"], match[8]));
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(schema.text(text.slice(lastIndex)));
}
return nodes;
}
function buildTable(
schema: Schema,
headerLine: string,
dataLines: string[],
): ProseMirrorNode {
const headers = parseCells(headerLine);
const columnCount = headers.length;
const headerCells = headers.map((h) => {
const content = h ? parseInlineContent(schema, h) : [];
return schema.nodes.tableHeader.create(
null,
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
);
});
const tableRows: ProseMirrorNode[] = [
schema.nodes.tableRow.create(null, headerCells),
];
for (const line of dataLines) {
const cells = parseCells(line);
const rowCells: ProseMirrorNode[] = [];
for (let c = 0; c < columnCount; c++) {
const value = cells[c]?.trim() ?? "";
const content = value ? parseInlineContent(schema, value) : [];
rowCells.push(
schema.nodes.tableCell.create(
null,
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
),
);
}
tableRows.push(schema.nodes.tableRow.create(null, rowCells));
}
return schema.nodes.table.create(null, tableRows);
}
function hasMarkdown(text: string): boolean {
return markdownLinePattern.test(text) || tableSeparatorPattern.test(text);
}
function parseMarkdown(
text: string,
schema: Schema,
): ProseMirrorNode[] {
const lines = text.split("\n");
const nodes: ProseMirrorNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code blocks
const codeMatch = line.match(codeBlockOpenPattern);
if (codeMatch) {
const language = codeMatch[1] || null;
const contentLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
contentLines.push(lines[i]);
i++;
}
i++;
const codeContent = contentLines.join("\n").replace(/\n$/, "");
nodes.push(
schema.nodes.codeBlock.create(
{ language },
codeContent ? schema.text(codeContent) : undefined,
),
);
continue;
}
// Tables
if (
line.includes("|")
&& i + 1 < lines.length
&& tableSeparatorPattern.test(lines[i + 1].trim())
) {
const headerLine = line;
i += 2;
const dataLines: string[] = [];
while (i < lines.length) {
const row = lines[i].trim();
if (!row || !row.includes("|")) break;
dataLines.push(lines[i]);
i++;
}
nodes.push(buildTable(schema, headerLine, dataLines));
continue;
}
// Horizontal rules (before bullet lists since --- / *** could overlap)
const trimmed = line.trim();
if (trimmed === "---" || trimmed === "___" || trimmed === "***") {
nodes.push(schema.nodes.horizontalRule.create());
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const content = parseInlineContent(schema, headingMatch[2]);
nodes.push(
schema.nodes.heading.create(
{ level },
content.length > 0 ? content : undefined,
),
);
i++;
continue;
}
// Blockquotes (group consecutive lines)
if (line.startsWith("> ")) {
const paragraphs: ProseMirrorNode[] = [];
while (i < lines.length) {
const bqMatch = lines[i].match(/^>\s(.*)$/);
if (!bqMatch) break;
const bqText = bqMatch[1];
const content = bqText
? parseInlineContent(schema, bqText)
: [];
paragraphs.push(
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
);
i++;
}
nodes.push(schema.nodes.blockquote.create(null, paragraphs));
continue;
}
// Bullet lists (group consecutive items)
if (
line.startsWith("- ")
|| line.startsWith("+ ")
|| line.startsWith("* ")
) {
const items: ProseMirrorNode[] = [];
while (i < lines.length) {
const blMatch = lines[i].match(/^[-+*]\s(.*)$/);
if (!blMatch) break;
const blText = blMatch[1];
const content = blText
? parseInlineContent(schema, blText)
: [];
items.push(
schema.nodes.listItem.create(
null,
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
),
);
i++;
}
nodes.push(schema.nodes.bulletList.create(null, items));
continue;
}
// Ordered lists (group consecutive items)
const olMatch = line.match(/^(\d+)\.\s(.*)$/);
if (olMatch) {
const start = +olMatch[1];
const items: ProseMirrorNode[] = [];
while (i < lines.length) {
const itemMatch = lines[i].match(/^\d+\.\s(.*)$/);
if (!itemMatch) break;
const olText = itemMatch[1];
const content = olText
? parseInlineContent(schema, olText)
: [];
items.push(
schema.nodes.listItem.create(
null,
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
),
);
i++;
}
nodes.push(schema.nodes.orderedList.create({ start }, items));
continue;
}
// Plain paragraph
if (line.trim()) {
const content = parseInlineContent(schema, line);
nodes.push(
schema.nodes.paragraph.create(
null,
content.length > 0 ? content : undefined,
),
);
}
i++;
}
return nodes;
}
export const MarkdownPasteExtension = Extension.create({
name: "markdownPaste",