blog/libs/post/markdown.ts

157 lines
4.5 KiB
TypeScript

import Prism from "prismjs";
import "prismjs/components/prism-lua.js";
import "prismjs/components/prism-markdown.js";
import { escape as escapeHtml } from "@std/html";
import { mangle } from "marked-mangle";
import GitHubSlugger from "github-slugger";
import * as Marked from "marked";
const slugger = new GitHubSlugger();
Marked.marked.use(mangle());
const ADMISSION_REG = /^\[(info|warn|tip)\]:\s/;
export interface MarkdownHeading {
id: string;
html: string;
}
class DefaultRenderer extends Marked.Renderer {
headings: MarkdownHeading[] = [];
override text(
token: Marked.Tokens.Text | Marked.Tokens.Escape | Marked.Tokens.Tag,
): string {
if (
token.type === "text" && "tokens" in token && token.tokens !== undefined
) {
return this.parser.parseInline(token.tokens);
}
// Smartypants typography enhancement
return token.text
.replaceAll("...", "…")
.replaceAll("--", "—")
.replaceAll("---", "–")
.replaceAll(/(\w)'(\w)/g, "$1’$2")
.replaceAll(/s'/g, "s’")
.replaceAll("'", "’")
.replaceAll(/["](.*?)["]/g, "“$1&#8221")
.replaceAll(/"(.*?)"/g, "“$1&#8221")
.replaceAll(/['](.*?)[']/g, "‘$1’");
}
override heading({
tokens,
depth,
raw,
}: Marked.Tokens.Heading): string {
const slug = slugger.slug(raw);
const text = this.parser.parseInline(tokens);
this.headings.push({ id: slug, html: text });
return `<h${depth} id="${slug}"><a class="md-anchor" tabindex="-1" href="#${slug}">${text}<span aria-hidden="true">#</span></a></h${depth}>`;
}
override link({ href, title, tokens }: Marked.Tokens.Link) {
const text = this.parser.parseInline(tokens);
const titleAttr = title ? ` title="${title}"` : "";
if (href.startsWith("#")) {
return `<a href="${href}"${titleAttr}>${text}</a>`;
}
return `<a href="${href}"${titleAttr} rel="noopener noreferrer">${text}</a>`;
}
override image({ href, text, title }: Marked.Tokens.Image) {
return `<img src="${href}" alt="${text ?? ""}" title="${title ?? ""}" />`;
}
override code({ lang: info, text }: Marked.Tokens.Code): string {
// format: tsx
// format: tsx my/file.ts
// format: tsx "This is my title"
let lang = "";
let title = "";
const match = info?.match(/^([\w_-]+)\s*(.*)?$/);
if (match) {
lang = match[1].toLocaleLowerCase();
title = match[2] ?? "";
}
let out = `<div class="fenced-code">`;
if (title) {
out += `<div class="fenced-code-header">
<span class="fenced-code-title lang-${lang}">
${title ? escapeHtml(String(title)) : "&nbsp;"}
</span>
</div>`;
}
const grammar = lang && Object.hasOwnProperty.call(Prism.languages, lang)
? Prism.languages[lang]
: undefined;
if (grammar === undefined) {
out += `<pre><code class="notranslate">${escapeHtml(text)}</code></pre>`;
} else {
const html = Prism.highlight(text, grammar, lang);
out +=
`<pre class="highlight highlight-source-${lang} notranslate lang-${lang}"><code>${html}</code></pre>`;
}
out += `</div>`;
return out;
}
override blockquote({ text, tokens }: Marked.Tokens.Blockquote): string {
const match = text.match(ADMISSION_REG);
if (match) {
const label: Record<string, string> = {
tip: "Tip",
warn: "Warning",
info: "Info",
};
Marked.walkTokens(tokens, (token) => {
if (token.type === "text" && token.text.startsWith(match[0])) {
token.text = token.text.slice(match[0].length);
}
});
const type = match[1];
const icon = `<svg class="icon"><use href="/icons.svg#${type}" /></svg>`;
return `<blockquote class="admonition ${type}">\n<span class="admonition-header">${icon}${
label[type]
}</span>${Marked.parser(tokens)}</blockquote>\n`;
}
return `<blockquote>\n${Marked.parser(tokens)}</blockquote>\n`;
}
}
export interface MarkdownHeading {
id: string;
html: string;
}
export interface MarkdownOptions {
inline?: boolean;
}
export function renderMarkdown(
input: string,
opts: MarkdownOptions = {},
): { headings: MarkdownHeading[]; html: string } {
const renderer = new DefaultRenderer();
const markedOpts: Marked.MarkedOptions = {
gfm: true,
renderer,
};
const html = opts.inline
? Marked.parseInline(input, markedOpts) as string
: Marked.parse(input, markedOpts) as string;
return { headings: renderer.headings, html };
}