mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-02 18:20:29 +08:00
166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
import type { Tokens } from "marked";
|
|
|
|
import dompurify from "dompurify";
|
|
import katexMarkedExtension from "marked-katex-extension";
|
|
import markedLinkifyIt from "marked-linkify-it";
|
|
import { Marked, Renderer as BaseRenderer } from "marked";
|
|
|
|
import emojis from "@app/lib/emojis";
|
|
|
|
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: Tokens.Generic): string =>
|
|
`<span>${token.text in emojis ? emojis[token.text] : token.text}</span>`,
|
|
};
|
|
|
|
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: Tokens.Generic): string =>
|
|
`<sup class="txt-tiny 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: Tokens.Generic): string =>
|
|
`<p class="txt-small footnote" id="${footnotePrefix}:${
|
|
token.reference
|
|
}"><span class="marker">${
|
|
token.reference
|
|
}.</span> ${markedInstance.parseInline(
|
|
token.text,
|
|
)} <a class="txt-tiny ref-arrow no-underline" 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: Tokens.Generic): string => `<a name="${token.text}"></a>`,
|
|
};
|
|
|
|
export class Renderer extends BaseRenderer {
|
|
#baseUrl: string | undefined;
|
|
#stripEmphasizedStyling: boolean | 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, stripEmphasizedStyling: boolean) {
|
|
super();
|
|
this.#baseUrl = baseUrl;
|
|
this.#stripEmphasizedStyling = stripEmphasizedStyling;
|
|
}
|
|
// 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}>`;
|
|
}
|
|
|
|
strong(text: string) {
|
|
return this.#stripEmphasizedStyling ? text : `<strong>${text}</strong>`;
|
|
}
|
|
|
|
em(text: string) {
|
|
return this.#stripEmphasizedStyling ? text : `<em>${text}</em>`;
|
|
}
|
|
|
|
link(href: string, title: string | undefined | null, text: string): string {
|
|
if (href.startsWith("#")) {
|
|
// By lowercasing we avoid casing mismatches, between headings and links.
|
|
return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
|
|
} else {
|
|
href = URL.canParse(href, this.#baseUrl)
|
|
? new URL(href, this.#baseUrl).href
|
|
: href;
|
|
|
|
return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const markedInstance = new Marked(
|
|
katexMarkedExtension({ throwOnError: false }),
|
|
markedLinkifyIt({}, { fuzzyLink: false }),
|
|
{
|
|
extensions: [
|
|
emojisMarkedExtension,
|
|
footnoteMarkedExtension,
|
|
footnoteReferenceMarkedExtension,
|
|
anchorMarkedExtension,
|
|
],
|
|
},
|
|
);
|
|
|
|
export default markedInstance;
|