mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
Based on a new addition to marked, this commits replaces the imported marked namespaces with a Marked class, that doesn't change when `marked.use` introduces new options or extensions. Also changes the naming slighty to use `markdown` as module, so it sounds more generic. Reference: https://github.com/markedjs/marked/pull/2831 Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
175 lines
4.9 KiB
TypeScript
175 lines
4.9 KiB
TypeScript
import type { marked } from "marked";
|
|
|
|
import dompurify from "dompurify";
|
|
import emojis from "@app/lib/emojis";
|
|
import katex from "katex";
|
|
import { Marked, Renderer as BaseRenderer } from "marked";
|
|
|
|
dompurify.setConfig({
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
SANITIZE_DOM: false,
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
FORBID_TAGS: ["textarea", "style"],
|
|
});
|
|
|
|
const emojisMarkedExtension = {
|
|
name: "emoji",
|
|
level: "inline",
|
|
start: (src: string) => src.indexOf(":"),
|
|
tokenizer(src: string) {
|
|
const match = src.match(/^:([\w+-]+):/);
|
|
if (match) {
|
|
return {
|
|
type: "emoji",
|
|
raw: match[0],
|
|
text: match[1].trim(),
|
|
};
|
|
}
|
|
},
|
|
renderer: (token: marked.Tokens.Generic): string =>
|
|
`<span>${token.text in emojis ? emojis[token.text] : token.text}</span>`,
|
|
};
|
|
|
|
const katexMarkedExtension = {
|
|
name: "katex",
|
|
level: "inline",
|
|
start: (src: string) => src.indexOf("$"),
|
|
tokenizer(src: string) {
|
|
const match = src.match(/^\$+([^$\n]+?)\$+/);
|
|
if (match) {
|
|
return {
|
|
type: "katex",
|
|
raw: match[0],
|
|
text: match[1].trim(),
|
|
};
|
|
}
|
|
},
|
|
renderer: (token: marked.Tokens.Generic): string =>
|
|
katex.renderToString(token.text, {
|
|
throwOnError: false,
|
|
}),
|
|
};
|
|
const footnotePrefix = "marked-fn";
|
|
const referencePrefix = "marked-fnref";
|
|
const referenceMatch = /^\[\^([^\]]+)\](?!\()/;
|
|
|
|
const footnoteReferenceMarkedExtension = {
|
|
name: "footnote-ref",
|
|
level: "inline",
|
|
start: (src: string) => referenceMatch.test(src),
|
|
tokenizer(src: string) {
|
|
const match = src.match(referenceMatch);
|
|
if (match) {
|
|
return {
|
|
type: "footnote-ref",
|
|
raw: match[0],
|
|
text: match[1].trim(),
|
|
};
|
|
}
|
|
},
|
|
renderer: (token: marked.Tokens.Generic): string =>
|
|
`<sup class="footnote-ref" id="${referencePrefix}:${token.text}"><a href="#${footnotePrefix}:${token.text}">[${token.text}]</a></sup>`,
|
|
};
|
|
const footnoteMatch = /^\[\^([^\]]+)\]:\s([\S]*)/;
|
|
const footnoteMarkedExtension = {
|
|
name: "footnote",
|
|
level: "block",
|
|
start: (src: string) => footnoteMatch.test(src),
|
|
tokenizer(src: string) {
|
|
const match = src.match(footnoteMatch);
|
|
if (match) {
|
|
return {
|
|
type: "footnote",
|
|
raw: match[0],
|
|
reference: match[1].trim(),
|
|
text: match[2].trim(),
|
|
};
|
|
}
|
|
},
|
|
renderer: (token: marked.Tokens.Generic): string =>
|
|
`${
|
|
token.reference === "0" ? "<hr />" : ""
|
|
}<p class="txt-small" id="${footnotePrefix}:${token.reference}">${
|
|
token.reference
|
|
}. ${markedInstance.parseInline(
|
|
token.text,
|
|
)} <a class="txt-tiny ref-arrow" href="#${referencePrefix}:${
|
|
token.reference
|
|
}">↩</a></p>`,
|
|
};
|
|
|
|
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
|
|
// e.g. <a name="test"/> -> <a name="test"></a>
|
|
const anchorMarkedExtension = {
|
|
name: "sanitizedAnchor",
|
|
level: "block",
|
|
start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
|
|
tokenizer(src: string) {
|
|
const match = src.match(/^<a name="([\w]+)"\/>/);
|
|
if (match) {
|
|
return {
|
|
type: "sanitizedAnchor",
|
|
raw: match[0],
|
|
text: match[1].trim(),
|
|
};
|
|
}
|
|
},
|
|
renderer: (token: marked.Tokens.Generic): string =>
|
|
`<a name="${token.text}"></a>`,
|
|
};
|
|
|
|
export class Renderer extends BaseRenderer {
|
|
#baseUrl: string | undefined;
|
|
|
|
/**
|
|
* If `baseUrl` is provided, all hrefs attributes in anchor tags, except those
|
|
* starting with `#`, are resolved with respect to `baseUrl`
|
|
*/
|
|
constructor(baseUrl: string | undefined) {
|
|
super();
|
|
this.#baseUrl = baseUrl;
|
|
}
|
|
// Overwrites the rendering of heading tokens.
|
|
// Since there are possible non ASCII characters in headings,
|
|
// we escape them by replacing them with dashes and,
|
|
// trim eventual dashes on each side of the string.
|
|
heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
|
|
const escapedText = text
|
|
// By lowercasing we avoid casing mismatches, between headings and links.
|
|
.toLowerCase()
|
|
.replace(/[^\w]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
|
|
return `<h${level} id="${escapedText}">${text}</h${level}>`;
|
|
}
|
|
|
|
link(href: string, _title: string, text: string): string {
|
|
if (href.startsWith("#")) {
|
|
// By lowercasing we avoid casing mismatches, between headings and links.
|
|
return `<a href="${href.toLowerCase()}">${text}</a>`;
|
|
} else {
|
|
try {
|
|
href = new URL(href, this.#baseUrl).href;
|
|
} catch {
|
|
// Use original href value
|
|
}
|
|
return `<a href="${href}">${text}</a>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const markedInstance = new Marked({
|
|
extensions: [
|
|
emojisMarkedExtension,
|
|
katexMarkedExtension,
|
|
footnoteMarkedExtension,
|
|
footnoteReferenceMarkedExtension,
|
|
anchorMarkedExtension,
|
|
],
|
|
// TODO: Disables deprecated options, remove once removed from marked
|
|
mangle: false,
|
|
headerIds: false,
|
|
});
|
|
|
|
export default markedInstance;
|