import frontMatter from "front-matter"; import hljs from "highlight.js/lib/core"; import { marked, type RendererObject, type Tokens } from "marked"; import readingTime, { type ReadTimeResults } from "reading-time"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkCjkFriendly from "remark-cjk-friendly"; import remarkStringify from "remark-stringify"; import { markedAlert, markedFootnotes, markedInfographic, markedMarkup, markedPlantUML, markedRuby, markedSlider, markedToc, MDKatex, } from "./extensions/index.js"; import { COMMON_LANGUAGES, highlightAndFormatCode, } from "./utils/languages.js"; import { macCodeSvg } from "./constants.js"; import type { IOpts, ParseResult, RendererAPI } from "./types.js"; Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => { hljs.registerLanguage(name, lang); }); export { hljs }; marked.setOptions({ breaks: true, }); marked.use(markedSlider()); function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`"); } function buildAddition(): string { return ` `; } function buildFootnoteArray(footnotes: [number, string, string][]): string { return footnotes .map(([index, title, link]) => link === title ? `[${index}]: ${title}
` : `[${index}] ${title}: ${link}
` ) .join("\n"); } function transform(legend: string, text: string | null, title: string | null): string { const options = legend.split("-"); for (const option of options) { if (option === "alt" && text) { return text; } if (option === "title" && title) { return title; } } return ""; } function parseFrontMatterAndContent(markdownText: string): ParseResult { try { const parsed = frontMatter(markdownText); const yamlData = parsed.attributes; const markdownContent = parsed.body; const readingTimeResult = readingTime(markdownContent); return { yamlData: yamlData as Record, markdownContent, readingTime: readingTimeResult, }; } catch (error) { console.error("Error parsing front-matter:", error); return { yamlData: {}, markdownContent: markdownText, readingTime: readingTime(markdownText), }; } } function wrapInlineCode(value: string): string { const runs = value.match(/`+/g); const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1); const padding = /^ *$/.test(value) ? "" : " "; return `${fence}${padding}${value}${padding}${fence}`; } export function initRenderer(opts: IOpts = {}): RendererAPI { const footnotes: [number, string, string][] = []; let footnoteIndex = 0; let codeIndex = 0; const listOrderedStack: boolean[] = []; const listCounters: number[] = []; const isBrowser = typeof window !== "undefined"; function getOpts(): IOpts { return opts; } function styledContent(styleLabel: string, content: string, tagName?: string): string { const tag = tagName ?? styleLabel; const className = `${styleLabel.replace(/_/g, "-")}`; const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : ""; return `<${tag} class="${className}"${headingAttr}>${content}`; } function addFootnote(title: string, link: string): number { const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link); if (existingFootnote) { return existingFootnote[0]; } footnotes.push([++footnoteIndex, title, link]); return footnoteIndex; } function reset(newOpts: Partial): void { footnotes.length = 0; footnoteIndex = 0; setOptions(newOpts); } function setOptions(newOpts: Partial): void { opts = { ...opts, ...newOpts }; marked.use(markedAlert()); if (isBrowser) { marked.use(MDKatex({ nonStandard: true }, true)); } marked.use(markedMarkup()); marked.use(markedInfographic({ themeMode: opts.themeMode })); } function buildReadingTime(readingTimeResult: ReadTimeResults): string { if (!opts.countStatus) { return ""; } if (!readingTimeResult.words) { return ""; } return `

字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟

`; } const buildFootnotes = () => { if (!footnotes.length) { return ""; } return ( styledContent("h4", "引用链接") + styledContent("footnotes", buildFootnoteArray(footnotes), "p") ); }; const renderer: RendererObject = { heading({ tokens, depth }: Tokens.Heading) { const text = this.parser.parseInline(tokens); const tag = `h${depth}`; return styledContent(tag, text); }, paragraph({ tokens }: Tokens.Paragraph): string { const text = this.parser.parseInline(tokens); const isFigureImage = text.includes(" { const windowRef = typeof window !== "undefined" ? (window as any) : undefined; if (windowRef && windowRef.mermaid) { const mermaid = windowRef.mermaid; await mermaid.run(); } else { const mermaid = await import("mermaid"); await mermaid.default.run(); } }, 0) as any as number; } return `
${text}
`; } const langText = lang.split(" ")[0]; const isLanguageRegistered = hljs.getLanguage(langText); const language = isLanguageRegistered ? langText : "plaintext"; const highlighted = highlightAndFormatCode( text, language, hljs, !!opts.isShowLineNumber ); const span = `${macCodeSvg}`; let pendingAttr = ""; if (!isLanguageRegistered && langText !== "plaintext") { const escapedText = text.replace(/"/g, """); pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`; } const code = `${highlighted}`; return `
${span}${code}
`; }, codespan({ text }: Tokens.Codespan): string { const escapedText = escapeHtml(text); return styledContent("codespan", escapedText, "code"); }, list({ ordered, items, start = 1 }: Tokens.List) { listOrderedStack.push(ordered); listCounters.push(Number(start)); const html = items.map((item) => this.listitem(item)).join(""); listOrderedStack.pop(); listCounters.pop(); return styledContent(ordered ? "ol" : "ul", html); }, listitem(token: Tokens.ListItem) { const ordered = listOrderedStack[listOrderedStack.length - 1]; const idx = listCounters[listCounters.length - 1]!; listCounters[listCounters.length - 1] = idx + 1; const prefix = ordered ? `${idx}. ` : "• "; let content: string; try { content = this.parser.parseInline(token.tokens); } catch { content = this.parser .parse(token.tokens) .replace(/^]*)?>([\s\S]*?)<\/p>/, "$1"); } return styledContent("listitem", `${prefix}${content}`, "li"); }, image({ href, title, text }: Tokens.Image): string { const newText = opts.legend ? transform(opts.legend, text, title) : ""; const subText = newText ? styledContent("figcaption", newText) : ""; const titleAttr = title ? ` title="${title}"` : ""; return `
${text}${subText}
`; }, link({ href, title, text, tokens }: Tokens.Link): string { const parsedText = this.parser.parseInline(tokens); if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) { return `${parsedText}`; } if (href === text) { return parsedText; } if (opts.citeStatus) { const ref = addFootnote(title || text, href); return `${parsedText}[${ref}]`; } return `${parsedText}`; }, strong({ tokens }: Tokens.Strong): string { return styledContent("strong", this.parser.parseInline(tokens)); }, em({ tokens }: Tokens.Em): string { return styledContent("em", this.parser.parseInline(tokens)); }, table({ header, rows }: Tokens.Table): string { const headerRow = header .map((cell) => { const text = this.parser.parseInline(cell.tokens); return styledContent("th", text); }) .join(""); const body = rows .map((row) => { const rowContent = row.map((cell) => this.tablecell(cell)).join(""); return styledContent("tr", rowContent); }) .join(""); return `
${headerRow}${body}
`; }, tablecell(token: Tokens.TableCell): string { const text = this.parser.parseInline(token.tokens); return styledContent("td", text); }, hr(_: Tokens.Hr): string { return styledContent("hr", ""); }, }; marked.use({ renderer }); marked.use(markedMarkup()); marked.use(markedToc()); marked.use(markedSlider()); marked.use(markedAlert({})); if (isBrowser) { marked.use(MDKatex({ nonStandard: true }, true)); } marked.use(markedFootnotes()); marked.use( markedPlantUML({ inlineSvg: isBrowser, }) ); marked.use(markedInfographic()); marked.use(markedRuby()); return { buildAddition, buildFootnotes, setOptions, reset, parseFrontMatterAndContent, buildReadingTime, createContainer(content: string) { return styledContent("container", content, "section"); }, getOpts, }; } function preprocessCjkEmphasis(markdown: string): string { const processor = unified() .use(remarkParse) .use(remarkCjkFriendly); const tree = processor.parse(markdown); const extractText = (node: any): string => { if (node.type === "text") return node.value; if (node.type === "inlineCode") return wrapInlineCode(node.value); if (node.children) return node.children.map(extractText).join(""); return ""; }; const visit = (node: any, parent?: any, index?: number) => { if (node.children) { for (let i = 0; i < node.children.length; i++) { visit(node.children[i], node, i); } } if (node.type === "strong" && parent && typeof index === "number") { const text = extractText(node); parent.children[index] = { type: "html", value: `${text}` }; } if (node.type === "emphasis" && parent && typeof index === "number") { const text = extractText(node); parent.children[index] = { type: "html", value: `${text}` }; } }; visit(tree); const stringify = unified().use(remarkStringify); let result = stringify.stringify(tree); result = result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)) ); return result; } export function renderMarkdown(raw: string, renderer: RendererAPI): { html: string; readingTime: ReadTimeResults; } { const { markdownContent, readingTime: readingTimeResult } = renderer.parseFrontMatterAndContent(raw); const preprocessed = preprocessCjkEmphasis(markdownContent); const html = marked.parse(preprocessed) as string; return { html, readingTime: readingTimeResult }; } export function postProcessHtml( baseHtml: string, reading: ReadTimeResults, renderer: RendererAPI ): string { let html = baseHtml; html = renderer.buildReadingTime(reading) + html; html += renderer.buildFootnotes(); html += renderer.buildAddition(); html += ` `; html += ` `; return renderer.createContainer(html); }