import * as React from "react";
type InlineNode =
| { type: "text"; value: string }
| { type: "strong"; value: string }
| { type: "em"; value: string }
| { type: "del"; value: string }
| { type: "code"; value: string }
| { type: "link"; label: string; href: string }
| { type: "image"; alt: string; src: string };
type InlineDecorationRange = {
from: number;
to: number;
className: string;
};
type InlineTokenRange = {
from: number;
to: number;
};
type ParagraphBlock = {
type: "paragraph";
text: string;
};
type HeadingBlock = {
type: "heading";
level: number;
text: string;
};
type HrBlock = {
type: "hr";
};
type BlockQuoteBlock = {
type: "blockquote";
text: string;
};
type CodeBlock = {
type: "code";
language: string;
code: string;
};
type ListItem = {
text: string;
checked: boolean | null;
};
type ListBlock = {
type: "list";
ordered: boolean;
items: ListItem[];
};
type TableBlock = {
type: "table";
headers: string[];
rows: string[][];
};
type MarkdownBlock =
| ParagraphBlock
| HeadingBlock
| HrBlock
| BlockQuoteBlock
| CodeBlock
| ListBlock
| TableBlock;
const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
/**
* Executes parseInlineNodes.
* @param input Parameter input.
* @returns InlineNode[].
*/
export function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const index = match.index;
const raw = match[0];
if (index > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) });
} else if (match[3] !== undefined && match[4] !== undefined) {
nodes.push({
type: "link",
label: match[3],
href: normalizeUrl(match[4]),
});
} else if (match[5] !== undefined) {
nodes.push({ type: "code", value: match[5] });
} else if (match[6] !== undefined) {
nodes.push({ type: "del", value: match[6] });
} else if (match[7] !== undefined || match[8] !== undefined) {
nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" });
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
} else {
nodes.push({ type: "text", value: raw });
}
cursor = index + raw.length;
match = INLINE_TOKEN_REGEX.exec(input);
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return nodes;
}
/**
* Executes collectInlineRanges.
* @param input Parameter input.
* @param offset Parameter offset.
* @returns {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
}.
*/
export function collectInlineRanges(
input: string,
offset = 0,
): {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
} {
const styleRanges: InlineDecorationRange[] = [];
const tokenRanges: InlineTokenRange[] = [];
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const raw = match[0];
const start = offset + match.index;
const end = start + raw.length;
if (match[1] !== undefined && match[2] !== undefined) {
const openLength = 2;
const closeLength = raw.endsWith(")") ? 1 : 0;
const imageEnd = start + openLength + match[1].length;
tokenRanges.push({ from: start, to: start + openLength });
tokenRanges.push({ from: imageEnd, to: imageEnd + 1 });
const srcStart = imageEnd + 1;
const srcEnd = end - closeLength;
tokenRanges.push({ from: srcStart, to: srcStart + 1 });
tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength });
} else if (match[3] !== undefined && match[4] !== undefined) {
const label = match[3];
const labelStart = start + 1;
const labelEnd = labelStart + label.length;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: labelEnd, to: labelEnd + 1 });
tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: labelStart,
to: labelEnd,
className: "tm-md-link",
});
} else if (match[5] !== undefined) {
const codeStart = start + 1;
const codeEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: codeStart,
to: codeEnd,
className: "tm-md-code",
});
} else if (match[6] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-del",
});
} else if (match[7] !== undefined || match[8] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-strong",
});
} else if (match[9] !== undefined || match[10] !== undefined) {
const contentStart = start + 1;
const contentEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-em",
});
}
match = INLINE_TOKEN_REGEX.exec(input);
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return { styleRanges, tokenRanges };
}
/**
* Executes parseMarkdownBlocks.
* @param markdown Parameter markdown.
* @returns MarkdownBlock[].
*/
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const codeFence = line.match(/^```\s*([^`]*)$/);
if (codeFence) {
const language = (codeFence[1] ?? "").trim();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ type: "code", language, code: codeLines.join("\n") });
continue;
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) {
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
blocks.push({
type: "heading",
level: heading[1].length,
text: heading[2],
});
index += 1;
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
const quoteLines: string[] = [quote[1]];
index += 1;
while (index < lines.length) {
const next = lines[index].match(/^>\s?(.*)$/);
if (!next) break;
quoteLines.push(next[1]);
index += 1;
}
blocks.push({ type: "blockquote", text: quoteLines.join("\n") });
continue;
}
const tableCandidate = readTable(lines, index);
if (tableCandidate) {
blocks.push(tableCandidate.block);
index = tableCandidate.nextIndex;
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
if (unordered || ordered) {
const orderedList = Boolean(ordered);
const items: ListItem[] = [];
while (index < lines.length) {
const current = lines[index];
const match = orderedList
? current.match(/^\s*\d+\.\s+(.*)$/)
: current.match(/^\s*[-*+]\s+(.*)$/);
if (!match) break;
const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/);
if (task) {
items.push({
text: task[2],
checked: task[1].toLowerCase() === "x",
});
} else {
items.push({ text: match[1], checked: null });
}
index += 1;
}
blocks.push({ type: "list", ordered: orderedList, items });
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```\s*/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) &&
!/^\s*[-*+]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim())
) {
paragraphLines.push(lines[index]);
index += 1;
}
blocks.push({ type: "paragraph", text: paragraphLines.join("\n") });
}
return blocks;
}
/**
* Executes renderInline.
* @param nodes Parameter nodes.
* @returns React.ReactNode[].
*/
export function renderInline(nodes: InlineNode[]): React.ReactNode[] {
return nodes.map((node, index) => {
if (node.type === "text") {
return node.value;
}
if (node.type === "strong") {
return (
{node.value}
);
}
if (node.type === "em") {
return (
{node.value}
);
}
if (node.type === "del") {
return (
{node.value}
);
}
if (node.type === "code") {
return (
{node.value}
);
}
if (node.type === "link") {
return (
{node.label}
);
}
return (
);
});
}
/**
* Executes renderBlocks.
* @param blocks Parameter blocks.
* @returns React.ReactElement.
*/
export function renderBlocks(blocks: MarkdownBlock[]): React.ReactElement {
return (
<>
{blocks.map((block, blockIndex) => {
if (block.type === "heading") {
const className = `tm-md-heading tm-md-h${String(block.level)}`;
if (block.level === 1)
return (
{block.text.split("\n").map((line, lineIndex) => (); } if (block.type === "code") { return ({renderInline(parseInlineNodes(line))}
))}
{block.code}
);
}
if (block.type === "list") {
const Tag = block.ordered ? "ol" : "ul";
return (
| {renderInline(parseInlineNodes(header))} | ))}
|---|
| {renderInline(parseInlineNodes(cell))} | ))}
{block.text.split("\n").map((line, lineIndex) => (
: null}
{renderInline(parseInlineNodes(line))}