diff --git a/packages/client/components/markdown/index.tsx b/packages/client/components/markdown/index.tsx index 53326e22..b9898295 100644 --- a/packages/client/components/markdown/index.tsx +++ b/packages/client/components/markdown/index.tsx @@ -33,6 +33,11 @@ import { remarkSpoiler, spoilerHandler, } from "./plugins/spoiler"; +import { + RenderFormatter, + remarkTextFormattingExtensions, + textFormatHandler, +} from "./plugins/textFormattingExtensions"; import { RenderTimestamp, remarkTimestamps, @@ -61,6 +66,7 @@ const components = () => ({ mention: RenderMention, timestamp: RenderTimestamp, spoiler: RenderSpoiler, + formatter: RenderFormatter, a: RenderAnchor, p: elements.paragraph, @@ -111,6 +117,7 @@ const pipeline = unified() .use(remarkUnicodeEmoji) .use(remarkCustomEmoji) .use(remarkSpoiler) + .use(remarkTextFormattingExtensions) .use(remarkHtmlToText) // @ts-expect-error non-standard elements not recognised by typing .use(remarkRehype, { @@ -120,6 +127,7 @@ const pipeline = unified() mention: mentionHandler, timestamp: timestampHandler, spoiler: spoilerHandler, + formatter: textFormatHandler, }, }) .use(remarkInsertBreaks) diff --git a/packages/client/components/markdown/plugins/textFormattingExtensions.tsx b/packages/client/components/markdown/plugins/textFormattingExtensions.tsx new file mode 100644 index 00000000..83a4b829 --- /dev/null +++ b/packages/client/components/markdown/plugins/textFormattingExtensions.tsx @@ -0,0 +1,244 @@ +import { Handler } from "mdast-util-to-hast"; +import { cva } from "styled-system/css"; +import { Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import z from "zod"; + +type Attributes = Partial<{ + colour: string; + opacity: number; + font: "sans-serif" | "serif" | "monospace" | "cursive" | "casual" | "rounded"; + size: number; + background: string; + weight: number; +}>; + +export function RenderFormatter(props: { + attributes: Attributes; + children: Element; +}) { + // const [shown, setShown] = createSignal(false); + + return ( + + {props.children} + + // setShown(true)}> + // {props.children} + // + ); +} + +const RE_STYLE_BRACE = + /\{([a-zA-Z]+):([^;]+?)(?:;\s*([a-zA-Z]+):([^;]+?))*\}\(/d; + +const RE_BRACKET = /\(|\)/g; + +function reachesCloseBracket(value: string, idx: number) { + let ident = 1; + + RE_BRACKET.lastIndex = idx; + let match = RE_BRACKET.exec(value); + while (match) { + if (match[0] === "(") ident++; + else ident--; + + if (ident === 0) { + return true; + } + + match = RE_BRACKET.exec(value); + } + + return false; +} + +const schema = z + .object({ + colour: z.string(), // TODO: untrusted + opacity: z.preprocess( + (val) => String(val).slice(0, String(val).length - 1), + z.coerce + .number() + .gte(0) + .lte(100) + .int() + .transform((v) => v / 100) + ), + font: z.enum([ + "sans-serif", + "serif", + "monospace", + "cursive", + "casual", + "rounded", + ]), + size: z.coerce.number().gte(0.25).lte(5.0), + background: z.string(), // TODO: untrusted + weight: z.coerce.number().gte(50).lte(950).int(), + }) + .partial(); + +function visitor(node: { + children: ( + | { type: "text"; value: string } + | { type: "paragraph"; children: any[] } + | { type: "formatter"; children: any[]; attributes: Attributes } + )[]; +}) { + // Visit all children of paragraphs + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + + // Find the next text element to start a formatter from + if (child.type === "text") { + // console.info(i, child); + + RE_STYLE_BRACE.lastIndex = 0; + const styleBrace = RE_STYLE_BRACE.exec(child.value); + if (styleBrace) { + const rawAttrs: { [key: string]: string } = styleBrace + .slice(1) + .filter((x) => x) // js preallocates array from previous runs, so we end up with undefined values + .reduce( + (obj, value, idx) => + idx % 2 + ? { + ...(obj as [{}])[0], + [(obj as [{}, string])[1]]: value.trim(), + } + : [obj, value === "color" ? "colour" : value], + {} + ); + + try { + const attributes = schema.parse(rawAttrs); + const [start, end] = styleBrace.indices![0]; + + let successfulMatch = false; + if (reachesCloseBracket(child.value, styleBrace.indices![0][1])) { + // case: split on this element + successfulMatch = true; + + // recursively process children + let children = [ + { + type: "text", + value: child.value.substring(end, RE_BRACKET.lastIndex - 1), + }, + ]; + + // need to preserve this value as it can be affected in call + const lastIndex = RE_BRACKET.lastIndex; + visitor({ children: children as never }); + + // create the formatter and append elements + node.children.splice( + i + 1, + 0, + { + type: "formatter", + attributes, + children, + }, + { + type: "text", + value: child.value.substring(lastIndex), + } + ); + } else { + // case: search other elements and split on whichever matches + let j = i + 1, + foundEnd: { type: "text"; value: string } | undefined; + + for (; j < node.children.length; j++) { + const el = node.children[j]; + if (el.type === "text") { + if (reachesCloseBracket(el.value, 0)) { + foundEnd = el; + break; + } + } + } + + if (foundEnd) { + successfulMatch = true; + + // recursively process children + let children = [ + { + type: "text", + value: child.value.substring(end), + }, + ...node.children.slice(i + 1, j), + { + type: "text", + value: foundEnd.value.substring(0, RE_BRACKET.lastIndex - 1), + }, + ]; + + // need to preserve this value as it can be affected in call + const lastIndex = RE_BRACKET.lastIndex; + visitor({ children: children as never }); + + // insert the formatter + node.children.splice( + i + 1, + j - i, + { + type: "formatter", + attributes, + children, + }, + { + type: "text", + value: foundEnd.value.substring(lastIndex), + } + ); + } + } + + if (successfulMatch) { + // we will naturally skip over the `formatter` and + // reach the next `text` element, so no index + // manipulation is necessary for it + + // strip the value + child.value = child.value.substring(0, start); + } + } catch (err) { + // skip, invalid! + console.error("zod!", err, rawAttrs); + } + } + } + } +} + +export const remarkTextFormattingExtensions: Plugin = () => (tree) => { + visit(tree, "paragraph", visitor); +}; + +export const textFormatHandler: Handler = (h, node) => { + return { + type: "element" as const, + tagName: "formatter", + children: h.all({ + type: "paragraph", + children: node.children, + }), + properties: { + attributes: node.attributes, + }, + }; +};