Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 85 additions & 96 deletions packages/client/components/markdown/plugins/spoiler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ import { visit } from "unist-util-visit";

const Spoiler = styled("span", {
base: {
padding: "0 2px",
display: "inline-block",
padding: "0 6px",
borderRadius: "var(--borderRadius-md)",
},
variants: {
shown: {
true: {
color: "var(--md-sys-color-inverse-on-surface)",
background: "var(--md-sys-color-inverse-surface)",
background: "var(--md-sys-color-inverse-on-surface)",
},
false: {
cursor: "pointer",
userSelect: "none",
color: "transparent",
background: "#151515",
background: "var(--md-sys-color-on-secondary-fixed-variant)",

"> *": {
"& *": {
opacity: 0,
pointerEvents: "none",
},
Expand All @@ -47,106 +47,95 @@ export function RenderSpoiler(props: {
);
}

export const remarkSpoiler: Plugin = () => (tree) => {
visit(
tree,
"paragraph",
(
node: {
children: (
| { type: "text"; value: string }
| { type: "paragraph"; children: unknown[] }
| { type: "spoiler"; children: unknown[] }
)[];
},
_idx,
_parent,
) => {
// Visitor state
let searchingForEnd = -1;
let spoilerContent: object[] = [];

// 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 spoiler from
if (child.type === "text") {
const components = child.value.split("||");
if (components.length === 1) continue; // no spoilers

// Handle terminating spoiler tag
if (searchingForEnd !== -1) {
// Get all preceding elements
const elements = node.children.splice(
searchingForEnd,
i - searchingForEnd,
);

// Create a spoiler
node.children.splice(i, 0, {
type: "spoiler",
children: [
...spoilerContent,
...elements,
{
type: "text",
value: components.shift(),
},
],
});

// Adjust our current index
i += elements.length + 1;

searchingForEnd = -1;
spoilerContent = [];
}

// Replace current child with next component
child.value = components.shift()!;

// Check how many spoilers we have to process
const spillOver = components.length % 2 === 1;
const innerElements = (components.length - (spillOver ? 1 : 0)) / 2;
type ParentNode = {
type: "string";
children: (
| { type: "text"; value: string }
| { type: "paragraph" | "spoiler"; children: Node[] }
)[];
};

// Convert inner elements into spoilers
if (innerElements) {
for (let j = 0; j < innerElements; j++) {
node.children.splice(
i + 1,
0,
export const remarkSpoiler: Plugin = () => (tree) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tNodes = (tree as any).children;
let spillover: Node[] | null;
let spoilerStart = -1;
let spoilerText: string;

visit(tree, "paragraph", (node: ParentNode, tIdx) => {
// Visit all children of paragraphs
for (let i = 0, s, sl; i < node.children.length; ++i) {
const child = node.children[i];

// Find the next text element to start a spoiler from
if (child.type === "text") {
const spoilers = child.value.split("||");
if (spoilers.length === 1) continue; //No spoilers
node.children.splice(i, 1); //Delete this node

//Parse spoiler start & end
for (s = 1, sl = spoilers.length; s < sl; ++s) {
if (spoilerStart !== -1) {
//End spoiler
const sText = spoilers[s - 1],
elements = node.children.splice(spoilerStart, i - spoilerStart),
inject = [
{
type: "spoiler",
children: [
{
type: "text",
value: components.shift(),
},
...(spoilerText!
? [{ type: "text", value: spoilerText }]
: []),
...(spillover || []),
...elements,
...(sText && (spillover || i !== spoilerStart)
? [{ type: "text", value: spoilers[s - 1] }]
: []),
],
},
{
type: "text",
value: components.shift()!,
},
);

i += 2;
}
}

// Update state if we are looking for the end of a spoiler
if (spillOver) {
searchingForEnd = i + 1;
spoilerContent.push({
type: "text",
value: components.pop(),
});
...(spoilers[s] ? [{ type: "text", value: spoilers[s] }] : []),
];

//Inject spoiler
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node.children.splice(spoilerStart, 0, ...(inject as any));
i = spoilerStart + inject.length - 1;

spoilerStart = -1;
spillover = null;
} else {
//Inject non-spoiler text
if (spoilers[s - 1])
node.children.splice(i++, 0, {
type: "text",
value: spoilers[s - 1],
});

//Start spoiler
spoilerStart = i;
spoilerText = spoilers[s];
}
}
}
},
);
}

//Spillover to next parent node
if (spoilerStart !== -1) {
if (!spillover) spillover = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spillover.push(...(node.children.splice(spoilerStart) as any));
spoilerStart = 0;
}

//Append excess spillover
if (spillover && tIdx === tNodes.length - 1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node.children as any[]).push(
...(spoilerText! ? [{ type: "text", value: spoilerText! }] : []),
...spillover,
);
}
});
};

export const spoilerHandler: Handler = (h, node) => {
Expand Down
15 changes: 10 additions & 5 deletions packages/client/components/markdown/sanitise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ const RE_RECURSIVE =
/**
* Regex for matching multi-line blockquotes
*/
const RE_BLOCKQUOTE = /^([^\S\r\n]*>[^\n]+\n?)+/gm;
const RE_BLOCKQUOTE = /^(?:[^\S\r\n]*>[^\n]+\n?)+/gm;

/**
* Regex for matching HTML tags
*/
const RE_HTML_TAGS = /^(<\/?[a-zA-Z0-9]+>)(.*$)/gm;
const RE_HTML_TAGS = /^(?:<\/?[a-zA-Z0-9]+>)(?:.*$)/gm;

/**
* Regex for matching empty lines
Expand All @@ -30,6 +30,8 @@ const RE_PLUS = /^\s*\+(?:$|[^+])/gm;
const RE_CODEBLOCK_EMPTY_LINE_FIX =
/(?<=`{3}[\s\S]*)\n\uF800\n(?=[\s\S]*`{3})/gm;

const RE_SPOILER_URL = /(https?:\/\/[\w_.~!*''();:@&=+$,/?#[%-]+)\|\|/;

/**
* Sanitise Markdown input before rendering
* @param content Input string
Expand All @@ -44,12 +46,12 @@ export function sanitise(content: string) {
// Append empty character if string starts with html tag
// This is to avoid inconsistencies in rendering Markdown inside/after HTML tags
// https://github.com/revoltchat/revite/issues/733
.replace(RE_HTML_TAGS, (match) => `\uF800${match}`)
.replace(RE_HTML_TAGS, "\uF800$&")

// Append empty character if line starts with a plus
// which would usually open a new list but we want
// to avoid that behaviour in our case.
.replace(RE_PLUS, (match) => `\uF800${match}`)
.replace(RE_PLUS, "\uF800$&")

// Replace empty lines with non-breaking space
// because remark renderer is collapsing empty
Expand All @@ -62,7 +64,10 @@ export function sanitise(content: string) {
.replace(RE_CODEBLOCK_EMPTY_LINE_FIX, "")

// Ensure empty line after blockquotes for correct rendering
.replace(RE_BLOCKQUOTE, (match) => `${match}\n`)
.replace(RE_BLOCKQUOTE, "$&\n")

// Prevent spoilers from breaking on links at end
.replace(RE_SPOILER_URL, "$1 ||")
);
}

Expand Down
Loading