161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import clsx from 'clsx';
|
|
import styles from './assets/highlight.module.css';
|
|
|
|
const parseTableRow = (line: string): string[] =>
|
|
line.split('|').slice(1, -1).map(cell => cell.trim());
|
|
|
|
const isSeparatorRow = (line: string): boolean =>
|
|
/^\|[\s|:\-]+\|$/.test(line.trim());
|
|
|
|
export const parseList = (block: string, ordered: boolean): string => {
|
|
const marker = ordered ? /^\d+\. / : /^[-+] /;
|
|
const items = block.trim().split('\n')
|
|
.map(l => `<li>${l.trim().replace(marker, '')}</li>`)
|
|
.join('');
|
|
const tag = ordered ? 'ol' : 'ul';
|
|
return `<${tag} class="${styles.list}">${items}</${tag}>`;
|
|
};
|
|
|
|
export const parseTable = (table: string): string => {
|
|
const lines = table.trim().split('\n').map(l => l.trim()).filter(l => l.startsWith('|'));
|
|
if (lines.length < 2) return table;
|
|
|
|
const sepIndex = lines.findIndex(isSeparatorRow);
|
|
if (sepIndex === -1) return table;
|
|
|
|
const headerLines = lines.slice(0, sepIndex);
|
|
const bodyLines = lines.slice(sepIndex + 1);
|
|
|
|
const renderCells = (cells: string[], tag: 'th' | 'td') =>
|
|
cells.map(cell => `<${tag}>${cell}</${tag}>`).join('');
|
|
|
|
const headers = headerLines
|
|
.map(l => `<tr>${renderCells(parseTableRow(l), 'th')}</tr>`)
|
|
.join('');
|
|
const rows = bodyLines
|
|
.map(l => `<tr>${renderCells(parseTableRow(l), 'td')}</tr>`)
|
|
.join('');
|
|
|
|
return `<table class="${styles.table}"><thead>${headers}</thead><tbody>${rows}</tbody></table>`;
|
|
};
|
|
|
|
export const highlight = (message: string, keepMarkup = true): string => {
|
|
let resultHTML = '';
|
|
const tokenRegex = /(\*{1,3}|"|```|`|(?:^|\n)#{1,3} |(?:^|\n)> |\n)|---/g;
|
|
const headerRegex = /#{1,3} $/;
|
|
const blockquoteRegex = /> $/;
|
|
const stack: string[] = [];
|
|
let inCodeBlock = false;
|
|
let inMonospaced = false;
|
|
let inHeader = false;
|
|
let inBlockquote = false;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = tokenRegex.exec(message)) !== null) {
|
|
resultHTML += message.slice(lastIndex, match.index);
|
|
lastIndex = tokenRegex.lastIndex;
|
|
|
|
const token = match[0];
|
|
const isClose = stack.at(-1) === token;
|
|
const keepToken = keepMarkup || token === '"';
|
|
|
|
if (inCodeBlock) {
|
|
if (token === '```' && isClose) {
|
|
inCodeBlock = false;
|
|
stack.pop();
|
|
resultHTML += `${keepToken ? token : ''}</span>`;
|
|
} else {
|
|
resultHTML += token;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inMonospaced) {
|
|
if (token === '`' && isClose) {
|
|
inMonospaced = false;
|
|
stack.pop();
|
|
resultHTML += `${keepToken ? token : ''}</span>`;
|
|
} else {
|
|
resultHTML += token;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const headerMatch = token.match(headerRegex);
|
|
if (headerMatch) {
|
|
if (inHeader) resultHTML += '</span>';
|
|
if (inBlockquote) { resultHTML += '</span>'; inBlockquote = false; }
|
|
const markup = keepMarkup ? headerMatch[0] : '';
|
|
const len = headerMatch[0].length;
|
|
inHeader = true;
|
|
resultHTML += `${token.slice(0, -len)}<span class="${clsx(styles.header, styles[`header${len - 1}`])}">${markup}`;
|
|
continue;
|
|
}
|
|
|
|
const blockquoteMatch = token.match(blockquoteRegex);
|
|
if (blockquoteMatch) {
|
|
if (inHeader) { resultHTML += '</span>'; inHeader = false; }
|
|
if (inBlockquote) resultHTML += '</span>';
|
|
const markup = keepMarkup ? '> ' : '';
|
|
inBlockquote = true;
|
|
resultHTML += `${token.slice(0, -2)}<span class="${styles.blockquote}">${markup}`;
|
|
continue;
|
|
}
|
|
|
|
if (token === '\n') {
|
|
if (inHeader) {
|
|
resultHTML += `${keepMarkup ? '\n' : ''}</span>`;
|
|
inHeader = false;
|
|
} else if (inBlockquote) {
|
|
resultHTML += `${keepMarkup ? '\n' : ''}</span>`;
|
|
inBlockquote = false;
|
|
} else {
|
|
resultHTML += '\n';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (token === '***' || token === '---') {
|
|
resultHTML += `<span class="${styles.hr}">${keepToken ? token : ''}</span>`;
|
|
} else if (isClose) {
|
|
stack.pop();
|
|
resultHTML += `${keepToken ? token : ''}</span>`;
|
|
} else if (token === '*') {
|
|
stack.push(token);
|
|
resultHTML += `<span class="${styles.italic}">${keepToken ? token : ''}`;
|
|
} else if (token === '**') {
|
|
stack.push(token);
|
|
resultHTML += `<span class="${styles.bold}">${keepToken ? token : ''}`;
|
|
} else if (token === '"') {
|
|
stack.push(token);
|
|
resultHTML += `<span class="${styles.quote}">"`;
|
|
} else if (token === '```') {
|
|
stack.push(token);
|
|
inCodeBlock = true;
|
|
resultHTML += `<span class="${styles.codeBlock}">${keepToken ? token : ''}`;
|
|
} else if (token === '`') {
|
|
stack.push(token);
|
|
inMonospaced = true;
|
|
resultHTML += `<span class="${styles.inlineCode}">${keepToken ? token : ''}`;
|
|
}
|
|
}
|
|
|
|
resultHTML += message.slice(lastIndex);
|
|
|
|
if (inHeader) resultHTML += '</span>';
|
|
if (inBlockquote) resultHTML += '</span>';
|
|
resultHTML += '</span>'.repeat(stack.length);
|
|
|
|
if (!keepMarkup) {
|
|
resultHTML = resultHTML.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
|
|
`<img src="${src}" alt="${alt}" class="${styles.image}"/>`
|
|
);
|
|
resultHTML = resultHTML.replace(/((?:(?:^|\n)\|.+)+)/g, match => parseTable(match));
|
|
resultHTML = resultHTML.replace(/((?:(?:^|\n)[-+] .+)+)/g, match => parseList(match, false));
|
|
resultHTML = resultHTML.replace(/((?:(?:^|\n)\d+\. .+)+)/g, match => parseList(match, true));
|
|
}
|
|
|
|
return resultHTML;
|
|
}
|