Handle html in markdown during conversion to prosemirror nodes
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -19710,6 +19710,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tiptap/pm": "^3.20.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"marked": "^15.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/pm": "^3.20.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"marked": "^15.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
270
packages/prosemirror/src/htmlBlock.ts
Normal file
270
packages/prosemirror/src/htmlBlock.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
// Use of this source code is governed by the ISC license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import type { Token } from "marked";
|
||||
import type {
|
||||
Mark as ProseMirrorMark,
|
||||
Node as ProseMirrorNode,
|
||||
Schema,
|
||||
} from "@tiptap/pm/model";
|
||||
|
||||
const blockOpenTagPattern =
|
||||
/^<(ul|ol|table|blockquote|pre|div|h[1-6]|hr)[\s>\/]/i;
|
||||
|
||||
const blockTags = new Set([
|
||||
"p", "div", "ul", "ol", "blockquote", "pre", "table",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||
]);
|
||||
|
||||
export function safeLinkHref(href: string): string {
|
||||
href = href.trim();
|
||||
if (!href) return "#";
|
||||
if (href[0] === "#") return href;
|
||||
if (href.startsWith("/")) {
|
||||
if (href.length > 1 && (href[1] === "/" || href[1] === "\\")) return "#";
|
||||
return href;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
||||
switch (scheme) {
|
||||
case "http":
|
||||
case "https":
|
||||
case "mailto":
|
||||
case "tel":
|
||||
return href;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
export function cellHasBlockHTML(tokens: Token[]): boolean {
|
||||
return tokens.some(
|
||||
t => t.type === "html" && blockOpenTagPattern.test(t.raw),
|
||||
);
|
||||
}
|
||||
|
||||
export function convertHTMLBlockContent(
|
||||
html: string,
|
||||
schema: Schema,
|
||||
): ProseMirrorNode[] {
|
||||
const sanitized = DOMPurify.sanitize(html).trim();
|
||||
if (!sanitized) return [];
|
||||
const doc = new DOMParser().parseFromString(sanitized, "text/html");
|
||||
const c = new HTMLBlockConverter(schema);
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
for (const child of Array.from(doc.body.childNodes)) {
|
||||
nodes.push(...c.convertBlockNode(child));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
class HTMLBlockConverter {
|
||||
private schema: Schema;
|
||||
private marks: ProseMirrorMark[];
|
||||
|
||||
constructor(schema: Schema) {
|
||||
this.schema = schema;
|
||||
this.marks = [];
|
||||
}
|
||||
|
||||
private withMark(
|
||||
mark: ProseMirrorMark,
|
||||
fn: () => ProseMirrorNode[],
|
||||
): ProseMirrorNode[] {
|
||||
this.marks.push(mark);
|
||||
const result = fn();
|
||||
this.marks.pop();
|
||||
return result;
|
||||
}
|
||||
|
||||
private currentMarks(): readonly ProseMirrorMark[] {
|
||||
let result: readonly ProseMirrorMark[] = [];
|
||||
for (const mark of this.marks) {
|
||||
result = mark.addToSet(result as ProseMirrorMark[]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
convertBlockNode(node: Node): ProseMirrorNode[] {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent?.trim();
|
||||
if (!text) return [];
|
||||
return [
|
||||
this.schema.nodes.paragraph.create(null, this.schema.text(text)),
|
||||
];
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return [];
|
||||
|
||||
const el = node as Element;
|
||||
switch (el.tagName.toLowerCase()) {
|
||||
case "ul":
|
||||
return this.convertList(el, false);
|
||||
case "ol":
|
||||
return this.convertList(el, true);
|
||||
case "blockquote": {
|
||||
const children = this.convertBlockChildren(el);
|
||||
if (children.length === 0) return [];
|
||||
return [this.schema.nodes.blockquote.create(null, children)];
|
||||
}
|
||||
case "pre": {
|
||||
const codeEl = el.querySelector("code");
|
||||
const text = codeEl?.textContent ?? el.textContent ?? "";
|
||||
const lang =
|
||||
codeEl?.className?.match(/language-(\w+)/)?.[1] ?? null;
|
||||
return [
|
||||
this.schema.nodes.codeBlock.create(
|
||||
{ language: lang },
|
||||
text ? this.schema.text(text) : undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "hr":
|
||||
return [this.schema.nodes.horizontalRule.create()];
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6": {
|
||||
const level = parseInt(el.tagName[1]);
|
||||
const inlines = this.convertInlineChildren(el);
|
||||
return [
|
||||
this.schema.nodes.heading.create(
|
||||
{ level },
|
||||
inlines.length > 0 ? inlines : undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
default: {
|
||||
const inlines = this.convertInlineChildren(el);
|
||||
if (inlines.length === 0) return [];
|
||||
return [this.schema.nodes.paragraph.create(null, inlines)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private convertBlockChildren(parent: Node): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
nodes.push(...this.convertBlockNode(child));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private convertList(
|
||||
el: Element,
|
||||
ordered: boolean,
|
||||
): ProseMirrorNode[] {
|
||||
const items: ProseMirrorNode[] = [];
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (child.tagName.toLowerCase() !== "li") continue;
|
||||
const content = this.convertListItem(child);
|
||||
items.push(
|
||||
this.schema.nodes.listItem.create(
|
||||
null,
|
||||
content.length > 0
|
||||
? content
|
||||
: this.schema.nodes.paragraph.create(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (items.length === 0) return [];
|
||||
|
||||
if (ordered) {
|
||||
const start = parseInt(el.getAttribute("start") ?? "1") || 1;
|
||||
return [this.schema.nodes.orderedList.create({ start }, items)];
|
||||
}
|
||||
return [this.schema.nodes.bulletList.create(null, items)];
|
||||
}
|
||||
|
||||
private convertListItem(el: Element): ProseMirrorNode[] {
|
||||
const hasBlock = Array.from(el.children).some(child =>
|
||||
blockTags.has(child.tagName.toLowerCase()),
|
||||
);
|
||||
|
||||
if (hasBlock) {
|
||||
return this.convertBlockChildren(el);
|
||||
}
|
||||
|
||||
const inlines = this.convertInlineChildren(el);
|
||||
if (inlines.length === 0) return [];
|
||||
return [this.schema.nodes.paragraph.create(null, inlines)];
|
||||
}
|
||||
|
||||
private convertInlineChildren(parent: Node): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
nodes.push(...this.convertInlineNode(child));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private convertInlineNode(node: Node): ProseMirrorNode[] {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? "";
|
||||
if (!text) return [];
|
||||
const marks = this.currentMarks();
|
||||
return [this.schema.text(text, marks.length > 0 ? marks : undefined)];
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return [];
|
||||
|
||||
const el = node as Element;
|
||||
switch (el.tagName.toLowerCase()) {
|
||||
case "strong":
|
||||
case "b":
|
||||
return this.withMark(this.schema.marks.bold.create(), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
case "em":
|
||||
case "i":
|
||||
return this.withMark(this.schema.marks.italic.create(), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
case "s":
|
||||
case "del":
|
||||
case "strike":
|
||||
return this.withMark(this.schema.marks.strike.create(), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
case "u":
|
||||
return this.withMark(this.schema.marks.underline.create(), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
case "code":
|
||||
return this.withMark(this.schema.marks.code.create(), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "#";
|
||||
const attrs: Record<string, unknown> = {
|
||||
href: safeLinkHref(href),
|
||||
};
|
||||
const title = el.getAttribute("title");
|
||||
if (title) attrs.title = title;
|
||||
return this.withMark(this.schema.marks.link.create(attrs), () =>
|
||||
this.convertInlineChildren(el),
|
||||
);
|
||||
}
|
||||
case "br":
|
||||
return [this.schema.nodes.hardBreak.create()];
|
||||
case "img": {
|
||||
const src = el.getAttribute("src");
|
||||
if (!src) return [];
|
||||
const attrs: Record<string, unknown> = { src };
|
||||
const alt = el.getAttribute("alt");
|
||||
const title = el.getAttribute("title");
|
||||
if (alt) attrs.alt = alt;
|
||||
if (title) attrs.title = title;
|
||||
return [this.schema.nodes.image.create(attrs)];
|
||||
}
|
||||
default:
|
||||
return this.convertInlineChildren(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
Schema,
|
||||
} from "@tiptap/pm/model";
|
||||
|
||||
import {
|
||||
cellHasBlockHTML,
|
||||
convertHTMLBlockContent,
|
||||
safeLinkHref,
|
||||
} from "./htmlBlock";
|
||||
|
||||
const tableSeparatorPattern =
|
||||
/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/m;
|
||||
const markdownLinePattern =
|
||||
@@ -23,31 +29,6 @@ function unescapeHtml(html: string): string {
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function safeLinkHref(href: string): string {
|
||||
href = href.trim();
|
||||
if (!href) return "#";
|
||||
if (href[0] === "#") return href;
|
||||
if (href.startsWith("/")) {
|
||||
if (href.length > 1 && (href[1] === "/" || href[1] === "\\")) return "#";
|
||||
return href;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
||||
switch (scheme) {
|
||||
case "http":
|
||||
case "https":
|
||||
case "mailto":
|
||||
case "tel":
|
||||
return href;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
type HtmlTagInfo = {
|
||||
closing: boolean;
|
||||
markName: string;
|
||||
@@ -296,10 +277,19 @@ class Converter {
|
||||
cell: Tokens.TableCell,
|
||||
isHeader: boolean,
|
||||
): ProseMirrorNode {
|
||||
const content = this.convertInlineTokens(cell.tokens);
|
||||
const nodeType = isHeader
|
||||
? this.schema.nodes.tableHeader
|
||||
: this.schema.nodes.tableCell;
|
||||
|
||||
if (cellHasBlockHTML(cell.tokens)) {
|
||||
const raw = cell.tokens.map(t => t.raw).join("");
|
||||
const blockContent = convertHTMLBlockContent(raw, this.schema);
|
||||
if (blockContent.length > 0) {
|
||||
return nodeType.create(null, blockContent);
|
||||
}
|
||||
}
|
||||
|
||||
const content = this.convertInlineTokens(cell.tokens);
|
||||
return nodeType.create(
|
||||
null,
|
||||
this.schema.nodes.paragraph.create(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user