Skip to content

Commit

Permalink
feat: initial commit of text formatting extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
insertish committed Nov 28, 2024
1 parent e5804e8 commit 0a5da99
Show file tree
Hide file tree
Showing 2 changed files with 252 additions and 0 deletions.
8 changes: 8 additions & 0 deletions packages/client/components/markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import {
remarkSpoiler,
spoilerHandler,
} from "./plugins/spoiler";
import {
RenderFormatter,
remarkTextFormattingExtensions,
textFormatHandler,
} from "./plugins/textFormattingExtensions";
import {
RenderTimestamp,
remarkTimestamps,
Expand Down Expand Up @@ -61,6 +66,7 @@ const components = () => ({
mention: RenderMention,
timestamp: RenderTimestamp,
spoiler: RenderSpoiler,
formatter: RenderFormatter,

a: RenderAnchor,
p: elements.paragraph,
Expand Down Expand Up @@ -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, {
Expand All @@ -120,6 +127,7 @@ const pipeline = unified()
mention: mentionHandler,
timestamp: timestampHandler,
spoiler: spoilerHandler,
formatter: textFormatHandler,
},
})
.use(remarkInsertBreaks)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<span
style={{
color: props.attributes.colour,
background: props.attributes.background,
opacity: props.attributes.opacity,
"font-weight": props.attributes.weight,
"font-size": props.attributes.size
? `${props.attributes.size}em`
: undefined,
}}
>
{props.children}
</span>
// <Spoiler shown={shown()} onClick={() => setShown(true)}>
// {props.children}
// </Spoiler>
);
}

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,
},
};
};

0 comments on commit 0a5da99

Please sign in to comment.