= {
- b: MarkType.Bold,
- strong: MarkType.Bold,
- i: MarkType.Italic,
- em: MarkType.Italic,
- u: MarkType.Underline,
- s: MarkType.StrikeThrough,
- del: MarkType.StrikeThrough,
- code: MarkType.Code,
- span: MarkType.Spoiler,
-};
+type ProcessTextCallback = (text: string) => string;
-const elementToTextMark = (node: Element): MarkType | undefined => {
- const markType = markNodeToType[node.name];
- if (!markType) return undefined;
-
- if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
- return undefined;
- }
- if (
- markType === MarkType.Code &&
- node.parent &&
- 'name' in node.parent &&
- node.parent.name === 'pre'
- ) {
- return undefined;
- }
- return markType;
-};
-
-const parseNodeText = (node: ChildNode): string => {
+const getText = (node: ChildNode): string => {
if (isText(node)) {
return node.data;
}
if (isTag(node)) {
- return node.children.map((child) => parseNodeText(child)).join('');
+ return node.children.map((child) => getText(child)).join('');
}
return '';
};
-const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
+const getInlineNodeMarkType = (node: Element): MarkType | undefined => {
+ if (node.name === 'b' || node.name === 'strong') {
+ return MarkType.Bold;
+ }
+
+ if (node.name === 'i' || node.name === 'em') {
+ return MarkType.Italic;
+ }
+
+ if (node.name === 'u') {
+ return MarkType.Underline;
+ }
+
+ if (node.name === 's' || node.name === 'del') {
+ return MarkType.StrikeThrough;
+ }
+
+ if (node.name === 'code') {
+ if (node.parent && 'name' in node.parent && node.parent.name === 'pre') {
+ return undefined; // Don't apply `Code` mark inside a tag
+ }
+ return MarkType.Code;
+ }
+
+ if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
+ return MarkType.Spoiler;
+ }
+
+ return undefined;
+};
+
+const getInlineMarkElement = (
+ markType: MarkType,
+ node: Element,
+ getChild: (child: ChildNode) => InlineElement[]
+): InlineElement[] => {
+ const children = node.children.flatMap(getChild);
+ const mdSequence = node.attribs['data-md'];
+ if (mdSequence !== undefined) {
+ children.unshift({ text: mdSequence });
+ children.push({ text: mdSequence });
+ return children;
+ }
+ children.forEach((child) => {
+ if (Text.isText(child)) {
+ child[markType] = true;
+ }
+ });
+ return children;
+};
+
+const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
const { src, alt } = node.attribs;
if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (testMatrixTo(href)) {
const userMention = parseMatrixToUser(href);
if (userMention) {
- return createMentionElement(userMention, parseNodeText(node) || userMention, false);
+ return createMentionElement(userMention, getText(node) || userMention, false);
}
const roomMention = parseMatrixToRoom(href);
if (roomMention) {
return createMentionElement(
roomMention.roomIdOrAlias,
- parseNodeText(node) || roomMention.roomIdOrAlias,
+ getText(node) || roomMention.roomIdOrAlias,
false,
undefined,
roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (eventMention) {
return createMentionElement(
eventMention.roomIdOrAlias,
- parseNodeText(node) || eventMention.roomIdOrAlias,
+ getText(node) || eventMention.roomIdOrAlias,
false,
eventMention.eventId,
eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
return undefined;
};
-const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
if (isText(node)) {
- return [{ text: node.data }];
+ return [{ text: processText(node.data) }];
}
+
if (isTag(node)) {
- const markType = elementToTextMark(node);
+ const markType = getInlineNodeMarkType(node);
if (markType) {
- const children = node.children.flatMap(parseInlineNodes);
- if (node.attribs['data-md'] !== undefined) {
- children.unshift({ text: node.attribs['data-md'] });
- children.push({ text: node.attribs['data-md'] });
- } else {
- children.forEach((child) => {
- if (Text.isText(child)) {
- child[markType] = true;
- }
- });
- }
- return children;
+ return getInlineMarkElement(markType, node, (child) => {
+ if (markType === MarkType.Code) return [{ text: getText(child) }];
+ return getInlineElement(child, processText);
+ });
}
- const inlineNode = elementToInlineNode(node);
+ const inlineNode = getInlineNonMarkElement(node);
if (inlineNode) return [inlineNode];
if (node.name === 'a') {
- const children = node.childNodes.flatMap(parseInlineNodes);
+ const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` });
return children;
}
- return node.childNodes.flatMap(parseInlineNodes);
+ return node.childNodes.flatMap((child) => getInlineElement(child, processText));
}
return [];
};
-const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
+const parseBlockquoteNode = (
+ node: Element,
+ processText: ProcessTextCallback
+): BlockQuoteElement[] | ParagraphElement[] => {
const quoteLines: Array = [];
let lineHolder: InlineElement[] = [];
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
node.children.forEach((child) => {
if (isText(child)) {
- lineHolder.push({ text: child.data });
+ lineHolder.push({ text: processText(child.data) });
return;
}
if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
if (child.name === 'p') {
appendLine();
- quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+ quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return;
}
- parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+ lineHolder.push(...getInlineElement(child, processText));
}
});
appendLine();
- if (node.attribs['data-md'] !== undefined) {
+ const mdSequence = node.attribs['data-md'];
+ if (mdSequence !== undefined) {
return quoteLines.map((lineChildren) => ({
type: BlockType.Paragraph,
- children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
+ children: [{ text: `${mdSequence} ` }, ...lineChildren],
}));
}
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
];
};
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
- const codeLines = parseNodeText(node).trim().split('\n');
+ const codeLines = getText(node).trim().split('\n');
- if (node.attribs['data-md'] !== undefined) {
- const pLines = codeLines.map((lineText) => ({
+ const mdSequence = node.attribs['data-md'];
+ if (mdSequence !== undefined) {
+ const pLines = codeLines.map((text) => ({
type: BlockType.Paragraph,
- children: [
- {
- text: lineText,
- },
- ],
+ children: [{ text }],
}));
const childCode = node.children[0];
const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
- const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
- const suffix = { text: node.attribs['data-md'] };
+ const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
+ const suffix = { text: mdSequence };
return [
{ type: BlockType.Paragraph, children: [prefix] },
...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
return [
{
type: BlockType.CodeBlock,
- children: codeLines.map((lineTxt) => ({
+ children: codeLines.map((text) => ({
type: BlockType.CodeLine,
- children: [
- {
- text: lineTxt,
- },
- ],
+ children: [{ text }],
})),
},
];
};
const parseListNode = (
- node: Element
+ node: Element,
+ processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array = [];
let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
node.children.forEach((child) => {
if (isText(child)) {
- lineHolder.push({ text: child.data });
+ lineHolder.push({ text: processText(child.data) });
return;
}
if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
if (child.name === 'li') {
appendLine();
- listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+ listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return;
}
- parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+ lineHolder.push(...getInlineElement(child, processText));
}
});
appendLine();
- if (node.attribs['data-md'] !== undefined) {
- const prefix = node.attribs['data-md'] || '-';
+ const mdSequence = node.attribs['data-md'];
+ if (mdSequence !== undefined) {
+ const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({
type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
},
];
};
-const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
- const children = node.children.flatMap((child) => parseInlineNodes(child));
+const parseHeadingNode = (
+ node: Element,
+ processText: ProcessTextCallback
+): HeadingElement | ParagraphElement => {
+ const children = node.children.flatMap((child) => getInlineElement(child, processText));
const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
const level = parseInt(g1AsLevel, 10);
- if (node.attribs['data-md'] !== undefined) {
+ const mdSequence = node.attribs['data-md'];
+ if (mdSequence !== undefined) {
return {
type: BlockType.Paragraph,
- children: [{ text: `${node.attribs['data-md']} ` }, ...children],
+ children: [{ text: `${mdSequence} ` }, ...children],
};
}
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
};
};
-export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+export const domToEditorInput = (
+ domNodes: ChildNode[],
+ processText: ProcessTextCallback,
+ processLineStartText: ProcessTextCallback
+): Descendant[] => {
const children: Descendant[] = [];
let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
domNodes.forEach((node) => {
if (isText(node)) {
- lineHolder.push({ text: node.data });
+ if (lineHolder.length === 0) {
+ // we are inserting first part of line
+ // it may contain block markdown starting data
+ // that we may need to escape.
+ lineHolder.push({ text: processLineStartText(node.data) });
+ return;
+ }
+ lineHolder.push({ text: processText(node.data) });
return;
}
if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
appendLine();
children.push({
type: BlockType.Paragraph,
- children: node.children.flatMap((child) => parseInlineNodes(child)),
+ children: node.children.flatMap((child) => getInlineElement(child, processText)),
});
return;
}
if (node.name === 'blockquote') {
appendLine();
- children.push(...parseBlockquoteNode(node));
+ children.push(...parseBlockquoteNode(node, processText));
return;
}
if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
}
if (node.name === 'ol' || node.name === 'ul') {
appendLine();
- children.push(...parseListNode(node));
+ children.push(...parseListNode(node, processText));
return;
}
if (node.name.match(/^h[123456]$/)) {
appendLine();
- children.push(parseHeadingNode(node));
+ children.push(parseHeadingNode(node, processText));
return;
}
- parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+ lineHolder.push(...getInlineElement(node, processText));
}
});
appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
return children;
};
-export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
+ const processText = (partText: string) => {
+ if (!markdown) return partText;
+ return escapeMarkdownInlineSequences(partText);
+ };
+
const domNodes = parse(sanitizedHtml);
- const editorNodes = domToEditorInput(domNodes);
+ const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
+ if (!markdown) return lineStartText;
+ return escapeMarkdownBlockSequences(lineStartText, processText);
+ });
return editorNodes;
};
-export const plainToEditorInput = (text: string): Descendant[] => {
+export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph,
children: [
{
- text: lineText,
+ text: markdown
+ ? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
+ : lineText,
},
],
};
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
index c5ecc6de..256bdbd9 100644
--- a/src/app/components/editor/output.ts
+++ b/src/app/components/editor/output.ts
@@ -3,7 +3,12 @@ import { Descendant, Text } from 'slate';
import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types';
import { CustomElement } from './slate';
-import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
+import {
+ parseBlockMD,
+ parseInlineMD,
+ unescapeMarkdownBlockSequences,
+ unescapeMarkdownInlineSequences,
+} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
@@ -19,7 +24,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.bold) string = `${string}`;
if (node.italic) string = `${string}`;
if (node.underline) string = `${string}`;
- if (node.strikeThrough) string = `${string}`;
+ if (node.strikeThrough) string = `${string}`;
if (node.code) string = `${string}`;
if (node.spoiler) string = `${string}`;
}
@@ -102,7 +107,8 @@ export const toMatrixCustomHTML = (
allowBlockMarkdown: false,
})
.replace(/
$/, '\n')
- .replace(/^>/, '>');
+ .replace(/^(\\*)>/, '$1>');
+
markdownLines += line;
if (index === targetNodes.length - 1) {
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -157,11 +163,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
}
};
-export const toPlainText = (node: Descendant | Descendant[]): string => {
- if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
- if (Text.isText(node)) return node.text;
+export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
+ if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
+ if (Text.isText(node))
+ return isMarkdown
+ ? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
+ : node.text;
- const children = node.children.map((n) => toPlainText(n)).join('');
+ const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
return elementToPlainText(node, children);
};
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 897cdd48..c4befef6 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -255,7 +255,7 @@ export const RoomInput = forwardRef(
const commandName = getBeginCommand(editor);
- let plainText = toPlainText(editor.children).trim();
+ let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
index 0c995030..deeb8215 100644
--- a/src/app/features/room/message/MessageEditor.tsx
+++ b/src/app/features/room/message/MessageEditor.tsx
@@ -92,7 +92,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
- const plainText = toPlainText(editor.children).trim();
+ const plainText = toPlainText(editor.children, isMarkdown).trim();
const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
@@ -192,8 +192,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const initialValue =
typeof customHtml === 'string'
- ? htmlToEditorInput(customHtml)
- : plainToEditorInput(typeof body === 'string' ? body : '');
+ ? htmlToEditorInput(customHtml, isMarkdown)
+ : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
Transforms.select(editor, {
anchor: Editor.start(editor, []),
@@ -202,7 +202,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor);
- }, [editor, getPrevBodyAndFormattedBody]);
+ }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
useEffect(() => {
if (saveState.status === AsyncStatus.Success) {
diff --git a/src/app/plugins/markdown.ts b/src/app/plugins/markdown.ts
deleted file mode 100644
index 9b3b82f7..00000000
--- a/src/app/plugins/markdown.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-export type MatchResult = RegExpMatchArray | RegExpExecArray;
-export type RuleMatch = (text: string) => MatchResult | null;
-
-export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
- text.slice(0, match.index);
-export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
- text.slice((match.index ?? 0) + match[0].length);
-
-export const replaceMatch = (
- convertPart: (txt: string) => Array,
- text: string,
- match: MatchResult,
- content: C
-): Array => [
- ...convertPart(beforeMatch(text, match)),
- content,
- ...convertPart(afterMatch(text, match)),
-];
-
-/*
- *****************
- * INLINE PARSER *
- *****************
- */
-
-export type InlineMDParser = (text: string) => string;
-
-export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string;
-
-export type InlineMDRule = {
- match: RuleMatch;
- html: InlineMatchConverter;
-};
-
-export type InlineRuleRunner = (
- parse: InlineMDParser,
- text: string,
- rule: InlineMDRule
-) => string | undefined;
-export type InlineRulesRunner = (
- parse: InlineMDParser,
- text: string,
- rules: InlineMDRule[]
-) => string | undefined;
-
-const MIN_ANY = '(.+?)';
-const URL_NEG_LB = '(? text.match(BOLD_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const ITALIC_MD_1 = '*';
-const ITALIC_PREFIX_1 = '\\*';
-const ITALIC_NEG_LA_1 = '(?!\\*)';
-const ITALIC_REG_1 = new RegExp(
- `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`
-);
-const ItalicRule1: InlineMDRule = {
- match: (text) => text.match(ITALIC_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const ITALIC_MD_2 = '_';
-const ITALIC_PREFIX_2 = '_';
-const ITALIC_NEG_LA_2 = '(?!_)';
-const ITALIC_REG_2 = new RegExp(
- `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`
-);
-const ItalicRule2: InlineMDRule = {
- match: (text) => text.match(ITALIC_REG_2),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const UNDERLINE_MD_1 = '__';
-const UNDERLINE_PREFIX_1 = '_{2}';
-const UNDERLINE_NEG_LA_1 = '(?!_)';
-const UNDERLINE_REG_1 = new RegExp(
- `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
-);
-const UnderlineRule: InlineMDRule = {
- match: (text) => text.match(UNDERLINE_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const STRIKE_MD_1 = '~~';
-const STRIKE_PREFIX_1 = '~{2}';
-const STRIKE_NEG_LA_1 = '(?!~)';
-const STRIKE_REG_1 = new RegExp(
- `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`
-);
-const StrikeRule: InlineMDRule = {
- match: (text) => text.match(STRIKE_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const CODE_MD_1 = '`';
-const CODE_PREFIX_1 = '`';
-const CODE_NEG_LA_1 = '(?!`)';
-const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
-const CodeRule: InlineMDRule = {
- match: (text) => text.match(CODE_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${g2}`;
- },
-};
-
-const SPOILER_MD_1 = '||';
-const SPOILER_PREFIX_1 = '\\|{2}';
-const SPOILER_NEG_LA_1 = '(?!\\|)';
-const SPOILER_REG_1 = new RegExp(
- `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
-);
-const SpoilerRule: InlineMDRule = {
- match: (text) => text.match(SPOILER_REG_1),
- html: (parse, match) => {
- const [, , g2] = match;
- return `${parse(g2)}`;
- },
-};
-
-const LINK_ALT = `\\[${MIN_ANY}\\]`;
-const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
-const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
-const LinkRule: InlineMDRule = {
- match: (text) => text.match(LINK_REG_1),
- html: (parse, match) => {
- const [, g1, g2] = match;
- return `${parse(g1)}`;
- },
-};
-
-const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
- const matchResult = rule.match(text);
- if (matchResult) {
- const content = rule.html(parse, matchResult);
- return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
- }
- return undefined;
-};
-
-/**
- * Runs multiple rules at the same time to better handle nested rules.
- * Rules will be run in the order they appear.
- */
-const runInlineRules: InlineRulesRunner = (parse, text, rules) => {
- const matchResults = rules.map((rule) => rule.match(text));
-
- let targetRule: InlineMDRule | undefined;
- let targetResult: MatchResult | undefined;
-
- for (let i = 0; i < matchResults.length; i += 1) {
- const currentResult = matchResults[i];
- if (currentResult && typeof currentResult.index === 'number') {
- if (
- !targetResult ||
- (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
- ) {
- targetResult = currentResult;
- targetRule = rules[i];
- }
- }
- }
-
- if (targetRule && targetResult) {
- const content = targetRule.html(parse, targetResult);
- return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
- }
- return undefined;
-};
-
-const LeveledRules = [
- BoldRule,
- ItalicRule1,
- UnderlineRule,
- ItalicRule2,
- StrikeRule,
- SpoilerRule,
- LinkRule,
-];
-
-export const parseInlineMD: InlineMDParser = (text) => {
- if (text === '') return text;
- let result: string | undefined;
- if (!result) result = runInlineRule(parseInlineMD, text, CodeRule);
-
- if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules);
-
- return result ?? text;
-};
-
-/*
- ****************
- * BLOCK PARSER *
- ****************
- */
-
-export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string;
-
-export type BlockMatchConverter = (
- match: MatchResult,
- parseInline?: (txt: string) => string
-) => string;
-
-export type BlockMDRule = {
- match: RuleMatch;
- html: BlockMatchConverter;
-};
-
-export type BlockRuleRunner = (
- parse: BlockMDParser,
- text: string,
- rule: BlockMDRule,
- parseInline?: (txt: string) => string
-) => string | undefined;
-
-const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
-const HeadingRule: BlockMDRule = {
- match: (text) => text.match(HEADING_REG_1),
- html: (match, parseInline) => {
- const [, g1, g2] = match;
- const level = g1.length;
- return `${parseInline ? parseInline(g2) : g2}`;
- },
-};
-
-const CODEBLOCK_MD_1 = '```';
-const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
-const CodeBlockRule: BlockMDRule = {
- match: (text) => text.match(CODEBLOCK_REG_1),
- html: (match) => {
- const [, g1, g2] = match;
- const classNameAtt = g1 ? ` class="language-${g1}"` : '';
- return `${g2}
`;
- },
-};
-
-const BLOCKQUOTE_MD_1 = '>';
-const QUOTE_LINE_PREFIX = /^> */;
-const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/;
-const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m;
-const BlockQuoteRule: BlockMDRule = {
- match: (text) => text.match(BLOCKQUOTE_REG_1),
- html: (match, parseInline) => {
- const [blockquoteText] = match;
-
- const lines = blockquoteText
- .replace(BLOCKQUOTE_TRAILING_NEWLINE, '')
- .split('\n')
- .map((lineText) => {
- const line = lineText.replace(QUOTE_LINE_PREFIX, '');
- if (parseInline) return `${parseInline(line)}
`;
- return `${line}
`;
- })
- .join('');
- return `${lines}
`;
- },
-};
-
-const ORDERED_LIST_MD_1 = '-';
-const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
-const O_LIST_START = /^([\d])\./;
-const O_LIST_TYPE = /^([aAiI])\./;
-const O_LIST_TRAILING_NEWLINE = /\n$/;
-const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
-const OrderedListRule: BlockMDRule = {
- match: (text) => text.match(ORDERED_LIST_REG_1),
- html: (match, parseInline) => {
- const [listText] = match;
- const [, listStart] = listText.match(O_LIST_START) ?? [];
- const [, listType] = listText.match(O_LIST_TYPE) ?? [];
-
- const lines = listText
- .replace(O_LIST_TRAILING_NEWLINE, '')
- .split('\n')
- .map((lineText) => {
- const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
- const txt = parseInline ? parseInline(line) : line;
- return `${txt}
`;
- })
- .join('');
-
- const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
- const startAtt = listStart ? ` start="${listStart}"` : '';
- const typeAtt = listType ? ` type="${listType}"` : '';
- return `${lines}
`;
- },
-};
-
-const UNORDERED_LIST_MD_1 = '*';
-const U_LIST_ITEM_PREFIX = /^\* */;
-const U_LIST_TRAILING_NEWLINE = /\n$/;
-const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
-const UnorderedListRule: BlockMDRule = {
- match: (text) => text.match(UNORDERED_LIST_REG_1),
- html: (match, parseInline) => {
- const [listText] = match;
-
- const lines = listText
- .replace(U_LIST_TRAILING_NEWLINE, '')
- .split('\n')
- .map((lineText) => {
- const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
- const txt = parseInline ? parseInline(line) : line;
- return `${txt}
`;
- })
- .join('');
-
- return ``;
- },
-};
-
-const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => {
- const matchResult = rule.match(text);
- if (matchResult) {
- const content = rule.html(matchResult, parseInline);
- return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join('');
- }
- return undefined;
-};
-
-export const parseBlockMD: BlockMDParser = (text, parseInline) => {
- if (text === '') return text;
- let result: string | undefined;
-
- if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline);
- if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline);
- if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline);
- if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline);
- if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline);
-
- // replace \n with
because want to preserve empty lines
- if (!result) {
- if (parseInline) {
- result = text
- .split('\n')
- .map((lineText) => parseInline(lineText))
- .join('
');
- } else {
- result = text.replace(/\n/g, '
');
- }
- }
-
- return result ?? text;
-};
diff --git a/src/app/plugins/markdown/block/index.ts b/src/app/plugins/markdown/block/index.ts
new file mode 100644
index 00000000..75aa8b93
--- /dev/null
+++ b/src/app/plugins/markdown/block/index.ts
@@ -0,0 +1 @@
+export * from './parser';
diff --git a/src/app/plugins/markdown/block/parser.ts b/src/app/plugins/markdown/block/parser.ts
new file mode 100644
index 00000000..ed16a327
--- /dev/null
+++ b/src/app/plugins/markdown/block/parser.ts
@@ -0,0 +1,47 @@
+import { replaceMatch } from '../internal';
+import {
+ BlockQuoteRule,
+ CodeBlockRule,
+ ESC_BLOCK_SEQ,
+ HeadingRule,
+ OrderedListRule,
+ UnorderedListRule,
+} from './rules';
+import { runBlockRule } from './runner';
+import { BlockMDParser } from './type';
+
+/**
+ * Parses block-level markdown text into HTML using defined block rules.
+ *
+ * @param text - The markdown text to be parsed.
+ * @param parseInline - Optional function to parse inline elements.
+ * @returns The parsed HTML or the original text if no block-level markdown was found.
+ */
+export const parseBlockMD: BlockMDParser = (text, parseInline) => {
+ if (text === '') return text;
+ let result: string | undefined;
+
+ if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
+ if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
+ if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
+ if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
+ if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
+
+ // replace \n with
because want to preserve empty lines
+ if (!result) {
+ result = text
+ .split('\n')
+ .map((lineText) => {
+ const match = lineText.match(ESC_BLOCK_SEQ);
+ if (!match) {
+ return parseInline?.(lineText) ?? lineText;
+ }
+
+ const [, g1] = match;
+ return replaceMatch(lineText, match, g1, (t) => [parseInline?.(t) ?? t]).join('');
+ })
+ .join('
');
+ }
+
+ return result ?? text;
+};
diff --git a/src/app/plugins/markdown/block/rules.ts b/src/app/plugins/markdown/block/rules.ts
new file mode 100644
index 00000000..f598ee63
--- /dev/null
+++ b/src/app/plugins/markdown/block/rules.ts
@@ -0,0 +1,100 @@
+import { BlockMDRule } from './type';
+
+const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
+export const HeadingRule: BlockMDRule = {
+ match: (text) => text.match(HEADING_REG_1),
+ html: (match, parseInline) => {
+ const [, g1, g2] = match;
+ const level = g1.length;
+ return `${parseInline ? parseInline(g2) : g2}`;
+ },
+};
+
+const CODEBLOCK_MD_1 = '```';
+const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
+export const CodeBlockRule: BlockMDRule = {
+ match: (text) => text.match(CODEBLOCK_REG_1),
+ html: (match) => {
+ const [, g1, g2] = match;
+ const classNameAtt = g1 ? ` class="language-${g1}"` : '';
+ return `${g2}
`;
+ },
+};
+
+const BLOCKQUOTE_MD_1 = '>';
+const QUOTE_LINE_PREFIX = /^> */;
+const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/;
+const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m;
+export const BlockQuoteRule: BlockMDRule = {
+ match: (text) => text.match(BLOCKQUOTE_REG_1),
+ html: (match, parseInline) => {
+ const [blockquoteText] = match;
+
+ const lines = blockquoteText
+ .replace(BLOCKQUOTE_TRAILING_NEWLINE, '')
+ .split('\n')
+ .map((lineText) => {
+ const line = lineText.replace(QUOTE_LINE_PREFIX, '');
+ if (parseInline) return `${parseInline(line)}
`;
+ return `${line}
`;
+ })
+ .join('');
+ return `${lines}
`;
+ },
+};
+
+const ORDERED_LIST_MD_1 = '-';
+const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
+const O_LIST_START = /^([\d])\./;
+const O_LIST_TYPE = /^([aAiI])\./;
+const O_LIST_TRAILING_NEWLINE = /\n$/;
+const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
+export const OrderedListRule: BlockMDRule = {
+ match: (text) => text.match(ORDERED_LIST_REG_1),
+ html: (match, parseInline) => {
+ const [listText] = match;
+ const [, listStart] = listText.match(O_LIST_START) ?? [];
+ const [, listType] = listText.match(O_LIST_TYPE) ?? [];
+
+ const lines = listText
+ .replace(O_LIST_TRAILING_NEWLINE, '')
+ .split('\n')
+ .map((lineText) => {
+ const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
+ const txt = parseInline ? parseInline(line) : line;
+ return `${txt}
`;
+ })
+ .join('');
+
+ const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
+ const startAtt = listStart ? ` start="${listStart}"` : '';
+ const typeAtt = listType ? ` type="${listType}"` : '';
+ return `${lines}
`;
+ },
+};
+
+const UNORDERED_LIST_MD_1 = '*';
+const U_LIST_ITEM_PREFIX = /^\* */;
+const U_LIST_TRAILING_NEWLINE = /\n$/;
+const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
+export const UnorderedListRule: BlockMDRule = {
+ match: (text) => text.match(UNORDERED_LIST_REG_1),
+ html: (match, parseInline) => {
+ const [listText] = match;
+
+ const lines = listText
+ .replace(U_LIST_TRAILING_NEWLINE, '')
+ .split('\n')
+ .map((lineText) => {
+ const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
+ const txt = parseInline ? parseInline(line) : line;
+ return `${txt}
`;
+ })
+ .join('');
+
+ return ``;
+ },
+};
+
+export const UN_ESC_BLOCK_SEQ = /^\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +)/;
+export const ESC_BLOCK_SEQ = /^\\(\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +))/;
diff --git a/src/app/plugins/markdown/block/runner.ts b/src/app/plugins/markdown/block/runner.ts
new file mode 100644
index 00000000..1dc8d8b8
--- /dev/null
+++ b/src/app/plugins/markdown/block/runner.ts
@@ -0,0 +1,25 @@
+import { replaceMatch } from '../internal';
+import { BlockMDParser, BlockMDRule } from './type';
+
+/**
+ * Parses block-level markdown text into HTML using defined block rules.
+ *
+ * @param text - The text to parse.
+ * @param rule - The markdown rule to run.
+ * @param parse - A function that run the parser on remaining parts..
+ * @param parseInline - Optional function to parse inline elements.
+ * @returns The text with the markdown rule applied or `undefined` if no match is found.
+ */
+export const runBlockRule = (
+ text: string,
+ rule: BlockMDRule,
+ parse: BlockMDParser,
+ parseInline?: (txt: string) => string
+): string | undefined => {
+ const matchResult = rule.match(text);
+ if (matchResult) {
+ const content = rule.html(matchResult, parseInline);
+ return replaceMatch(text, matchResult, content, (txt) => [parse(txt, parseInline)]).join('');
+ }
+ return undefined;
+};
diff --git a/src/app/plugins/markdown/block/type.ts b/src/app/plugins/markdown/block/type.ts
new file mode 100644
index 00000000..0949eb70
--- /dev/null
+++ b/src/app/plugins/markdown/block/type.ts
@@ -0,0 +1,30 @@
+import { MatchResult, MatchRule } from '../internal';
+
+/**
+ * Type for a function that parses block-level markdown into HTML.
+ *
+ * @param text - The markdown text to be parsed.
+ * @param parseInline - Optional function to parse inline elements.
+ * @returns The parsed HTML.
+ */
+export type BlockMDParser = (text: string, parseInline?: (txt: string) => string) => string;
+
+/**
+ * Type for a function that converts a block match to output.
+ *
+ * @param match - The match result.
+ * @param parseInline - Optional function to parse inline elements.
+ * @returns The output string after processing the match.
+ */
+export type BlockMatchConverter = (
+ match: MatchResult,
+ parseInline?: (txt: string) => string
+) => string;
+
+/**
+ * Type representing a block-level markdown rule that includes a matching pattern and HTML conversion.
+ */
+export type BlockMDRule = {
+ match: MatchRule; // A function that matches a specific markdown pattern.
+ html: BlockMatchConverter; // A function that converts the match to HTML.
+};
diff --git a/src/app/plugins/markdown/index.ts b/src/app/plugins/markdown/index.ts
new file mode 100644
index 00000000..4c4e4491
--- /dev/null
+++ b/src/app/plugins/markdown/index.ts
@@ -0,0 +1,3 @@
+export * from './utils';
+export * from './block';
+export * from './inline';
diff --git a/src/app/plugins/markdown/inline/index.ts b/src/app/plugins/markdown/inline/index.ts
new file mode 100644
index 00000000..75aa8b93
--- /dev/null
+++ b/src/app/plugins/markdown/inline/index.ts
@@ -0,0 +1 @@
+export * from './parser';
diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts
new file mode 100644
index 00000000..37c71a66
--- /dev/null
+++ b/src/app/plugins/markdown/inline/parser.ts
@@ -0,0 +1,40 @@
+import {
+ BoldRule,
+ CodeRule,
+ EscapeRule,
+ ItalicRule1,
+ ItalicRule2,
+ LinkRule,
+ SpoilerRule,
+ StrikeRule,
+ UnderlineRule,
+} from './rules';
+import { runInlineRule, runInlineRules } from './runner';
+import { InlineMDParser } from './type';
+
+const LeveledRules = [
+ BoldRule,
+ ItalicRule1,
+ UnderlineRule,
+ ItalicRule2,
+ StrikeRule,
+ SpoilerRule,
+ LinkRule,
+ EscapeRule,
+];
+
+/**
+ * Parses inline markdown text into HTML using defined rules.
+ *
+ * @param text - The markdown text to be parsed.
+ * @returns The parsed HTML or the original text if no markdown was found.
+ */
+export const parseInlineMD: InlineMDParser = (text) => {
+ if (text === '') return text;
+ let result: string | undefined;
+ if (!result) result = runInlineRule(text, CodeRule, parseInlineMD);
+
+ if (!result) result = runInlineRules(text, LeveledRules, parseInlineMD);
+
+ return result ?? text;
+};
diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts
new file mode 100644
index 00000000..bc76f60a
--- /dev/null
+++ b/src/app/plugins/markdown/inline/rules.ts
@@ -0,0 +1,123 @@
+import { InlineMDRule } from './type';
+
+const MIN_ANY = '(.+?)';
+const URL_NEG_LB = '(? text.match(BOLD_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const ITALIC_MD_1 = '*';
+const ITALIC_PREFIX_1 = `${ESC_NEG_LB}\\*`;
+const ITALIC_NEG_LA_1 = '(?!\\*)';
+const ITALIC_REG_1 = new RegExp(
+ `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`
+);
+export const ItalicRule1: InlineMDRule = {
+ match: (text) => text.match(ITALIC_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const ITALIC_MD_2 = '_';
+const ITALIC_PREFIX_2 = `${ESC_NEG_LB}_`;
+const ITALIC_NEG_LA_2 = '(?!_)';
+const ITALIC_REG_2 = new RegExp(
+ `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`
+);
+export const ItalicRule2: InlineMDRule = {
+ match: (text) => text.match(ITALIC_REG_2),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const UNDERLINE_MD_1 = '__';
+const UNDERLINE_PREFIX_1 = `${ESC_NEG_LB}_{2}`;
+const UNDERLINE_NEG_LA_1 = '(?!_)';
+const UNDERLINE_REG_1 = new RegExp(
+ `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
+);
+export const UnderlineRule: InlineMDRule = {
+ match: (text) => text.match(UNDERLINE_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const STRIKE_MD_1 = '~~';
+const STRIKE_PREFIX_1 = `${ESC_NEG_LB}~{2}`;
+const STRIKE_NEG_LA_1 = '(?!~)';
+const STRIKE_REG_1 = new RegExp(
+ `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`
+);
+export const StrikeRule: InlineMDRule = {
+ match: (text) => text.match(STRIKE_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const CODE_MD_1 = '`';
+const CODE_PREFIX_1 = `${ESC_NEG_LB}\``;
+const CODE_NEG_LA_1 = '(?!`)';
+const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
+export const CodeRule: InlineMDRule = {
+ match: (text) => text.match(CODE_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${g2}`;
+ },
+};
+
+const SPOILER_MD_1 = '||';
+const SPOILER_PREFIX_1 = `${ESC_NEG_LB}\\|{2}`;
+const SPOILER_NEG_LA_1 = '(?!\\|)';
+const SPOILER_REG_1 = new RegExp(
+ `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
+);
+export const SpoilerRule: InlineMDRule = {
+ match: (text) => text.match(SPOILER_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return `${parse(g2)}`;
+ },
+};
+
+const LINK_ALT = `\\[${MIN_ANY}\\]`;
+const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
+const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
+export const LinkRule: InlineMDRule = {
+ match: (text) => text.match(LINK_REG_1),
+ html: (parse, match) => {
+ const [, g1, g2] = match;
+ return `${parse(g1)}`;
+ },
+};
+
+export const INLINE_SEQUENCE_SET = '[*_~`|]';
+const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`;
+const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`);
+export const EscapeRule: InlineMDRule = {
+ match: (text) => text.match(ESC_REG_1),
+ html: (parse, match) => {
+ const [, , g2] = match;
+ return g2;
+ },
+};
diff --git a/src/app/plugins/markdown/inline/runner.ts b/src/app/plugins/markdown/inline/runner.ts
new file mode 100644
index 00000000..3a794d25
--- /dev/null
+++ b/src/app/plugins/markdown/inline/runner.ts
@@ -0,0 +1,62 @@
+import { MatchResult, replaceMatch } from '../internal';
+import { InlineMDParser, InlineMDRule } from './type';
+
+/**
+ * Runs a single markdown rule on the provided text.
+ *
+ * @param text - The text to parse.
+ * @param rule - The markdown rule to run.
+ * @param parse - A function that run the parser on remaining parts.
+ * @returns The text with the markdown rule applied or `undefined` if no match is found.
+ */
+export const runInlineRule = (
+ text: string,
+ rule: InlineMDRule,
+ parse: InlineMDParser
+): string | undefined => {
+ const matchResult = rule.match(text);
+ if (matchResult) {
+ const content = rule.html(parse, matchResult);
+ return replaceMatch(text, matchResult, content, (txt) => [parse(txt)]).join('');
+ }
+ return undefined;
+};
+
+/**
+ * Runs multiple rules at the same time to better handle nested rules.
+ * Rules will be run in the order they appear.
+ *
+ * @param text - The text to parse.
+ * @param rules - The markdown rules to run.
+ * @param parse - A function that run the parser on remaining parts.
+ * @returns The text with the markdown rules applied or `undefined` if no match is found.
+ */
+export const runInlineRules = (
+ text: string,
+ rules: InlineMDRule[],
+ parse: InlineMDParser
+): string | undefined => {
+ const matchResults = rules.map((rule) => rule.match(text));
+
+ let targetRule: InlineMDRule | undefined;
+ let targetResult: MatchResult | undefined;
+
+ for (let i = 0; i < matchResults.length; i += 1) {
+ const currentResult = matchResults[i];
+ if (currentResult && typeof currentResult.index === 'number') {
+ if (
+ !targetResult ||
+ (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
+ ) {
+ targetResult = currentResult;
+ targetRule = rules[i];
+ }
+ }
+ }
+
+ if (targetRule && targetResult) {
+ const content = targetRule.html(parse, targetResult);
+ return replaceMatch(text, targetResult, content, (txt) => [parse(txt)]).join('');
+ }
+ return undefined;
+};
diff --git a/src/app/plugins/markdown/inline/type.ts b/src/app/plugins/markdown/inline/type.ts
new file mode 100644
index 00000000..a65ad276
--- /dev/null
+++ b/src/app/plugins/markdown/inline/type.ts
@@ -0,0 +1,26 @@
+import { MatchResult, MatchRule } from '../internal';
+
+/**
+ * Type for a function that parses inline markdown into HTML.
+ *
+ * @param text - The markdown text to be parsed.
+ * @returns The parsed HTML.
+ */
+export type InlineMDParser = (text: string) => string;
+
+/**
+ * Type for a function that converts a match to output.
+ *
+ * @param parse - The inline markdown parser function.
+ * @param match - The match result.
+ * @returns The output string after processing the match.
+ */
+export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string;
+
+/**
+ * Type representing a markdown rule that includes a matching pattern and HTML conversion.
+ */
+export type InlineMDRule = {
+ match: MatchRule; // A function that matches a specific markdown pattern.
+ html: InlineMatchConverter; // A function that converts the match to HTML.
+};
diff --git a/src/app/plugins/markdown/internal/index.ts b/src/app/plugins/markdown/internal/index.ts
new file mode 100644
index 00000000..04bca77e
--- /dev/null
+++ b/src/app/plugins/markdown/internal/index.ts
@@ -0,0 +1 @@
+export * from './utils';
diff --git a/src/app/plugins/markdown/internal/utils.ts b/src/app/plugins/markdown/internal/utils.ts
new file mode 100644
index 00000000..86bc9d5c
--- /dev/null
+++ b/src/app/plugins/markdown/internal/utils.ts
@@ -0,0 +1,61 @@
+/**
+ * @typedef {RegExpMatchArray | RegExpExecArray} MatchResult
+ *
+ * Represents the result of a regular expression match.
+ * This type can be either a `RegExpMatchArray` or a `RegExpExecArray`,
+ * which are returned when performing a match with a regular expression.
+ *
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec}
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match}
+ */
+export type MatchResult = RegExpMatchArray | RegExpExecArray;
+
+/**
+ * @typedef {function(string): MatchResult | null} MatchRule
+ *
+ * A function type that takes a string and returns a `MatchResult` or `null` if no match is found.
+ *
+ * @param {string} text The string to match against.
+ * @returns {MatchResult | null} The result of the regular expression match, or `null` if no match is found.
+ */
+export type MatchRule = (text: string) => MatchResult | null;
+
+/**
+ * Returns the part of the text before a match.
+ *
+ * @param text - The input text string.
+ * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`).
+ * @returns A string containing the part of the text before the match.
+ */
+export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
+ text.slice(0, match.index);
+
+/**
+ * Returns the part of the text after a match.
+ *
+ * @param text - The input text string.
+ * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`).
+ * @returns A string containing the part of the text after the match.
+ */
+export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
+ text.slice((match.index ?? 0) + match[0].length);
+
+/**
+ * Replaces a match in the text with a content.
+ *
+ * @param text - The input text string.
+ * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`).
+ * @param content - The content to replace the match with.
+ * @param processPart - A function to further process remaining parts of the text.
+ * @returns An array containing the processed parts of the text, including the content.
+ */
+export const replaceMatch = (
+ text: string,
+ match: MatchResult,
+ content: C,
+ processPart: (txt: string) => Array
+): Array => [
+ ...processPart(beforeMatch(text, match)),
+ content,
+ ...processPart(afterMatch(text, match)),
+];
diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts
new file mode 100644
index 00000000..5ebd958c
--- /dev/null
+++ b/src/app/plugins/markdown/utils.ts
@@ -0,0 +1,83 @@
+import { findAndReplace } from '../../utils/findAndReplace';
+import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules';
+import { EscapeRule, INLINE_SEQUENCE_SET } from './inline/rules';
+import { runInlineRule } from './inline/runner';
+import { replaceMatch } from './internal';
+
+/**
+ * Removes escape sequences from markdown inline elements in the given plain-text.
+ * This function unescapes characters that are escaped with backslashes (e.g., `\*`, `\_`)
+ * in markdown syntax, returning the original plain-text with markdown characters in effect.
+ *
+ * @param text - The input markdown plain-text containing escape characters (e.g., `"some \*italic\*"`)
+ * @returns The plain-text with markdown escape sequences removed (e.g., `"some *italic*"`)
+ */
+export const unescapeMarkdownInlineSequences = (text: string): string =>
+ runInlineRule(text, EscapeRule, (t) => {
+ if (t === '') return t;
+ return unescapeMarkdownInlineSequences(t);
+ }) ?? text;
+
+/**
+ * Recovers the markdown escape sequences in the given plain-text.
+ * This function adds backslashes (`\`) before markdown characters that may need escaping
+ * (e.g., `*`, `_`) to ensure they are treated as literal characters and not part of markdown formatting.
+ *
+ * @param text - The input plain-text that may contain markdown sequences (e.g., `"some *italic*"`)
+ * @returns The plain-text with markdown escape sequences added (e.g., `"some \*italic\*"`)
+ */
+export const escapeMarkdownInlineSequences = (text: string): string => {
+ const regex = new RegExp(`(${INLINE_SEQUENCE_SET})`, 'g');
+ const parts = findAndReplace(
+ text,
+ regex,
+ (match) => {
+ const [, g1] = match;
+ return `\\${g1}`;
+ },
+ (t) => t
+ );
+
+ return parts.join('');
+};
+
+/**
+ * Removes escape sequences from markdown block elements in the given plain-text.
+ * This function unescapes characters that are escaped with backslashes (e.g., `\>`, `\#`)
+ * in markdown syntax, returning the original plain-text with markdown characters in effect.
+ *
+ * @param {string} text - The input markdown plain-text containing escape characters (e.g., `\> block quote`).
+ * @param {function} processPart - It takes the plain-text as input and returns a modified version of it.
+ * @returns {string} The plain-text with markdown escape sequences removed and markdown formatting applied.
+ */
+export const unescapeMarkdownBlockSequences = (
+ text: string,
+ processPart: (text: string) => string
+): string => {
+ const match = text.match(ESC_BLOCK_SEQ);
+
+ if (!match) return processPart(text);
+
+ const [, g1] = match;
+ return replaceMatch(text, match, g1, (t) => [processPart(t)]).join('');
+};
+
+/**
+ * Escapes markdown block elements by adding backslashes before markdown characters
+ * (e.g., `\>`, `\#`) that are normally interpreted as markdown syntax.
+ *
+ * @param {string} text - The input markdown plain-text that may contain markdown elements (e.g., `> block quote`).
+ * @param {function} processPart - It takes the plain-text as input and returns a modified version of it.
+ * @returns {string} The plain-text with markdown escape sequences added, preventing markdown formatting.
+ */
+export const escapeMarkdownBlockSequences = (
+ text: string,
+ processPart: (text: string) => string
+): string => {
+ const match = text.match(UN_ESC_BLOCK_SEQ);
+
+ if (!match) return processPart(text);
+
+ const [, g1] = match;
+ return replaceMatch(text, match, `\\${g1}`, (t) => [processPart(t)]).join('');
+};
From f121cc0a240eaf6a056bca0c429bf4574f82fc38 Mon Sep 17 00:00:00 2001
From: Lain Iwakura
Date: Fri, 21 Feb 2025 05:22:48 -0300
Subject: [PATCH 07/30] fix space/tab inconsistency (#2180)
---
contrib/nginx/cinny.domain.tld.conf | 46 ++++++++++++++---------------
1 file changed, 23 insertions(+), 23 deletions(-)
diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf
index 0ba70f7e..0b6c8aad 100644
--- a/contrib/nginx/cinny.domain.tld.conf
+++ b/contrib/nginx/cinny.domain.tld.conf
@@ -1,35 +1,35 @@
server {
- listen 80;
- listen [::]:80;
- server_name cinny.domain.tld;
+ listen 80;
+ listen [::]:80;
+ server_name cinny.domain.tld;
- location / {
- return 301 https://$host$request_uri;
- }
+ location / {
+ return 301 https://$host$request_uri;
+ }
- location /.well-known/acme-challenge/ {
- alias /var/lib/letsencrypt/.well-known/acme-challenge/;
- }
+ location /.well-known/acme-challenge/ {
+ alias /var/lib/letsencrypt/.well-known/acme-challenge/;
+ }
}
server {
- listen 443 ssl http2;
- listen [::]:443 ssl;
- server_name cinny.domain.tld;
+ listen 443 ssl http2;
+ listen [::]:443 ssl;
+ server_name cinny.domain.tld;
- location / {
- root /opt/cinny/dist/;
+ location / {
+ root /opt/cinny/dist/;
- rewrite ^/config.json$ /config.json break;
- rewrite ^/manifest.json$ /manifest.json break;
+ rewrite ^/config.json$ /config.json break;
+ rewrite ^/manifest.json$ /manifest.json break;
- rewrite ^.*/olm.wasm$ /olm.wasm break;
- rewrite ^/sw.js$ /sw.js break;
- rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
+ rewrite ^.*/olm.wasm$ /olm.wasm break;
+ rewrite ^/sw.js$ /sw.js break;
+ rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
- rewrite ^/public/(.*)$ /public/$1 break;
- rewrite ^/assets/(.*)$ /assets/$1 break;
+ rewrite ^/public/(.*)$ /public/$1 break;
+ rewrite ^/assets/(.*)$ /assets/$1 break;
- rewrite ^(.+)$ /index.html break;
- }
+ rewrite ^(.+)$ /index.html break;
+ }
}
From 7c6ab366aff46fa4c10fc8096541bc0085d9f183 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Sat, 22 Feb 2025 19:24:33 +1100
Subject: [PATCH 08/30] Fix unknown rooms in space lobby (#2224)
* add hook to fetch one level of space hierarchy
* add enable param to level hierarchy hook
* improve HierarchyItem types
* fix type errors in lobby
* load space hierarachy per level
* fix menu item visibility
* fix unknown spaces over federation
* show inaccessible rooms only to admins
* fix unknown room renders loading content twice
* fix unknown room visible to normal user if space all room are unknown
* show no rooms card if space does not have any room
---
src/app/features/lobby/HierarchyItemMenu.tsx | 4 +-
src/app/features/lobby/Lobby.tsx | 238 +++++++------------
src/app/features/lobby/RoomItem.tsx | 146 +++++-------
src/app/features/lobby/SpaceHierarchy.tsx | 225 ++++++++++++++++++
src/app/features/lobby/SpaceItem.tsx | 90 +++----
src/app/hooks/useSpaceHierarchy.ts | 162 ++++++++++---
src/app/state/spaceRooms.ts | 29 ++-
7 files changed, 564 insertions(+), 330 deletions(-)
create mode 100644 src/app/features/lobby/SpaceHierarchy.tsx
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index 30a4f632..d1a7ec6b 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -155,7 +155,7 @@ function SettingsMenuItem({
disabled?: boolean;
}) {
const handleSettings = () => {
- if (item.space) {
+ if ('space' in item) {
openSpaceSettings(item.roomId);
} else {
toggleRoomSettings(item.roomId);
@@ -271,7 +271,7 @@ export function HierarchyItemMenu({