339 lines
8.3 KiB
TypeScript
339 lines
8.3 KiB
TypeScript
import { ComponentProps, JSX, createEffect, createSignal, on } from "solid-js";
|
|
|
|
import "katex/dist/katex.min.css";
|
|
import { html } from "property-information";
|
|
import rehypeHighlight from "rehype-highlight";
|
|
import rehypeKatex from "rehype-katex";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
import remarkMath from "remark-math";
|
|
import remarkParse from "remark-parse";
|
|
import remarkRehype from "remark-rehype";
|
|
import { unified } from "unified";
|
|
import { VFile } from "vfile";
|
|
|
|
import * as elements from "./elements";
|
|
import { injectEmojiSize } from "./emoji/util";
|
|
import { RenderCodeblock } from "./plugins/Codeblock";
|
|
import { RenderAnchor } from "./plugins/anchors";
|
|
import { remarkChannels } from "./plugins/channels";
|
|
import {
|
|
RenderCustomEmoji,
|
|
customEmojiHandler,
|
|
remarkCustomEmoji,
|
|
} from "./plugins/customEmoji";
|
|
import { remarkHtmlToText } from "./plugins/htmlToText";
|
|
import {
|
|
RenderMention,
|
|
mentionHandler,
|
|
remarkMentions,
|
|
} from "./plugins/mentions";
|
|
import { remarkLinkify } from "./plugins/remarkLinkify";
|
|
import {
|
|
RenderSpoiler,
|
|
remarkSpoiler,
|
|
spoilerHandler,
|
|
} from "./plugins/spoiler";
|
|
import {
|
|
RenderTimestamp,
|
|
remarkTimestamps,
|
|
timestampHandler,
|
|
} from "./plugins/timestamps";
|
|
import {
|
|
RenderUnicodeEmoji,
|
|
remarkUnicodeEmoji,
|
|
unicodeEmojiHandler,
|
|
} from "./plugins/unicodeEmoji";
|
|
import { remarkInsertBreaks, sanitise } from "./sanitise";
|
|
import { childrenToSolid } from "./solid-markdown/ast-to-solid";
|
|
import { defaults } from "./solid-markdown/defaults";
|
|
|
|
/**
|
|
* Empty component
|
|
*/
|
|
const Null = () => null;
|
|
|
|
function RenderOrderedList(props: {
|
|
start?: string;
|
|
style?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}) {
|
|
return (
|
|
<elements.orderedList
|
|
{...props}
|
|
style={{
|
|
...(props.start
|
|
? { "--start-number": (parseInt(props.start, 10) - 1).toString() }
|
|
: {}),
|
|
...props.style,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom Markdown components
|
|
*/
|
|
const components = () => ({
|
|
unicodeEmoji: RenderUnicodeEmoji,
|
|
customEmoji: RenderCustomEmoji,
|
|
mention: RenderMention,
|
|
timestamp: RenderTimestamp,
|
|
spoiler: RenderSpoiler,
|
|
|
|
a: RenderAnchor,
|
|
p: elements.paragraph,
|
|
em: elements.emphasis,
|
|
strong: elements.strong,
|
|
del: elements.strikethrough,
|
|
h1: elements.heading1,
|
|
h2: elements.heading2,
|
|
h3: elements.heading3,
|
|
h4: elements.heading4,
|
|
h5: elements.heading5,
|
|
h6: elements.heading6,
|
|
pre: RenderCodeblock,
|
|
li: elements.listItem,
|
|
ul: elements.unorderedList,
|
|
ol: RenderOrderedList,
|
|
blockquote: elements.blockquote,
|
|
table: elements.table,
|
|
th: elements.tableHeader,
|
|
td: elements.tableElement,
|
|
code: elements.code,
|
|
time: elements.time,
|
|
|
|
// Block image elements
|
|
img: Null,
|
|
// Catch literally everything else just in case
|
|
video: Null,
|
|
figure: Null,
|
|
picture: Null,
|
|
source: Null,
|
|
audio: Null,
|
|
script: Null,
|
|
style: Null,
|
|
});
|
|
|
|
const replyComponents = () => ({
|
|
unicodeEmoji: RenderUnicodeEmoji,
|
|
customEmoji: RenderCustomEmoji,
|
|
mention: (props: ComponentProps<typeof RenderMention>) => {
|
|
// eslint-disable-next-line solid/reactivity
|
|
props.disabled = true;
|
|
return RenderMention(props);
|
|
},
|
|
spoiler: (props: ComponentProps<typeof RenderSpoiler>) => {
|
|
// eslint-disable-next-line solid/reactivity
|
|
props.disabled = true;
|
|
return RenderSpoiler(props);
|
|
},
|
|
a: (props: ComponentProps<typeof RenderAnchor>) => {
|
|
// eslint-disable-next-line solid/reactivity
|
|
props.disabled = true;
|
|
return RenderAnchor(props);
|
|
},
|
|
|
|
strong: elements.strong,
|
|
em: elements.emphasis,
|
|
code: elements.code,
|
|
del: elements.strikethrough,
|
|
|
|
p: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h1: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h2: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h3: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h4: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h5: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
h6: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
li: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
ul: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
ol: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
blockquote: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
td: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
th: (props: { children: JSX.Element }) => <>{props.children}</>,
|
|
time: RenderTimestamp,
|
|
timestamp: RenderTimestamp,
|
|
pre: Null,
|
|
table: Null,
|
|
img: Null,
|
|
video: Null,
|
|
figure: Null,
|
|
picture: Null,
|
|
source: Null,
|
|
audio: Null,
|
|
script: Null,
|
|
style: Null,
|
|
});
|
|
|
|
/**
|
|
* Unified Markdown renderer
|
|
*/
|
|
export const unifiedPipeline = unified()
|
|
.use(remarkParse)
|
|
.use(remarkBreaks)
|
|
.use(remarkGfm)
|
|
.use(remarkMath, {
|
|
// TODO: fork for \[\] support
|
|
singleDollarTextMath: false,
|
|
});
|
|
|
|
/**
|
|
* for schema only, todo: clean up
|
|
*/
|
|
export const UNIFIED_PLUGINS = [
|
|
remarkMentions,
|
|
remarkTimestamps,
|
|
remarkChannels,
|
|
remarkUnicodeEmoji,
|
|
remarkCustomEmoji,
|
|
remarkSpoiler,
|
|
remarkHtmlToText,
|
|
];
|
|
|
|
const HTML_UNIFIED_PLUGINS = [
|
|
remarkMentions,
|
|
remarkTimestamps,
|
|
remarkChannels,
|
|
remarkUnicodeEmoji,
|
|
remarkCustomEmoji,
|
|
remarkSpoiler,
|
|
remarkLinkify,
|
|
remarkHtmlToText,
|
|
];
|
|
|
|
const htmlPipeline = HTML_UNIFIED_PLUGINS.reduce(
|
|
(pipeline, plugin) => pipeline.use(plugin) as never,
|
|
unifiedPipeline,
|
|
)
|
|
// @ts-expect-error non-standard elements not recognised by typing
|
|
.use(remarkRehype, {
|
|
handlers: {
|
|
unicodeEmoji: unicodeEmojiHandler,
|
|
customEmoji: customEmojiHandler,
|
|
mention: mentionHandler,
|
|
timestamp: timestampHandler,
|
|
spoiler: spoilerHandler,
|
|
},
|
|
})
|
|
.use(remarkInsertBreaks)
|
|
.use(rehypeKatex, {
|
|
maxSize: 10,
|
|
maxExpand: 2,
|
|
trust: false,
|
|
strict: false,
|
|
output: "html",
|
|
errorColor: "var(--md-sys-color-error)",
|
|
})
|
|
.use(rehypeHighlight);
|
|
|
|
const replyPipeline = unified()
|
|
.use(remarkParse)
|
|
.use(remarkBreaks)
|
|
.use(remarkGfm)
|
|
.use(remarkMentions)
|
|
.use(remarkUnicodeEmoji)
|
|
.use(remarkCustomEmoji)
|
|
.use(remarkSpoiler)
|
|
.use(remarkLinkify)
|
|
// @ts-expect-error non-standard elements not recognized by typing
|
|
.use(remarkRehype, {
|
|
handlers: {
|
|
unicodeEmoji: unicodeEmojiHandler,
|
|
customEmoji: customEmojiHandler,
|
|
mention: mentionHandler,
|
|
spoiler: spoilerHandler,
|
|
},
|
|
})
|
|
.use(remarkInsertBreaks);
|
|
|
|
export interface MarkdownProps {
|
|
/**
|
|
* Content to render
|
|
*/
|
|
content?: string;
|
|
|
|
/**
|
|
* Whether to prevent big emoji from rendering
|
|
*/
|
|
disallowBigEmoji?: boolean;
|
|
}
|
|
|
|
export { Emoji } from "./emoji/Emoji";
|
|
export { TextWithEmoji } from "./emoji/TextWithEmoji";
|
|
|
|
export function renderSimpleMarkdown(content: string) {
|
|
const file = new VFile();
|
|
file.value = sanitise(content);
|
|
|
|
const hastNode = replyPipeline.runSync(replyPipeline.parse(file), file);
|
|
|
|
if (hastNode.type !== "root") {
|
|
throw new TypeError("Expected a `root` node");
|
|
}
|
|
|
|
return childrenToSolid(
|
|
{
|
|
options: {
|
|
...defaults,
|
|
// @ts-expect-error it doesn't like the td component
|
|
components: replyComponents(),
|
|
},
|
|
schema: html,
|
|
listDepth: 0,
|
|
},
|
|
hastNode,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remark renderer component
|
|
*/
|
|
export function Markdown(props: MarkdownProps) {
|
|
/**
|
|
* Render some given Markdown content
|
|
* @param content content
|
|
*/
|
|
function render(content = "") {
|
|
const file = new VFile();
|
|
file.value = sanitise(content);
|
|
|
|
const hastNode = htmlPipeline.runSync(htmlPipeline.parse(file), file);
|
|
|
|
if (hastNode.type !== "root") {
|
|
throw new TypeError("Expected a `root` node");
|
|
}
|
|
|
|
injectEmojiSize(props, hastNode as never);
|
|
|
|
return childrenToSolid(
|
|
{
|
|
options: {
|
|
...defaults,
|
|
// @ts-expect-error it doesn't like the td component
|
|
components: components(),
|
|
},
|
|
schema: html,
|
|
listDepth: 0,
|
|
},
|
|
hastNode,
|
|
);
|
|
}
|
|
|
|
// Render once immediately
|
|
// eslint-disable-next-line solid/reactivity
|
|
const [children, setChildren] = createSignal(render(props.content));
|
|
|
|
// If it ever updates, re-render the whole tree:
|
|
createEffect(
|
|
on(
|
|
() => props.content,
|
|
(content) => setChildren(render(content)),
|
|
{ defer: true },
|
|
),
|
|
);
|
|
|
|
// Give it to Solid:
|
|
return <>{children()}</>;
|
|
}
|