// src/Extension.ts
import {
commands,
Extension
} from "@tiptap/core";
// src/MarkdownManager.ts
import {
attrsEqual as attrsEqual2,
callOrReturn,
decodeHtmlEntities,
encodeHtmlEntities,
flattenExtensions,
generateJSON,
getExtensionField,
getSchema,
marksEqual,
sortExtensions
} from "@tiptap/core";
import { marked } from "marked";
// src/utils.ts
import { attrsEqual } from "@tiptap/core";
function wrapInMarkdownBlock(prefix, content) {
const lines = content.split("\n");
const output = lines.flatMap((line) => [line, ""]).map((line) => `${prefix}${line}`).join("\n");
return output.slice(0, output.length - 1);
}
function findMarksToClose(currentMarks, nextNode) {
const marksToClose = [];
Array.from(currentMarks.entries()).forEach(([markType, currentMark]) => {
if (!nextNode) {
marksToClose.push(markType);
return;
}
const nextMark = (nextNode.marks || []).find(
(mark) => mark.type === markType && attrsEqual(mark.attrs, currentMark.attrs)
);
if (!nextMark) {
marksToClose.push(markType);
}
});
return marksToClose;
}
function findMarksToOpen(activeMarks, currentMarks) {
const marksToOpen = [];
Array.from(currentMarks.entries()).forEach(([markType, mark]) => {
const activeMark = activeMarks.get(markType);
if (!activeMark || !attrsEqual(activeMark.attrs, mark.attrs)) {
marksToOpen.push({ type: markType, mark });
}
});
return marksToOpen;
}
function findMarksToCloseAtEnd(activeMarks, currentMarks, nextNode, markSetsEqual) {
const isLastNode = !nextNode;
const nextNodeHasNoMarks = nextNode && (!nextNode.marks || nextNode.marks.length === 0);
const nextNodeHasDifferentMarks = nextNode && nextNode.marks && !markSetsEqual(currentMarks, new Map(nextNode.marks.map((mark) => [mark.type, mark])));
const marksToCloseAtEnd = [];
if (isLastNode || nextNodeHasNoMarks || nextNodeHasDifferentMarks) {
if (nextNode && nextNode.marks) {
Array.from(activeMarks.entries()).reverse().forEach(([markType, activeMark]) => {
const nextMark = nextNode.marks.find(
(m) => m.type === markType && attrsEqual(m.attrs, activeMark.attrs)
);
if (!nextMark) {
marksToCloseAtEnd.push(markType);
}
});
} else if (isLastNode || nextNodeHasNoMarks) {
marksToCloseAtEnd.push(...Array.from(activeMarks.keys()).reverse());
}
}
return marksToCloseAtEnd;
}
function closeMarksBeforeNode(activeMarks, getMarkClosing) {
let beforeMarkdown = "";
Array.from(activeMarks.keys()).reverse().forEach((markType) => {
const mark = activeMarks.get(markType);
const closeMarkdown = getMarkClosing(markType, mark);
if (closeMarkdown) {
beforeMarkdown = closeMarkdown + beforeMarkdown;
}
});
activeMarks.clear();
return beforeMarkdown;
}
function reopenMarksAfterNode(marksToReopen, activeMarks, getMarkOpening) {
let afterMarkdown = "";
Array.from(marksToReopen.entries()).forEach(([markType, mark]) => {
const openMarkdown = getMarkOpening(markType, mark);
if (openMarkdown) {
afterMarkdown += openMarkdown;
}
activeMarks.set(markType, mark);
});
return afterMarkdown;
}
function isTaskItem(item) {
const raw = item.raw || item.text || "";
const match = raw.match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/);
if (match) {
return { isTask: true, checked: match[2].toLowerCase() === "x", indentLevel: match[1].length };
}
return { isTask: false, indentLevel: 0 };
}
function assumeContentType(content, contentType) {
if (typeof content !== "string") {
return "json";
}
return contentType;
}
// src/MarkdownManager.ts
var isHtmlUnknownElement = (element) => {
const ctor = window.HTMLUnknownElement;
return typeof ctor === "function" && element instanceof ctor;
};
var MarkdownManager = class {
/**
* Create a MarkdownManager.
* @param options.marked Optional marked instance to use (injected).
* @param options.markedOptions Optional options to pass to marked.setOptions
* @param options.indentation Indentation settings (style and size).
* @param options.extensions An array of Tiptap extensions to register for markdown parsing and rendering.
*/
constructor(options) {
this.activeParseLexer = null;
/**
* Order in which extensions were registered. Used to resolve mark nesting
* deterministically when several marks open on the same text node.
*
* The flattened extensions passed to the manager are pre-sorted by Tiptap's
* extension priority (descending), which is also the order ProseMirror uses
* to assign mark ranks. Recording that index here lets the serializer place
* higher-priority / lower-rank marks (e.g. link with priority 1000) on the
* outside without inspecting any rendered markdown output.
*/
this.extensionRanks = /* @__PURE__ */ new Map();
this.baseExtensions = [];
this.extensions = [];
/** Set of extension names whose `code` spec property is truthy (nodes and marks). */
this.codeTypes = /* @__PURE__ */ new Set();
/** Lazy cache of tag names declared by the registered schema's parseDOM rules. */
this.schemaParseDomTagsCache = null;
this.lastParseResult = null;
var _a, _b, _c, _d, _e;
this.markedInstance = (_a = options == null ? void 0 : options.marked) != null ? _a : marked;
this.indentStyle = (_c = (_b = options == null ? void 0 : options.indentation) == null ? void 0 : _b.style) != null ? _c : "space";
this.indentSize = (_e = (_d = options == null ? void 0 : options.indentation) == null ? void 0 : _d.size) != null ? _e : 2;
this.baseExtensions = (options == null ? void 0 : options.extensions) || [];
if ((options == null ? void 0 : options.markedOptions) && typeof this.markedInstance.setOptions === "function") {
this.markedInstance.setOptions(options.markedOptions);
}
this.registry = /* @__PURE__ */ new Map();
this.nodeTypeRegistry = /* @__PURE__ */ new Map();
if (options == null ? void 0 : options.extensions) {
this.baseExtensions = options.extensions;
const flattened = sortExtensions(flattenExtensions(options.extensions));
flattened.forEach((ext) => this.registerExtension(ext));
}
}
/** Returns the underlying marked instance. */
get instance() {
return this.markedInstance;
}
/** Returns the correct indentCharacter (space or tab) */
get indentCharacter() {
return this.indentStyle === "space" ? " " : " ";
}
/** Returns the correct indentString repeated X times */
get indentString() {
return this.indentCharacter.repeat(this.indentSize);
}
/** Helper to quickly check whether a marked instance is available. */
hasMarked() {
return !!this.markedInstance;
}
/**
* Register a Tiptap extension (Node/Mark/Extension). This will read
* `markdownName`, `parseMarkdown`, `renderMarkdown` and `priority` from the
* extension config (using the same resolution used across the codebase).
*/
registerExtension(extension) {
var _a, _b;
this.extensions.push(extension);
const isCode = callOrReturn(getExtensionField(extension, "code"));
const name = extension.name;
if (isCode) {
this.codeTypes.add(name);
}
if (!this.extensionRanks.has(name)) {
this.extensionRanks.set(name, this.extensionRanks.size);
}
const tokenName = getExtensionField(
extension,
"markdownTokenName"
) || name;
const parseMarkdown = getExtensionField(extension, "parseMarkdown");
const renderMarkdown = getExtensionField(extension, "renderMarkdown");
const tokenizer = getExtensionField(extension, "markdownTokenizer");
const markdownCfg = (_a = getExtensionField(extension, "markdownOptions")) != null ? _a : null;
const isIndenting = (_b = markdownCfg == null ? void 0 : markdownCfg.indentsContent) != null ? _b : false;
const htmlReopen = markdownCfg == null ? void 0 : markdownCfg.htmlReopen;
const spec = {
tokenName,
nodeName: name,
parseMarkdown,
renderMarkdown,
isIndenting,
htmlReopen,
tokenizer
};
if (tokenName && parseMarkdown) {
const parseExisting = this.registry.get(tokenName) || [];
parseExisting.push(spec);
this.registry.set(tokenName, parseExisting);
}
if (renderMarkdown) {
const renderExisting = this.nodeTypeRegistry.get(name) || [];
renderExisting.push(spec);
this.nodeTypeRegistry.set(name, renderExisting);
}
if (tokenizer && this.hasMarked()) {
this.registerTokenizer(tokenizer);
}
}
createLexer() {
return new this.markedInstance.Lexer();
}
createTokenizerHelpers(lexer) {
return {
inlineTokens: (src) => lexer.inlineTokens(src),
blockTokens: (src) => lexer.blockTokens(src)
};
}
tokenizeInline(src) {
var _a;
return ((_a = this.activeParseLexer) != null ? _a : this.createLexer()).inlineTokens(src);
}
/**
* Register a custom tokenizer with marked.js for parsing non-standard markdown syntax.
*/
registerTokenizer(tokenizer) {
if (!this.hasMarked()) {
return;
}
const { name, start, level = "inline", tokenize } = tokenizer;
const createTokenizerHelpers = this.createTokenizerHelpers.bind(this);
const createLexer = this.createLexer.bind(this);
let startCb;
if (!start) {
startCb = (src) => {
const result = tokenize(src, [], this.createTokenizerHelpers(this.createLexer()));
if (result && result.raw) {
const index = src.indexOf(result.raw);
return index;
}
return -1;
};
} else {
startCb = typeof start === "function" ? start : (src) => src.indexOf(start);
}
const markedExtension = {
name,
level,
start: startCb,
tokenizer(src, tokens) {
const helper = this.lexer ? createTokenizerHelpers(this.lexer) : createTokenizerHelpers(createLexer());
const result = tokenize(src, tokens, helper);
if (result && result.type) {
return {
...result,
type: result.type || name,
raw: result.raw || "",
tokens: result.tokens || []
};
}
return void 0;
},
childTokens: []
};
this.markedInstance.use({
extensions: [markedExtension]
});
}
/** Get registered handlers for a token type and try each until one succeeds. */
getHandlersForToken(type) {
try {
return this.registry.get(type) || [];
} catch {
return [];
}
}
/** Get the first handler for a token type (for backwards compatibility). */
getHandlerForToken(type) {
const markdownHandlers = this.getHandlersForToken(type);
if (markdownHandlers.length > 0) {
return markdownHandlers[0];
}
const nodeTypeHandlers = this.getHandlersForNodeType(type);
return nodeTypeHandlers.length > 0 ? nodeTypeHandlers[0] : void 0;
}
/** Get registered handlers for a node type (for rendering). */
getHandlersForNodeType(type) {
try {
return this.nodeTypeRegistry.get(type) || [];
} catch {
return [];
}
}
/**
* Serialize a ProseMirror-like JSON document (or node array) to a Markdown string
* using registered renderers and fallback renderers.
*/
serialize(docOrContent) {
if (!docOrContent) {
return "";
}
const result = this.renderNodes(docOrContent, docOrContent);
return this.isEmptyOutput(result) ? "" : result;
}
/**
* Check if the markdown output represents an empty document.
* Empty documents may contain only entities or non-breaking space characters
* which are used by the Paragraph extension to preserve blank lines.
*/
isEmptyOutput(markdown) {
if (!markdown || markdown.trim() === "") {
return true;
}
const cleanedOutput = markdown.replace(/ /g, "").replace(/\u00A0/g, "").trim();
return cleanedOutput === "";
}
/**
* Parse markdown string into Tiptap JSON document using registered extension handlers.
*/
parse(markdown) {
if (!this.hasMarked()) {
throw new Error("No marked instance available for parsing");
}
const previousParseLexer = this.activeParseLexer;
const parseLexer = this.createLexer();
this.activeParseLexer = parseLexer;
try {
const tokens = parseLexer.lex(markdown);
const content = this.parseTokens(tokens, true);
return {
type: "doc",
content
};
} finally {
this.activeParseLexer = previousParseLexer;
}
}
/**
* Convert an array of marked tokens into Tiptap JSON nodes using registered extension handlers.
*/
parseTokens(tokens, parseImplicitEmptyParagraphs = false) {
const nonSpaceTokenIndexes = tokens.reduce((indexes, token, index) => {
if (token.type !== "space") {
indexes.push(index);
}
return indexes;
}, []);
let previousNonSpaceTokenIndex = -1;
let nextNonSpaceTokenPointer = 0;
return tokens.flatMap((token, index) => {
var _a;
while (nextNonSpaceTokenPointer < nonSpaceTokenIndexes.length && nonSpaceTokenIndexes[nextNonSpaceTokenPointer] < index) {
previousNonSpaceTokenIndex = nonSpaceTokenIndexes[nextNonSpaceTokenPointer];
nextNonSpaceTokenPointer += 1;
}
if (parseImplicitEmptyParagraphs && token.type === "space") {
const nextNonSpaceTokenIndex = (_a = nonSpaceTokenIndexes[nextNonSpaceTokenPointer]) != null ? _a : -1;
return this.createImplicitEmptyParagraphsFromSpace(
token,
previousNonSpaceTokenIndex,
nextNonSpaceTokenIndex
);
}
const parsed = this.parseToken(token, parseImplicitEmptyParagraphs);
if (parsed === null) {
return [];
}
return Array.isArray(parsed) ? parsed : [parsed];
});
}
createImplicitEmptyParagraphsFromSpace(token, previousNonSpaceTokenIndex, nextNonSpaceTokenIndex) {
const separatorCount = this.countParagraphSeparators(token.raw || "");
if (separatorCount === 0) {
return [];
}
const isBoundarySpace = previousNonSpaceTokenIndex === -1 || nextNonSpaceTokenIndex === -1;
const emptyParagraphCount = Math.max(separatorCount - (isBoundarySpace ? 0 : 1), 0);
return Array.from({ length: emptyParagraphCount }, () => ({ type: "paragraph", content: [] }));
}
countParagraphSeparators(raw) {
return (raw.replace(/\r\n/g, "\n").match(/\n\n/g) || []).length;
}
/**
* Parse a single token into Tiptap JSON using the appropriate registered handler.
*/
parseToken(token, parseImplicitEmptyParagraphs = false) {
if (!token.type) {
return null;
}
if (token.type === "list") {
return this.parseListToken(token);
}
const handlers = this.getHandlersForToken(token.type);
const helpers = this.createParseHelpers();
const result = handlers.find((handler) => {
if (!handler.parseMarkdown) {
return false;
}
const parseResult = handler.parseMarkdown(token, helpers);
const normalized = this.normalizeParseResult(parseResult);
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
this.lastParseResult = normalized;
return true;
}
return false;
});
if (result && this.lastParseResult) {
const toReturn = this.lastParseResult;
this.lastParseResult = null;
return toReturn;
}
return this.parseFallbackToken(token, parseImplicitEmptyParagraphs);
}
/**
* Parse a list token, handling mixed bullet and task list items by splitting them into separate lists.
* This ensures that consecutive task items and bullet items are grouped and parsed as separate list nodes.
*
* @param token The list token to parse
* @returns Array of parsed list nodes, or null if parsing fails
*/
parseListToken(token) {
if (!token.items || token.items.length === 0) {
return this.parseTokenWithHandlers(token);
}
const hasTask = token.items.some((item) => isTaskItem(item).isTask);
const hasNonTask = token.items.some((item) => !isTaskItem(item).isTask);
if (!hasTask || !hasNonTask || this.getHandlersForToken("taskList").length === 0) {
return this.parseTokenWithHandlers(token);
}
const groups = [];
let currentGroup = [];
let currentType = null;
for (let i = 0; i < token.items.length; i += 1) {
const item = token.items[i];
const { isTask, checked, indentLevel } = isTaskItem(item);
let processedItem = item;
if (isTask) {
const raw = item.raw || item.text || "";
const lines = raw.split("\n");
const firstLineMatch = lines[0].match(/^\s*[-+*]\s+\[([ xX])\]\s+(.*)$/);
const mainContent = firstLineMatch ? firstLineMatch[2] : "";
let nestedTokens = [];
if (lines.length > 1) {
const nestedRaw = lines.slice(1).join("\n");
if (nestedRaw.trim()) {
const nestedLines = lines.slice(1);
const nonEmptyLines = nestedLines.filter((line) => line.trim());
if (nonEmptyLines.length > 0) {
const minIndent = Math.min(
...nonEmptyLines.map((line) => line.length - line.trimStart().length)
);
const trimmedLines = nestedLines.map((line) => {
if (!line.trim()) {
return "";
}
return line.slice(minIndent);
});
const nestedContent = trimmedLines.join("\n").trim();
if (nestedContent) {
nestedTokens = this.markedInstance.lexer(`${nestedContent}
`);
}
}
}
}
processedItem = {
type: "taskItem",
raw: "",
mainContent,
indentLevel,
checked: checked != null ? checked : false,
text: mainContent,
tokens: this.tokenizeInline(mainContent),
nestedTokens
};
}
const itemType = isTask ? "taskList" : "list";
if (currentType !== itemType) {
if (currentGroup.length > 0) {
groups.push({ type: currentType, items: currentGroup });
}
currentGroup = [processedItem];
currentType = itemType;
} else {
currentGroup.push(processedItem);
}
}
if (currentGroup.length > 0) {
groups.push({ type: currentType, items: currentGroup });
}
const results = [];
for (let i = 0; i < groups.length; i += 1) {
const group = groups[i];
const subToken = { ...token, type: group.type, items: group.items };
const parsed = this.parseToken(subToken);
if (parsed) {
if (Array.isArray(parsed)) {
results.push(...parsed);
} else {
results.push(parsed);
}
}
}
return results.length > 0 ? results : null;
}
/**
* Parse a token using registered handlers (extracted for reuse).
*/
parseTokenWithHandlers(token) {
if (!token.type) {
return null;
}
const handlers = this.getHandlersForToken(token.type);
const helpers = this.createParseHelpers();
const result = handlers.find((handler) => {
if (!handler.parseMarkdown) {
return false;
}
const parseResult = handler.parseMarkdown(token, helpers);
const normalized = this.normalizeParseResult(parseResult);
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
this.lastParseResult = normalized;
return true;
}
return false;
});
if (result && this.lastParseResult) {
const toReturn = this.lastParseResult;
this.lastParseResult = null;
return toReturn;
}
return this.parseFallbackToken(token);
}
/**
* Creates helper functions for parsing markdown tokens.
* @returns An object containing helper functions for parsing.
*/
createParseHelpers() {
return {
parseInline: (tokens) => this.parseInlineTokens(tokens),
tokenizeInline: (src) => this.tokenizeInline(src),
parseChildren: (tokens) => this.parseTokens(tokens),
parseBlockChildren: (tokens) => this.parseTokens(tokens, true),
createTextNode: (text, marks) => {
const node = {
type: "text",
text,
marks: marks || void 0
};
return node;
},
createNode: (type, attrs, content) => {
const node = {
type,
attrs: attrs || void 0,
content: content || void 0
};
if (!attrs || Object.keys(attrs).length === 0) {
delete node.attrs;
}
return node;
},
applyMark: (markType, content, attrs) => ({
mark: markType,
content,
attrs: attrs && Object.keys(attrs).length > 0 ? attrs : void 0
})
};
}
/**
* Escape special regex characters in a string.
*/
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Parse inline tokens (bold, italic, links, etc.) into text nodes with marks.
* This is the complex part that handles mark nesting and boundaries.
*/
parseInlineTokens(tokens) {
var _a, _b, _c, _d;
const result = [];
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token.type === "text") {
result.push({
type: "text",
text: decodeHtmlEntities(token.text || "")
});
} else if (token.type === "escape") {
result.push({
type: "text",
text: token.text || ""
});
} else if (token.type === "html") {
const raw = ((_b = (_a = token.raw) != null ? _a : token.text) != null ? _b : "").toString();
const isClosing = /^<\/[\s]*[\w-]+/i.test(raw);
const openMatch = raw.match(/^<[\s]*([\w-]+)(\s|>|\/|$)/i);
if (!isClosing && openMatch && !/\/>$/.test(raw)) {
const tagName = openMatch[1];
const escapedTagName = this.escapeRegex(tagName);
const closingRegex = new RegExp(`^<\\/\\s*${escapedTagName}\\b`, "i");
let foundIndex = -1;
const parts = [raw];
for (let j = i + 1; j < tokens.length; j += 1) {
const t = tokens[j];
const tRaw = ((_d = (_c = t.raw) != null ? _c : t.text) != null ? _d : "").toString();
parts.push(tRaw);
if (t.type === "html" && closingRegex.test(tRaw)) {
foundIndex = j;
break;
}
}
if (foundIndex !== -1) {
const mergedRaw = parts.join("");
const mergedToken = {
type: "html",
raw: mergedRaw,
text: mergedRaw,
block: false
};
const parsed = this.parseHTMLToken(mergedToken);
if (parsed) {
const normalized = this.normalizeParseResult(parsed);
if (Array.isArray(normalized)) {
result.push(...normalized);
} else if (normalized) {
result.push(normalized);
}
}
i = foundIndex;
continue;
}
}
const parsedSingle = this.parseHTMLToken(token);
if (parsedSingle) {
const normalized = this.normalizeParseResult(parsedSingle);
if (Array.isArray(normalized)) {
result.push(...normalized);
} else if (normalized) {
result.push(normalized);
}
}
} else if (token.type) {
const markHandler = this.getHandlerForToken(token.type);
if (markHandler && markHandler.parseMarkdown) {
const helpers = this.createParseHelpers();
const parsed = markHandler.parseMarkdown(token, helpers);
if (this.isMarkResult(parsed)) {
const markedContent = this.applyMarkToContent(parsed.mark, parsed.content, parsed.attrs);
result.push(...markedContent);
} else {
const normalized = this.normalizeParseResult(parsed);
if (Array.isArray(normalized)) {
result.push(...normalized);
} else if (normalized) {
result.push(normalized);
}
}
} else if (token.tokens) {
result.push(...this.parseInlineTokens(token.tokens));
}
}
}
for (let i = result.length - 1; i > 0; i -= 1) {
const current = result[i];
const previous = result[i - 1];
if (current.type === "text" && previous.type === "text") {
const currentMarks = current.marks || [];
const previousMarks = previous.marks || [];
if (marksEqual(currentMarks, previousMarks)) {
previous.text = (previous.text || "") + (current.text || "");
result.splice(i, 1);
}
}
}
return result;
}
/**
* Apply a mark to content nodes.
*/
applyMarkToContent(markType, content, attrs) {
return content.map((node) => {
if (node.type === "text") {
const existingMarks = node.marks || [];
const newMark = attrs ? { type: markType, attrs } : { type: markType };
return {
...node,
marks: [...existingMarks, newMark]
};
}
return {
...node,
content: node.content ? this.applyMarkToContent(markType, node.content, attrs) : void 0
};
});
}
/**
* Check if a parse result represents a mark to be applied.
*/
isMarkResult(result) {
return result && typeof result === "object" && "mark" in result;
}
/**
* Normalize parse results to ensure they're valid JSONContent.
*/
normalizeParseResult(result) {
if (!result) {
return null;
}
if (this.isMarkResult(result)) {
return result.content;
}
return result;
}
/**
* Fallback parsing for common tokens when no specific handler is registered.
*/
parseFallbackToken(token, parseImplicitEmptyParagraphs = false) {
switch (token.type) {
case "paragraph":
return {
type: "paragraph",
content: token.tokens ? this.parseInlineTokens(token.tokens) : []
};
case "heading":
return {
type: "heading",
attrs: { level: token.depth || 1 },
content: token.tokens ? this.parseInlineTokens(token.tokens) : []
};
case "text":
return {
type: "text",
text: decodeHtmlEntities(token.text || "")
};
case "html":
return this.parseHTMLToken(token);
// handle Marked escape tokens as literal text (e.g. backslash-escaped characters)
case "escape":
return {
type: "text",
text: token.text || ""
};
case "space":
return null;
default:
if (token.tokens) {
return this.parseTokens(token.tokens, parseImplicitEmptyParagraphs);
}
return null;
}
}
/**
* Parse an HTML token from marked into JSONContent using the registered
* extensions' `parseHTML` rules. Falls back to literal text when the HTML
* has nothing for the schema to keep.
*
* @param token Marked HTML token (block or inline).
* @example
* parseHTMLToken({ type: 'html', raw: 'hi', block: false })
* // → text node with an italic mark
*/
parseHTMLToken(token) {
const html = token.text || token.raw || "";
if (!html.trim()) {
return null;
}
if (typeof window === "undefined") {
if (token.block) {
return {
type: "paragraph",
content: [
{
type: "text",
text: html
}
]
};
}
return {
type: "text",
text: html
};
}
if (this.isUnrecognizedHtml(html)) {
return this.htmlAsLiteralText(html, !!token.block);
}
try {
const parsed = generateJSON(html, this.baseExtensions);
if (parsed.type === "doc" && parsed.content) {
if (token.block) {
return parsed.content;
}
if (parsed.content.length === 1 && parsed.content[0].type === "paragraph" && parsed.content[0].content) {
return parsed.content[0].content;
}
return parsed.content;
}
return parsed;
} catch (error) {
throw new Error(`Failed to parse HTML in markdown: ${error}`);
}
}
/**
* Returns true when the HTML contains an element the browser classifies as
* `HTMLUnknownElement` – unless a registered extension declares the tag
* name in its parseDOM rules, in which case it is treated as a known
* custom element.
*
* Recognized but empty elements such as `` or ``,
* and hyphenated custom elements like ``, are not considered
* unrecognized.
*
* @param html Raw HTML string from a marked token.
* @example
* isUnrecognizedHtml('') // → true
* isUnrecognizedHtml('') // → false (empty, but real tag)
* isUnrecognizedHtml('hi') // → false
* isUnrecognizedHtml('') // → false (valid custom element)
* isUnrecognizedHtml('
') // → false
*/
isUnrecognizedHtml(html) {
if (typeof window === "undefined" || typeof window.DOMParser === "undefined") {
return false;
}
const dom = new window.DOMParser().parseFromString(`${html}`, "text/html").body;
const elements = dom.querySelectorAll("*");
if (elements.length === 0) {
return false;
}
const schemaTags = this.getSchemaParseDomTags();
return Array.from(elements).some((el) => {
if (!isHtmlUnknownElement(el)) {
return false;
}
const tagName = el.tagName.toLowerCase();
return !schemaTags.has(tagName);
});
}
/**
* Collect the lower-cased tag names declared by the registered extensions'
* parseDOM rules, so custom node/mark elements that use non-hyphenated,
* non-standard tag names are treated as recognized HTML. Result is cached for the
* lifetime of the manager since extensions don't change after registration.
*
* @example
* // After registering an extension with parseDOM [{ tag: 'something' }]
* getSchemaParseDomTags().has('something') // → true
*/
getSchemaParseDomTags() {
if (this.schemaParseDomTagsCache) {
return this.schemaParseDomTagsCache;
}
const tags = /* @__PURE__ */ new Set();
try {
const schema = getSchema(this.baseExtensions);
const collect = (spec) => {
const parseDOM = spec == null ? void 0 : spec.parseDOM;
if (!Array.isArray(parseDOM)) {
return;
}
parseDOM.forEach((rule) => {
if (typeof (rule == null ? void 0 : rule.tag) === "string") {
const match = rule.tag.match(/^[a-zA-Z][\w-]*/);
if (match) {
tags.add(match[0].toLowerCase());
}
}
});
};
Object.values(schema.nodes).forEach((type) => collect(type.spec));
Object.values(schema.marks).forEach((type) => collect(type.spec));
} catch {
}
this.schemaParseDomTagsCache = tags;
return tags;
}
/**
* Build a JSONContent that preserves the original HTML markup as literal
* text. Used when the HTML would otherwise be silently dropped during
* schema-aware parsing.
*
* @param html Raw HTML string to preserve verbatim.
* @param isBlock Whether to wrap the text in a paragraph node (block tokens)
* or return it as a bare text node (inline tokens).
* @example
* htmlAsLiteralText('', true)
* // → { type: 'paragraph', content: [{ type: 'text', text: '' }] }
*/
htmlAsLiteralText(html, isBlock) {
const text = html.replace(/\s+$/, "");
if (!text) {
return null;
}
if (isBlock) {
return {
type: "paragraph",
content: [{ type: "text", text }]
};
}
return { type: "text", text };
}
/**
* Encode HTML entities in text unless the node is inside a code context
* (code mark or code-block parent) where literal characters should be preserved.
* Also backslash-escape markdown-significant characters in non-code text to
* prevent them from being misinterpreted as formatting delimiters.
*/
encodeTextForMarkdown(text, node, parentNode) {
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
if (isInsideCode) {
return text;
}
return this.escapeMarkdownSyntax(encodeHtmlEntities(text));
}
/**
* Backslash-escape characters that have special meaning in markdown inline
* syntax. This prevents literal characters in text nodes from being
* misinterpreted as formatting delimiters when the output is parsed again.
*
* The set covers the most common inline markdown syntax characters.
* Characters inside code blocks/code marks are skipped by the caller
* (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
*/
escapeMarkdownSyntax(text) {
return text.replace(/([\\`*_[\]~])/g, "\\$1");
}
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
var _a;
if (node.type === "text") {
return this.encodeTextForMarkdown(node.text || "", node, parentNode);
}
if (!node.type) {
return "";
}
const handler = this.getHandlerForToken(node.type);
if (!handler) {
return "";
}
const previousNode = Array.isArray(parentNode == null ? void 0 : parentNode.content) && index > 0 ? parentNode.content[index - 1] : void 0;
const helpers = {
renderChildren: (nodes, separator) => {
const childLevel = handler.isIndenting ? level + 1 : level;
if (!Array.isArray(nodes) && nodes.content) {
return this.renderNodes(
nodes.content,
node,
separator || "",
index,
childLevel
);
}
return this.renderNodes(nodes, node, separator || "", index, childLevel);
},
renderChild: (childNode, childIndex) => {
const childLevel = handler.isIndenting ? level + 1 : level;
return this.renderNodeToMarkdown(childNode, node, childIndex, childLevel);
},
indent: (content) => {
return this.indentString + content;
},
wrapInBlock: wrapInMarkdownBlock
};
const context = {
index,
level,
parentType: parentNode == null ? void 0 : parentNode.type,
previousNode,
meta: {
parentAttrs: parentNode == null ? void 0 : parentNode.attrs,
...meta
}
};
const rendered = ((_a = handler.renderMarkdown) == null ? void 0 : _a.call(handler, node, helpers, context)) || "";
return rendered;
}
/**
* Render a node or an array of nodes. Parent type controls how children
* are joined (which determines newline insertion between children).
*/
renderNodes(nodeOrNodes, parentNode, separator = "", index = 0, level = 0) {
if (!Array.isArray(nodeOrNodes)) {
if (!nodeOrNodes.type) {
return "";
}
return this.renderNodeToMarkdown(nodeOrNodes, parentNode, index, level);
}
return this.renderNodesWithMarkBoundaries(nodeOrNodes, parentNode, separator, level);
}
/**
* Render an array of nodes while properly tracking mark boundaries.
* This handles cases where marks span across multiple text nodes.
*/
renderNodesWithMarkBoundaries(nodes, parentNode, separator = "", level = 0) {
const result = [];
const activeMarks = /* @__PURE__ */ new Map();
const reopenWithHtmlOnNextOpen = /* @__PURE__ */ new Set();
const markOpeningModes = /* @__PURE__ */ new Map();
nodes.forEach((node, i) => {
const nextNode = i < nodes.length - 1 ? nodes[i + 1] : null;
if (!node.type) {
return;
}
if (node.type === "text") {
let textContent = this.encodeTextForMarkdown(node.text || "", node, parentNode);
const currentMarks = new Map((node.marks || []).map((mark) => [mark.type, mark]));
const marksToOpen = this.getMarksToOpenForSerialization(activeMarks, currentMarks, nextNode);
const marksToClose = findMarksToClose(currentMarks, nextNode);
const activeMarksClosingHere = marksToClose.filter((markType) => activeMarks.has(markType));
const hasCrossedBoundary = activeMarksClosingHere.length > 0 && marksToOpen.length > 0;
let middleTrailingWhitespace = "";
if (marksToClose.length > 0 && !hasCrossedBoundary) {
const middleTrailingMatch = textContent.match(/(\s+)$/);
if (middleTrailingMatch) {
middleTrailingWhitespace = middleTrailingMatch[1];
textContent = textContent.slice(0, -middleTrailingWhitespace.length);
}
}
if (!hasCrossedBoundary) {
marksToClose.slice().reverse().forEach((markType) => {
if (!activeMarks.has(markType)) {
return;
}
const mark = currentMarks.get(markType);
const closeMarkdown = this.getMarkClosing(
markType,
mark,
markOpeningModes.get(markType)
);
if (closeMarkdown) {
textContent += closeMarkdown;
}
if (activeMarks.has(markType)) {
activeMarks.delete(markType);
markOpeningModes.delete(markType);
}
});
}
let leadingWhitespace = "";
if (marksToOpen.length > 0) {
const leadingMatch = textContent.match(/^(\s+)/);
if (leadingMatch) {
leadingWhitespace = leadingMatch[1];
textContent = textContent.slice(leadingWhitespace.length);
}
}
marksToOpen.forEach(({ type, mark }) => {
const openingMode = reopenWithHtmlOnNextOpen.has(type) ? "html" : "markdown";
const openMarkdown = this.getMarkOpening(type, mark, openingMode);
if (openMarkdown) {
textContent = openMarkdown + textContent;
}
markOpeningModes.set(type, openingMode);
reopenWithHtmlOnNextOpen.delete(type);
});
if (!hasCrossedBoundary) {
marksToOpen.slice().reverse().forEach(({ type, mark }) => {
activeMarks.set(type, mark);
});
}
textContent = leadingWhitespace + textContent;
let marksToCloseAtEnd;
if (hasCrossedBoundary) {
const nextMarkTypes = new Set(((nextNode == null ? void 0 : nextNode.marks) || []).map((mark) => mark.type));
marksToOpen.forEach(({ type }) => {
if (nextMarkTypes.has(type) && this.getHtmlReopenTags(type)) {
reopenWithHtmlOnNextOpen.add(type);
}
});
const activeMarkKeys = Array.from(activeMarks.keys());
const activeMarksClosingHereLifo = activeMarksClosingHere.slice().sort((a, b) => activeMarkKeys.indexOf(b) - activeMarkKeys.indexOf(a));
marksToCloseAtEnd = [
...marksToOpen.map((m) => m.type),
// inner (opened here) — close first
...activeMarksClosingHereLifo
// outer (were active before) — close last, LIFO
];
} else {
marksToCloseAtEnd = findMarksToCloseAtEnd(
activeMarks,
currentMarks,
nextNode,
this.markSetsEqual.bind(this)
);
}
let trailingWhitespace = "";
if (marksToCloseAtEnd.length > 0) {
const trailingMatch = textContent.match(/(\s+)$/);
if (trailingMatch) {
trailingWhitespace = trailingMatch[1];
textContent = textContent.slice(0, -trailingWhitespace.length);
}
}
marksToCloseAtEnd.forEach((markType) => {
var _a;
const mark = (_a = activeMarks.get(markType)) != null ? _a : currentMarks.get(markType);
const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType));
if (closeMarkdown) {
textContent += closeMarkdown;
}
activeMarks.delete(markType);
markOpeningModes.delete(markType);
});
textContent += trailingWhitespace;
textContent += middleTrailingWhitespace;
result.push(textContent);
} else {
const nodeMarkTypes = new Set((node.marks || []).map((mark) => mark.type));
const marksToReopen = /* @__PURE__ */ new Map();
const openingModesToReopen = /* @__PURE__ */ new Map();
activeMarks.forEach((mark, type) => {
var _a;
if (nodeMarkTypes.has(type)) {
marksToReopen.set(type, mark);
openingModesToReopen.set(type, (_a = markOpeningModes.get(type)) != null ? _a : "markdown");
}
});
const beforeMarkdown = closeMarksBeforeNode(activeMarks, (markType, mark) => {
return this.getMarkClosing(markType, mark, markOpeningModes.get(markType));
});
markOpeningModes.clear();
const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level);
const afterMarkdown = node.type === "hardBreak" ? "" : reopenMarksAfterNode(marksToReopen, activeMarks, (markType, mark) => {
var _a;
const openingMode = (_a = openingModesToReopen.get(markType)) != null ? _a : "markdown";
markOpeningModes.set(markType, openingMode);
return this.getMarkOpening(markType, mark, openingMode);
});
result.push(beforeMarkdown + nodeContent + afterMarkdown);
}
});
return result.join(separator);
}
/**
* Get the opening markdown syntax for a mark type.
*/
getMarkOpening(markType, mark, openingMode = "markdown") {
var _a;
if (openingMode === "html") {
return ((_a = this.getHtmlReopenTags(markType)) == null ? void 0 : _a.open) || "";
}
const handlers = this.getHandlersForNodeType(markType);
const handler = handlers.length > 0 ? handlers[0] : void 0;
if (!handler || !handler.renderMarkdown) {
return "";
}
const placeholder = "\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001";
const syntheticNode = {
type: markType,
attrs: mark.attrs || {},
content: [{ type: "text", text: placeholder }]
};
try {
const rendered = handler.renderMarkdown(
syntheticNode,
{
renderChildren: () => placeholder,
renderChild: () => placeholder,
indent: (content) => content,
wrapInBlock: (prefix, content) => prefix + content
},
{ index: 0, level: 0, parentType: "text", meta: {} }
);
const placeholderIndex = rendered.indexOf(placeholder);
return placeholderIndex >= 0 ? rendered.substring(0, placeholderIndex) : "";
} catch (err) {
throw new Error(`Failed to get mark opening for ${markType}: ${err}`);
}
}
/**
* Get the closing markdown syntax for a mark type.
*/
getMarkClosing(markType, mark, openingMode = "markdown") {
var _a;
if (openingMode === "html") {
return ((_a = this.getHtmlReopenTags(markType)) == null ? void 0 : _a.close) || "";
}
const handlers = this.getHandlersForNodeType(markType);
const handler = handlers.length > 0 ? handlers[0] : void 0;
if (!handler || !handler.renderMarkdown) {
return "";
}
const placeholder = "\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001";
const syntheticNode = {
type: markType,
attrs: mark.attrs || {},
content: [{ type: "text", text: placeholder }]
};
try {
const rendered = handler.renderMarkdown(
syntheticNode,
{
renderChildren: () => placeholder,
renderChild: () => placeholder,
indent: (content) => content,
wrapInBlock: (prefix, content) => prefix + content
},
{ index: 0, level: 0, parentType: "text", meta: {} }
);
const placeholderIndex = rendered.indexOf(placeholder);
const placeholderEnd = placeholderIndex + placeholder.length;
return placeholderIndex >= 0 ? rendered.substring(placeholderEnd) : "";
} catch (err) {
throw new Error(`Failed to get mark closing for ${markType}: ${err}`);
}
}
/**
* Returns the inline HTML tags an extension exposes for overlap-boundary
* reopen handling, if that mark explicitly opted into HTML reopen mode.
*/
getHtmlReopenTags(markType) {
const handlers = this.getHandlersForNodeType(markType);
const handler = handlers.length > 0 ? handlers[0] : void 0;
return handler == null ? void 0 : handler.htmlReopen;
}
/**
* Check if two mark sets are equal (same types and matching attributes).
*/
markSetsEqual(marks1, marks2) {
if (marks1.size !== marks2.size) {
return false;
}
return Array.from(marks1.entries()).every(([type, mark]) => {
const otherMark = marks2.get(type);
return otherMark && attrsEqual2(mark.attrs, otherMark.attrs);
});
}
/**
* Decide the order in which marks open on the current text node.
*
* The returned array is iterated head-first when prepending opening
* delimiters, so the first entry becomes the innermost mark in the emitted
* markdown and the last becomes the outermost. Two stable signals drive
* the order — neither one inspects any rendered markdown:
*
* 1. Marks that end on this node must be inner relative to marks that
* continue into the next node, otherwise the delimiters interleave
* instead of nesting.
* 2. Within each lifetime group, marks are sorted so that lower
* registration ranks (i.e. higher Tiptap extension priorities) end up
* outermost. ProseMirror assigns mark ranks in the same priority-aware
* order Tiptap uses when building the schema, so link (priority 1000)
* naturally wraps bold/italic without the serializer needing to peek
* at how any particular mark renders.
*/
getMarksToOpenForSerialization(activeMarks, currentMarks, nextNode) {
const marksToOpen = findMarksToOpen(activeMarks, currentMarks);
if (marksToOpen.length <= 1) {
return marksToOpen;
}
const nextMarks = (nextNode == null ? void 0 : nextNode.marks) || [];
const continuesInNextNode = (markType, attrs) => nextMarks.some((m) => m.type === markType && attrsEqual2(m.attrs, attrs));
const byRankInnerFirst = (a, b) => {
var _a, _b;
const rankA = (_a = this.extensionRanks.get(a.type)) != null ? _a : Number.MAX_SAFE_INTEGER;
const rankB = (_b = this.extensionRanks.get(b.type)) != null ? _b : Number.MAX_SAFE_INTEGER;
if (rankA !== rankB) {
return rankB - rankA;
}
return a.type.localeCompare(b.type);
};
const endingHere = marksToOpen.filter((mark) => !continuesInNextNode(mark.type, mark.mark.attrs)).sort(byRankInnerFirst);
const continuing = marksToOpen.filter((mark) => continuesInNextNode(mark.type, mark.mark.attrs)).sort(byRankInnerFirst);
return [...endingHere, ...continuing];
}
};
var MarkdownManager_default = MarkdownManager;
// src/Extension.ts
var Markdown = Extension.create({
name: "markdown",
addOptions() {
return {
indentation: { style: "space", size: 2 },
marked: void 0,
markedOptions: {}
};
},
addCommands() {
return {
setContent: (content, options) => {
if (!(options == null ? void 0 : options.contentType)) {
return commands.setContent(content, options);
}
const actualContentType = assumeContentType(content, options == null ? void 0 : options.contentType);
if (actualContentType !== "markdown" || !this.editor.markdown) {
return commands.setContent(content, options);
}
const mdContent = this.editor.markdown.parse(content);
return commands.setContent(mdContent, options);
},
insertContent: (value, options) => {
if (!(options == null ? void 0 : options.contentType)) {
return commands.insertContent(value, options);
}
const actualContentType = assumeContentType(value, options == null ? void 0 : options.contentType);
if (actualContentType !== "markdown" || !this.editor.markdown) {
return commands.insertContent(value, options);
}
const mdContent = this.editor.markdown.parse(value);
return commands.insertContent(mdContent, options);
},
insertContentAt: (position, value, options) => {
if (!(options == null ? void 0 : options.contentType)) {
return commands.insertContentAt(position, value, options);
}
const actualContentType = assumeContentType(value, options == null ? void 0 : options.contentType);
if (actualContentType !== "markdown" || !this.editor.markdown) {
return commands.insertContentAt(position, value, options);
}
const mdContent = this.editor.markdown.parse(value);
return commands.insertContentAt(position, mdContent, options);
}
};
},
addStorage() {
return {
manager: new MarkdownManager_default({
indentation: this.options.indentation,
marked: this.options.marked,
markedOptions: this.options.markedOptions,
extensions: []
})
};
},
onBeforeCreate() {
var _a;
if (this.editor.markdown) {
console.error(
"[tiptap][markdown]: There is already a `markdown` property on the editor instance. This might lead to unexpected behavior."
);
return;
}
this.storage.manager = new MarkdownManager_default({
indentation: this.options.indentation,
marked: this.options.marked,
markedOptions: this.options.markedOptions,
extensions: this.editor.extensionManager.baseExtensions
});
this.editor.markdown = this.storage.manager;
this.editor.getMarkdown = () => {
return this.storage.manager.serialize(this.editor.getJSON());
};
if (!this.editor.options.contentType) {
return;
}
const assumedType = assumeContentType(
this.editor.options.content,
this.editor.options.contentType
);
if (assumedType !== "markdown") {
return;
}
if (!this.editor.markdown) {
throw new Error(
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the Markdown extension is not added to the editor. Please add the Markdown extension to use this feature.'
);
}
if (this.editor.options.content === void 0 || typeof this.editor.options.content !== "string") {
throw new Error(
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the initial content is not a string. Please provide the initial content as a markdown string.'
);
}
const json = this.editor.markdown.parse(this.editor.options.content);
if ((_a = json.content) == null ? void 0 : _a.length) {
this.editor.options.content = json;
}
}
});
export {
Markdown,
MarkdownManager,
assumeContentType,
closeMarksBeforeNode,
findMarksToClose,
findMarksToCloseAtEnd,
findMarksToOpen,
isTaskItem,
reopenMarksAfterNode,
wrapInMarkdownBlock
};
//# sourceMappingURL=index.js.map