stoat-for-desktop/components/markdown/index.tsx

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()}</>;
}