initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const TABLE_PARSING_ENABLED = false;
export const TABLE_PARSING_FLAG = 0;

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import markupStyles from '~/styles/Markup.module.css';
import {Parser} from './parser/parser/parser';
import {
getParserFlagsForContext,
MarkdownContext,
type MarkdownParseOptions,
render,
wrapRenderedContent,
} from './renderers';
const MarkdownErrorBoundary = class MarkdownErrorBoundary extends React.Component<
{children: React.ReactNode},
{hasError: boolean; error: Error | null}
> {
constructor(props: {children: React.ReactNode}) {
super(props);
this.state = {hasError: false, error: null};
}
static getDerivedStateFromError(error: Error) {
return {hasError: true, error};
}
override componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('Error rendering markdown:', error, info);
}
override render() {
if (this.state.hasError) {
return (
<span className={markupStyles.error}>
<Trans>Error rendering content</Trans>
</span>
);
}
return this.props.children;
}
};
function parseMarkdown(
content: string,
options: MarkdownParseOptions = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
): React.ReactNode {
try {
const flags = getParserFlagsForContext(options.context);
const parser = new Parser(content, flags);
const {nodes} = parser.parse();
const renderedContent = render(nodes, options);
return wrapRenderedContent(renderedContent, options.context);
} catch (error) {
console.error(`Error parsing markdown (${options.context}):`, error);
return <span>{content}</span>;
}
}
export const SafeMarkdown = observer(function SafeMarkdown({
content,
options = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
}: {
content: string;
options?: MarkdownParseOptions;
}): React.ReactElement {
return <MarkdownErrorBoundary>{parseMarkdown(content, options)}</MarkdownErrorBoundary>;
});

View File

@@ -0,0 +1,124 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {beforeEach, describe, expect, test} from 'vitest';
import {FormattingContext} from './formatting-context';
describe('FormattingContext', () => {
let context: FormattingContext;
beforeEach(() => {
context = new FormattingContext();
});
describe('pushFormatting and popFormatting', () => {
test('should push and pop formatting markers correctly', () => {
context.pushFormatting('*', true);
context.pushFormatting('_', false);
expect(context.popFormatting()).toEqual(['_', false]);
expect(context.popFormatting()).toEqual(['*', true]);
});
test('should handle empty stack when popping', () => {
expect(context.popFormatting()).toBeUndefined();
});
test('should update active formatting types when pushing', () => {
context.pushFormatting('*', true);
expect(context.isFormattingActive('*', true)).toBe(true);
expect(context.isFormattingActive('*', false)).toBe(false);
});
test('should remove active formatting types when popping', () => {
context.pushFormatting('*', true);
expect(context.isFormattingActive('*', true)).toBe(true);
context.popFormatting();
expect(context.isFormattingActive('*', true)).toBe(false);
});
test('should handle multiple formatting markers of same type', () => {
context.pushFormatting('*', true);
context.pushFormatting('*', false);
expect(context.isFormattingActive('*', true)).toBe(true);
expect(context.isFormattingActive('*', false)).toBe(true);
context.popFormatting();
expect(context.isFormattingActive('*', false)).toBe(false);
expect(context.isFormattingActive('*', true)).toBe(true);
context.popFormatting();
expect(context.isFormattingActive('*', true)).toBe(false);
});
});
describe('isFormattingActive', () => {
test('should detect active formatting correctly', () => {
context.pushFormatting('*', true);
expect(context.isFormattingActive('*', true)).toBe(true);
expect(context.isFormattingActive('*', false)).toBe(false);
expect(context.isFormattingActive('_', true)).toBe(false);
});
test('should handle different marker and emphasis combinations', () => {
context.pushFormatting('_', true);
context.pushFormatting('*', false);
context.pushFormatting('~', true);
expect(context.isFormattingActive('_', true)).toBe(true);
expect(context.isFormattingActive('*', false)).toBe(true);
expect(context.isFormattingActive('~', true)).toBe(true);
expect(context.isFormattingActive('_', false)).toBe(false);
});
});
describe('canEnterFormatting', () => {
test('should allow formatting when not active', () => {
expect(context.canEnterFormatting('*', true)).toBe(true);
expect(context.canEnterFormatting('_', false)).toBe(true);
});
test('should prevent entering active formatting', () => {
context.pushFormatting('*', true);
expect(context.canEnterFormatting('*', true)).toBe(false);
expect(context.canEnterFormatting('*', false)).toBe(true);
});
test('should handle underscore special cases', () => {
context.setCurrentText('test_with_underscores');
context.pushFormatting('_', false);
expect(context.canEnterFormatting('_', true)).toBe(true);
});
});
describe('setCurrentText', () => {
test('should detect underscores in text', () => {
context.setCurrentText('test_with_underscores');
context.pushFormatting('_', false);
expect(context.canEnterFormatting('_', true)).toBe(true);
});
test('should handle text without underscores', () => {
context.setCurrentText('test without underscores');
expect(context.canEnterFormatting('_', true)).toBe(true);
});
});
});

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MAX_INLINE_DEPTH} from '../types/constants';
export class FormattingContext {
private readonly activeFormattingTypes = new Map<string, boolean>();
private readonly formattingStack: Array<[string, boolean]> = [];
private currentDepth = 0;
canEnterFormatting(delimiter: string, isDouble: boolean): boolean {
const key = this.getFormattingKey(delimiter, isDouble);
if (this.activeFormattingTypes.has(key)) return false;
return this.currentDepth < MAX_INLINE_DEPTH;
}
isFormattingActive(delimiter: string, isDouble: boolean): boolean {
return this.activeFormattingTypes.has(this.getFormattingKey(delimiter, isDouble));
}
pushFormatting(delimiter: string, isDouble: boolean): void {
this.formattingStack.push([delimiter, isDouble]);
this.activeFormattingTypes.set(this.getFormattingKey(delimiter, isDouble), true);
this.currentDepth++;
}
popFormatting(): [string, boolean] | undefined {
const removed = this.formattingStack.pop();
if (removed) {
this.activeFormattingTypes.delete(this.getFormattingKey(removed[0], removed[1]));
this.currentDepth--;
}
return removed;
}
setCurrentText(_text: string): void {}
private getFormattingKey(delimiter: string, isDouble: boolean): string {
return `${delimiter}${isDouble ? '2' : '1'}`;
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
import {Parser} from './parser';
describe('Fluxer Markdown Parser', () => {
test('empty input', () => {
const input = '';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(0);
});
test('multiple consecutive newlines', () => {
const input = 'First paragraph.\n\n\n\nSecond paragraph.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'First paragraph.\n'},
{type: NodeType.Text, content: '\n\n\n'},
{type: NodeType.Text, content: 'Second paragraph.'},
]);
});
test('multiple newlines between blocks', () => {
const input = '# Heading\n\n\n\nParagraph.';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading'}],
},
{type: NodeType.Text, content: 'Paragraph.'},
]);
});
test('preserve consecutive newlines between paragraphs', () => {
const input = 'First paragraph\n\n\n\nSecond paragraph';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'First paragraph\n'},
{type: NodeType.Text, content: '\n\n\n'},
{type: NodeType.Text, content: 'Second paragraph'},
]);
});
test('flags disabling spoilers', () => {
const input = 'This is a ||secret|| message';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'This is a ||secret|| message'}]);
});
test('flags disabling headings', () => {
const input = '# Heading 1';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '# Heading 1'}]);
});
test('flags disabling code blocks', () => {
const input = '```rust\nfn main() {}\n```';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '```rust\nfn main() {}\n```'}]);
});
test('flags disabling custom links', () => {
const input = '[Rust](https://www.rust-lang.org)';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '[Rust](https://www.rust-lang.org)'}]);
});
test('flags disabling command mentions', () => {
const input = 'Use </airhorn:816437322781949972>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Use </airhorn:816437322781949972>'}]);
});
test('flags disabling guild navigations', () => {
const input = 'Go to <id:customize> now!';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Go to <id:customize> now!'}]);
});
test('flags partial', () => {
const input = '# Heading\nThis is a ||secret|| message with a [link](https://example.com).';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading'}],
},
{type: NodeType.Text, content: 'This is a '},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
{type: NodeType.Text, content: ' message with a [link](https://example.com).'},
]);
});
test('flags all enabled', () => {
const input = '# Heading\n||Spoiler|| with a [link](https://example.com) and a </command:12345>.';
const flags =
ParserFlags.ALLOW_HEADINGS |
ParserFlags.ALLOW_SPOILERS |
ParserFlags.ALLOW_MASKED_LINKS |
ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading'}],
},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'Spoiler'}]},
{type: NodeType.Text, content: ' with a '},
{
type: NodeType.Link,
text: {type: NodeType.Text, content: 'link'},
url: 'https://example.com/',
escaped: false,
},
{type: NodeType.Text, content: ' and a '},
{
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: 'command',
subcommandGroup: undefined,
subcommand: undefined,
id: '12345',
},
},
{type: NodeType.Text, content: '.'},
]);
});
});

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as BlockParsers from '../parsers/block-parsers';
import {applyTextPresentation} from '../parsers/emoji-parsers';
import * as InlineParsers from '../parsers/inline-parsers';
import * as ListParsers from '../parsers/list-parsers';
import {MAX_AST_NODES, MAX_LINE_LENGTH, MAX_LINES} from '../types/constants';
import {NodeType, ParserFlags} from '../types/enums';
import type {Node} from '../types/nodes';
import * as ASTUtils from '../utils/ast-utils';
export class Parser {
private readonly lines: Array<string>;
private currentLineIndex: number;
private readonly totalLineCount: number;
private readonly parserFlags: number;
private nodeCount: number;
constructor(input: string, flags: number) {
if (!input || input === '') {
this.lines = [];
this.currentLineIndex = 0;
this.totalLineCount = 0;
this.parserFlags = flags;
this.nodeCount = 0;
return;
}
const lines = input.split('\n');
if (lines.length > MAX_LINES) {
lines.length = MAX_LINES;
}
if (lines.length === 1 && lines[0] === '') {
this.lines = [];
} else {
this.lines = lines;
}
this.currentLineIndex = 0;
this.totalLineCount = this.lines.length;
this.parserFlags = flags;
this.nodeCount = 0;
}
parse(): {nodes: Array<Node>} {
const ast: Array<Node> = [];
if (this.totalLineCount === 0) {
return {nodes: ast};
}
while (this.currentLineIndex < this.totalLineCount && this.nodeCount <= MAX_AST_NODES) {
const line = this.lines[this.currentLineIndex];
let lineLength = line.length;
if (lineLength > MAX_LINE_LENGTH) {
this.lines[this.currentLineIndex] = line.slice(0, MAX_LINE_LENGTH);
lineLength = MAX_LINE_LENGTH;
}
const trimmedLine = line.trimStart();
if (trimmedLine === '') {
const blankLineCount = this.countBlankLines(this.currentLineIndex);
if (ast.length > 0 && this.currentLineIndex + blankLineCount < this.totalLineCount) {
const nextLine = this.lines[this.currentLineIndex + blankLineCount];
const nextTrimmed = nextLine.trimStart();
const isNextHeading = nextTrimmed
? BlockParsers.parseHeading(nextTrimmed, (text) => InlineParsers.parseInline(text, this.parserFlags)) !==
null
: false;
const isPreviousHeading = ast[ast.length - 1]?.type === NodeType.Heading;
if (!isNextHeading && !isPreviousHeading) {
const newlines = '\n'.repeat(blankLineCount);
ast.push({type: NodeType.Text, content: newlines});
this.nodeCount++;
}
}
this.currentLineIndex += blankLineCount;
continue;
}
const blockResult = BlockParsers.parseBlock(
this,
this.lines,
this.currentLineIndex,
this.parserFlags,
this.nodeCount,
);
if (blockResult.node) {
ast.push(blockResult.node);
if (blockResult.extraNodes) {
for (const extraNode of blockResult.extraNodes) {
ast.push(extraNode);
}
}
this.currentLineIndex = blockResult.newLineIndex;
this.nodeCount = blockResult.newNodeCount;
continue;
}
this.parseInlineLine(ast);
this.currentLineIndex++;
}
ASTUtils.flattenAST(ast);
for (const node of ast) {
applyTextPresentation(node);
}
return {nodes: ast};
}
private countBlankLines(startLine: number): number {
let count = 0;
let current = startLine;
while (current < this.totalLineCount && this.lines[current].trim() === '') {
count++;
current++;
}
return count;
}
private parseInlineLine(ast: Array<Node>): void {
let text = this.lines[this.currentLineIndex];
let linesConsumed = 1;
while (this.currentLineIndex + linesConsumed < this.totalLineCount) {
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
const trimmedNext = nextLine.trimStart();
if (this.isBlockStart(trimmedNext)) {
break;
}
if (trimmedNext === '') {
break;
}
text += `\n${nextLine}`;
linesConsumed++;
}
if (this.currentLineIndex + linesConsumed < this.totalLineCount) {
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
const trimmedNext = nextLine.trimStart();
const isNextLineHeading = trimmedNext.startsWith('#') && !trimmedNext.startsWith('-#');
const isNextLineBlockquote = trimmedNext.startsWith('>');
if (trimmedNext === '' || (!isNextLineHeading && !isNextLineBlockquote)) {
text += '\n';
}
}
const inlineNodes = InlineParsers.parseInline(text, this.parserFlags);
for (const node of inlineNodes) {
ast.push(node);
this.nodeCount++;
if (this.nodeCount > MAX_AST_NODES) break;
}
this.currentLineIndex += linesConsumed - 1;
}
private isBlockStart(line: string): boolean {
return !!(
line.startsWith('#') ||
(this.parserFlags & ParserFlags.ALLOW_SUBTEXT && line.startsWith('-#')) ||
(this.parserFlags & ParserFlags.ALLOW_CODE_BLOCKS && line.startsWith('```')) ||
(this.parserFlags & ParserFlags.ALLOW_LISTS && ListParsers.matchListItem(line) != null) ||
(this.parserFlags & (ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) &&
(line.startsWith('>') || line.startsWith('>>> ')))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,806 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Parser} from '../parser/parser';
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '../types/constants';
import {AlertType, NodeType, ParserFlags} from '../types/enums';
import type {AlertNode, CodeBlockNode, HeadingNode, Node, SpoilerNode, SubtextNode, TextNode} from '../types/nodes';
import {flattenChildren} from '../utils/ast-utils';
import * as InlineParsers from './inline-parsers';
import * as ListParsers from './list-parsers';
import * as TableParsers from './table-parsers';
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
interface BlockParseResult {
node: Node | null;
newLineIndex: number;
newNodeCount: number;
extraNodes?: Array<Node>;
}
const stringCache = new Map<string, boolean>();
function hasOpenInlineCode(text: string): boolean {
if (!text.includes('`')) return false;
let openLength: number | null = null;
let index = 0;
while (index < text.length) {
if (text[index] !== '`') {
index++;
continue;
}
let runLength = 0;
while (index + runLength < text.length && text[index + runLength] === '`') {
runLength++;
}
if (openLength === null) {
openLength = runLength;
} else if (runLength === openLength) {
openLength = null;
}
index += runLength;
}
return openLength !== null;
}
function cachedStartsWith(str: string, search: string): boolean {
const key = `${str}:${search}:startsWith`;
if (!stringCache.has(key)) {
stringCache.set(key, str.startsWith(search));
}
return stringCache.get(key)!;
}
export function parseBlock(
_parser: Parser,
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
): BlockParseResult {
if (currentLineIndex >= lines.length) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (cachedStartsWith(trimmed, '>>> ')) {
if (!(parserFlags & ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES)) {
const result = {
node: parseBlockAsText(lines, currentLineIndex, '>>> '),
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
const result = parseMultilineBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
return result;
}
if (cachedStartsWith(trimmed, '>')) {
if (!(parserFlags & ParserFlags.ALLOW_BLOCKQUOTES)) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const result = parseBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
return result;
}
const listMatch = ListParsers.matchListItem(line);
if (listMatch) {
const [isOrdered, indentLevel, _content] = listMatch;
if (parserFlags & ParserFlags.ALLOW_LISTS) {
const result = ListParsers.parseList(
lines,
currentLineIndex,
isOrdered,
indentLevel,
1,
parserFlags,
nodeCount,
(text) => InlineParsers.parseInline(text, parserFlags),
);
const finalResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: result.newNodeCount,
};
return finalResult;
}
const result = {
node: {type: NodeType.Text, content: line} as TextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (trimmed.startsWith('||') && !trimmed.slice(2).includes('||')) {
if (parserFlags & ParserFlags.ALLOW_SPOILERS) {
const result = parseSpoiler(lines, currentLineIndex, parserFlags);
const finalResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: nodeCount + 1,
};
return finalResult;
}
const result = {
node: {type: NodeType.Text, content: line} as TextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) {
const fencePosition = line.indexOf('```');
if (fencePosition !== -1) {
const startsWithFence = cachedStartsWith(trimmed, '```') && fencePosition === line.length - trimmed.length;
if (startsWithFence) {
const result = parseCodeBlock(lines, currentLineIndex);
const finalResult: BlockParseResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: nodeCount + 1,
};
if (result.extraContent) {
finalResult.extraNodes = [{type: NodeType.Text, content: result.extraContent}];
finalResult.newNodeCount = nodeCount + 2;
}
return finalResult;
}
const prefixText = line.slice(0, fencePosition);
if (hasOpenInlineCode(prefixText)) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const inlineNodes = InlineParsers.parseInline(prefixText, parserFlags);
const codeLines = [line.slice(fencePosition), ...lines.slice(currentLineIndex + 1)];
const codeResult = parseCodeBlock(codeLines, 0);
const newLineIndex = currentLineIndex + codeResult.newLineIndex;
const extraNodes: Array<Node> = [];
if (inlineNodes.length > 1) {
extraNodes.push(...inlineNodes.slice(1));
}
extraNodes.push(codeResult.node);
if (codeResult.extraContent) {
extraNodes.push({type: NodeType.Text, content: codeResult.extraContent});
}
const firstNode = inlineNodes[0] ?? codeResult.node;
const newNodeCount = nodeCount + inlineNodes.length + 1 + (codeResult.extraContent ? 1 : 0);
return {
node: firstNode,
extraNodes: extraNodes.length > 0 ? extraNodes : undefined,
newLineIndex,
newNodeCount,
};
}
}
if (!(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) && cachedStartsWith(trimmed, '```')) {
let codeBlockText = lines[currentLineIndex];
let endLineIndex = currentLineIndex + 1;
while (endLineIndex < lines.length) {
const nextLine = lines[endLineIndex];
if (nextLine.trim() === '```') {
codeBlockText += `\n${nextLine}`;
endLineIndex++;
break;
}
codeBlockText += `\n${nextLine}`;
endLineIndex++;
}
return {
node: {type: NodeType.Text, content: codeBlockText} as TextNode,
newLineIndex: endLineIndex,
newNodeCount: nodeCount + 1,
};
}
if (cachedStartsWith(trimmed, '-#')) {
if (parserFlags & ParserFlags.ALLOW_SUBTEXT) {
const subtextNode = parseSubtext(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
if (subtextNode) {
const result = {
node: subtextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
}
const result = {
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (cachedStartsWith(trimmed, '#')) {
if (parserFlags & ParserFlags.ALLOW_HEADINGS) {
const headingNode = parseHeading(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
if (headingNode) {
const result = {
node: headingNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
}
const result = {
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (trimmed.includes('|') && parserFlags & ParserFlags.ALLOW_TABLES) {
const startIndex = currentLineIndex;
const tableResult = TableParsers.parseTable(lines, currentLineIndex, parserFlags, (text) =>
InlineParsers.parseInline(text, parserFlags),
);
if (tableResult.node) {
const result = {
node: tableResult.node,
newLineIndex: tableResult.newLineIndex,
newNodeCount: nodeCount + 1,
};
return result;
}
currentLineIndex = startIndex;
}
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
function handleLineAsText(lines: Array<string>, currentLineIndex: number): string {
const isLastLine = currentLineIndex === lines.length - 1;
return isLastLine ? lines[currentLineIndex] : `${lines[currentLineIndex]}\n`;
}
function parseBlockAsText(lines: Array<string>, currentLineIndex: number, marker: string): TextNode {
const originalContent = lines[currentLineIndex];
if (marker === '>' || marker === '>>> ') {
return {
type: NodeType.Text,
content: originalContent + (currentLineIndex < lines.length - 1 ? '\n' : ''),
};
}
return {
type: NodeType.Text,
content: originalContent,
};
}
const MAX_HEADING_LEVEL = 4;
export function parseHeading(trimmed: string, parseInline: (text: string) => Array<Node>): HeadingNode | null {
let level = 0;
for (let i = 0; i < trimmed.length && i < MAX_HEADING_LEVEL; i++) {
if (trimmed[i] === '#') level++;
else break;
}
if (level >= 1 && level <= MAX_HEADING_LEVEL && trimmed[level] === ' ') {
const content = trimmed.slice(level + 1);
const inlineNodes = parseInline(content);
const result: HeadingNode = {
type: NodeType.Heading,
level,
children: inlineNodes,
};
return result;
}
return null;
}
function parseSubtext(trimmed: string, parseInline: (text: string) => Array<Node>): SubtextNode | null {
if (trimmed.startsWith('-#')) {
if ((trimmed.length > 2 && trimmed[2] !== ' ') || (trimmed.length > 3 && trimmed[3] === ' ')) {
return null;
}
const content = trimmed.slice(3);
const inlineNodes = parseInline(content);
const result: SubtextNode = {
type: NodeType.Subtext,
children: inlineNodes,
};
return result;
}
return null;
}
function parseBlockquote(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
): BlockParseResult {
let blockquoteContent = '';
const startLine = currentLineIndex;
let newLineIndex = currentLineIndex;
while (newLineIndex < lines.length) {
if (nodeCount > MAX_AST_NODES) break;
const line = lines[newLineIndex];
const trimmed = line.trimStart();
if (trimmed === '> ' || trimmed === '> ') {
if (blockquoteContent.length > 0) blockquoteContent += '\n';
newLineIndex++;
} else if (trimmed.startsWith('> ')) {
const content = trimmed.slice(2);
if (blockquoteContent.length > 0) blockquoteContent += '\n';
blockquoteContent += content;
newLineIndex++;
} else {
break;
}
if (blockquoteContent.length > MAX_LINE_LENGTH * 100) break;
}
if (blockquoteContent === '' && newLineIndex === startLine) {
return {node: null, newLineIndex, newNodeCount: nodeCount};
}
if (parserFlags & ParserFlags.ALLOW_ALERTS) {
const alertNode = parseAlert(blockquoteContent, parserFlags);
if (alertNode) {
return {
node: alertNode,
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
}
const childFlags = parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES;
const childParser = new Parser(blockquoteContent, childFlags);
const {nodes: childNodes} = childParser.parse();
flattenChildren(childNodes, true);
return {
node: {
type: NodeType.Blockquote,
children: childNodes,
},
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
function parseMultilineBlockquote(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
): BlockParseResult {
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (!trimmed.startsWith('>>> ')) {
return {
node: {type: NodeType.Text, content: ''},
newLineIndex: currentLineIndex,
newNodeCount: nodeCount,
};
}
let content = trimmed.slice(4);
let newLineIndex = currentLineIndex + 1;
while (newLineIndex < lines.length) {
const current = lines[newLineIndex];
content += `\n${current}`;
newLineIndex++;
if (content.length > MAX_LINE_LENGTH * 100) break;
}
const childFlags = (parserFlags & ~ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) | ParserFlags.ALLOW_BLOCKQUOTES;
const childParser = new Parser(content, childFlags);
const {nodes: childNodes} = childParser.parse();
return {
node: {
type: NodeType.Blockquote,
children: childNodes,
},
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
export function parseCodeBlock(
lines: Array<string>,
currentLineIndex: number,
): {node: CodeBlockNode; newLineIndex: number; extraContent?: string} {
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
const indentSpaces = line.length - trimmed.length;
const listIndent = indentSpaces > 0 ? ' '.repeat(indentSpaces) : '';
let fenceLength = 0;
for (let i = 0; i < trimmed.length && trimmed[i] === '`'; i++) {
fenceLength++;
}
const languagePart = trimmed.slice(fenceLength);
const closingFence = '`'.repeat(fenceLength);
const closingFenceIndex = languagePart.indexOf(closingFence);
let language: string | undefined;
if (closingFenceIndex !== -1) {
const inlineContent = languagePart.slice(0, closingFenceIndex);
const trailingInline = languagePart.slice(closingFenceIndex + fenceLength);
return {
node: {
type: NodeType.CodeBlock,
language: undefined,
content: inlineContent,
},
newLineIndex: currentLineIndex + 1,
extraContent: trailingInline || undefined,
};
}
language = languagePart.trim() || undefined;
let newLineIndex = currentLineIndex + 1;
let tempIndex = newLineIndex;
let lineCount = 0;
while (tempIndex < lines.length) {
const trimmedLine = lines[tempIndex].trimStart();
if (trimmedLine.startsWith(closingFence)) {
let backtickCount = 0;
for (let i = 0; i < trimmedLine.length && trimmedLine[i] === '`'; i++) {
backtickCount++;
}
const charAfterBackticks = trimmedLine[backtickCount];
if (
backtickCount >= fenceLength &&
(!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`')
) {
break;
}
}
tempIndex++;
lineCount++;
if (lineCount > 1000) break;
}
const contentParts: Array<string> = [];
let contentLength = 0;
while (newLineIndex < lines.length) {
const current = lines[newLineIndex];
const trimmedLine = current.trimStart();
const fenceIndex = trimmedLine.indexOf(closingFence);
if (fenceIndex !== -1) {
let backtickCount = 0;
let idx = fenceIndex;
while (idx < trimmedLine.length && trimmedLine[idx] === '`') {
backtickCount++;
idx++;
}
const charAfterBackticks = trimmedLine[idx];
const onlyWhitespaceAfter =
!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`';
if (backtickCount >= fenceLength && onlyWhitespaceAfter) {
const contentPrefix = current.slice(0, current.indexOf(closingFence));
let contentLine = contentPrefix;
if (indentSpaces > 0 && contentPrefix.startsWith(listIndent)) {
contentLine = contentPrefix.slice(indentSpaces);
}
if (contentLine.length > 0) {
contentParts.push(contentLine);
contentParts.push('\n');
}
let extraContent: string | undefined;
const trailingText = trimmedLine.slice(idx);
if (trailingText) {
extraContent = trailingText;
} else if (backtickCount > fenceLength) {
extraContent = trimmedLine.slice(fenceLength);
}
newLineIndex++;
if (extraContent) {
return {
node: {
type: NodeType.CodeBlock,
language,
content: contentParts.join(''),
},
newLineIndex,
extraContent,
};
}
break;
}
}
let contentLine = current;
if (indentSpaces > 0 && current.startsWith(listIndent)) {
contentLine = current.slice(indentSpaces);
}
contentParts.push(contentLine);
contentParts.push('\n');
contentLength += contentLine.length + 1;
if (contentLength > MAX_LINE_LENGTH * 100) break;
newLineIndex++;
}
return {
node: {
type: NodeType.CodeBlock,
language,
content: contentParts.join(''),
},
newLineIndex,
};
}
function parseSpoiler(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
): {node: SpoilerNode | TextNode; newLineIndex: number} {
const startLine = currentLineIndex;
let foundEnd = false;
let blockContent = '';
let newLineIndex = currentLineIndex;
while (newLineIndex < lines.length) {
const line = lines[newLineIndex];
if (newLineIndex === startLine) {
const startIdx = line.indexOf('||');
if (startIdx !== -1) {
blockContent += line.slice(startIdx + 2);
}
} else {
const endIdx = line.indexOf('||');
if (endIdx !== -1) {
blockContent += line.slice(0, endIdx);
foundEnd = true;
newLineIndex++;
break;
}
blockContent += line;
}
blockContent += '\n';
newLineIndex++;
if (blockContent.length > MAX_LINE_LENGTH * 10) break;
}
if (!foundEnd) {
return {
node: {
type: NodeType.Text,
content: `||${blockContent.trimEnd()}`,
},
newLineIndex,
};
}
const childParser = new Parser(blockContent.trim(), parserFlags);
const {nodes: innerNodes} = childParser.parse();
return {
node: {
type: NodeType.Spoiler,
children: innerNodes,
isBlock: true,
},
newLineIndex,
};
}
function parseAlert(blockquoteText: string, parserFlags: number): AlertNode | null {
const alertMatch = blockquoteText.match(ALERT_PATTERN);
if (!alertMatch) {
return null;
}
const alertTypeStr = alertMatch[1].toUpperCase();
let alertType: AlertType;
switch (alertTypeStr) {
case 'NOTE':
alertType = AlertType.Note;
break;
case 'TIP':
alertType = AlertType.Tip;
break;
case 'IMPORTANT':
alertType = AlertType.Important;
break;
case 'WARNING':
alertType = AlertType.Warning;
break;
case 'CAUTION':
alertType = AlertType.Caution;
break;
default:
return null;
}
const content = blockquoteText.slice(alertMatch[0].length);
const childFlags =
(parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES) | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
const lines = content.split('\n');
const processedLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith('-') || /^\d+\./.test(trimmed)) {
return line;
}
return trimmed;
});
const processedContent = processedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const childParser = new Parser(processedContent, childFlags);
const {nodes: childNodes} = childParser.parse();
const mergedNodes: Array<Node> = [];
let currentText = '';
for (const node of childNodes) {
if (node.type === NodeType.Text) {
if (currentText) {
currentText += node.content;
} else {
currentText = node.content;
}
} else {
if (currentText) {
mergedNodes.push({type: NodeType.Text, content: currentText});
currentText = '';
}
mergedNodes.push(node);
}
}
if (currentText) {
mergedNodes.push({type: NodeType.Text, content: currentText});
}
const finalNodes = postProcessAlertNodes(mergedNodes);
return {
type: NodeType.Alert,
alertType,
children: finalNodes,
};
}
function postProcessAlertNodes(nodes: Array<Node>): Array<Node> {
if (nodes.length <= 1) return nodes;
const result: Array<Node> = [];
let i = 0;
while (i < nodes.length) {
const node = nodes[i];
if (node.type === NodeType.Text && i + 1 < nodes.length) {
if (nodes[i + 1].type === NodeType.List) {
const trimmedContent = node.content.replace(/\s+$/, '\n');
if (trimmedContent) {
result.push({type: NodeType.Text, content: trimmedContent});
}
} else {
result.push(node);
}
} else if (node.type === NodeType.List && i + 1 < nodes.length) {
result.push(node);
const nextNode = nodes[i + 1];
if (nextNode.type === NodeType.Text) {
const content = nextNode.content.trim();
if (content) {
result.push({type: NodeType.Text, content: `\n${content}`});
i++;
}
}
} else {
result.push(node);
}
i++;
}
return result;
}

View File

@@ -0,0 +1,666 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {EmojiKind, NodeType, ParserFlags} from '../types/enums';
describe('Fluxer Markdown Parser', () => {
test('standard emoji', () => {
const input = 'Hello 🦶 World!';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '🦶',
codepoints: '1f9b6',
name: expect.any(String),
},
},
{type: NodeType.Text, content: ' World!'},
]);
});
test('custom emoji static', () => {
const input = 'Check this <:mmLol:216154654256398347> emoji!';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Check this '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name: 'mmLol',
id: '216154654256398347',
animated: false,
},
},
{type: NodeType.Text, content: ' emoji!'},
]);
});
test('custom emoji animated', () => {
const input = 'Animated: <a:b1nzy:392938283556143104>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Animated: '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name: 'b1nzy',
id: '392938283556143104',
animated: true,
},
},
]);
});
test('generate codepoints with vs16 and zwj', () => {
const input = '👨‍👩‍👧‍👦❤️😊';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '👨‍👩‍👧‍👦',
codepoints: '1f468-200d-1f469-200d-1f467-200d-1f466',
name: 'family_mwgb',
},
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '❤️',
codepoints: '2764',
name: 'heart',
},
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😊',
codepoints: '1f60a',
name: 'blush',
},
},
]);
});
test('multiple consecutive emojis', () => {
const input = '😀😃😄😁';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😀',
codepoints: '1f600',
name: expect.any(String),
},
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😃',
codepoints: '1f603',
name: expect.any(String),
},
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😄',
codepoints: '1f604',
name: expect.any(String),
},
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😁',
codepoints: '1f601',
name: expect.any(String),
},
},
]);
});
test('special plaintext symbols should be rendered as text', () => {
const input = '™ ™️ © ©️ ® ®️';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '™ ™ © © ® ®'}]);
});
test('copyright shortcode converts to text symbol', () => {
const input = ':copyright: normal text';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '© normal text'}]);
});
test('trademark shortcode converts to text symbol', () => {
const input = ':tm: normal text';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '™ normal text'}]);
});
test('registered shortcode converts to text symbol', () => {
const input = ':registered: normal text';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '® normal text'}]);
});
test('mixed shortcodes with regular emojis', () => {
const input = ':copyright: and :smile: with :registered:';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '© and '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😄',
codepoints: '1f604',
name: 'smile',
},
},
{type: NodeType.Text, content: ' with ®'},
]);
});
test('shortcodes in formatted text', () => {
const input = '**Bold :tm:** *Italic :copyright:* __:registered:__';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'Bold ™'}],
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'Italic ©'}],
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Underline,
children: [{type: NodeType.Text, content: '®'}],
},
]);
});
test('emoji data loaded', () => {
const input = ':smile: :wave: :heart:';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBeGreaterThan(0);
expect(ast.some((node) => node.type === NodeType.Emoji)).toBe(true);
});
test('emoji cache initialization', () => {
const input = ':smile: :face_holding_back_tears: :face-holding-back-tears:';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '😄',
codepoints: '1f604',
name: 'smile',
},
},
{
type: NodeType.Text,
content: ' ',
},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '🥹',
codepoints: '1f979',
name: 'face_holding_back_tears',
},
},
{
type: NodeType.Text,
content: ' :face-holding-back-tears:',
},
]);
});
test('case sensitive emoji lookup', () => {
const validVariants = [':smile:', ':face_holding_back_tears:'];
for (const emoji of validVariants) {
const parser = new Parser(emoji, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: expect.any(String),
codepoints: expect.any(String),
name: expect.any(String),
},
},
]);
}
});
test('invalid case emoji lookup', () => {
const invalidVariants = [
':SMILE:',
':Smile:',
':FACE_HOLDING_BACK_TEARS:',
':Face_Holding_Back_Tears:',
':face-holding-back-tears:',
];
for (const emoji of invalidVariants) {
const parser = new Parser(emoji, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: emoji}]);
}
});
test('separator variants', () => {
const input = ':face_holding_back_tears: :face-holding-back-tears:';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '🥹',
codepoints: '1f979',
name: 'face_holding_back_tears',
},
},
{
type: NodeType.Text,
content: ' :face-holding-back-tears:',
},
]);
});
test('basic emoji shortcode', () => {
const input = 'Hello :face_holding_back_tears: world!';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: expect.any(String),
codepoints: expect.any(String),
name: 'face_holding_back_tears',
},
},
{type: NodeType.Text, content: ' world!'},
]);
});
test('emoji shortcode in code', () => {
const input = "`print(':face_holding_back_tears:')`";
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.InlineCode, content: "print(':face_holding_back_tears:')"}]);
});
test('emoji shortcode in code block', () => {
const input = '```\n:face_holding_back_tears:\n```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.CodeBlock,
language: undefined,
content: ':face_holding_back_tears:\n',
},
]);
});
test('distinguishes between plaintext and emoji versions with variation selectors', () => {
const inputs = [
{text: '↩', shouldBeEmoji: false},
{text: '↩️', shouldBeEmoji: true},
{text: '↪', shouldBeEmoji: false},
{text: '↪️', shouldBeEmoji: true},
{text: '⤴', shouldBeEmoji: false},
{text: '⤴️', shouldBeEmoji: true},
];
for (const {text, shouldBeEmoji} of inputs) {
const parser = new Parser(text, 0);
const {nodes: ast} = parser.parse();
if (shouldBeEmoji) {
expect(ast[0].type).toBe(NodeType.Emoji);
expect((ast[0] as any).kind.kind).toBe(EmojiKind.Standard);
expect((ast[0] as any).kind.name).not.toBe('');
} else {
expect(ast[0].type).toBe(NodeType.Text);
expect((ast[0] as any).content).toBe(text);
}
}
});
test('renders mixed text with both plaintext and emoji versions', () => {
const input = '↩ is plaintext, ↩️ is emoji';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '↩ is plaintext, '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '↩️',
codepoints: '21a9',
name: 'leftwards_arrow_with_hook',
},
},
{type: NodeType.Text, content: ' is emoji'},
]);
});
test('correctly parses dingbat emojis', () => {
const inputs = [
{emoji: '✅', name: 'white_check_mark', codepoint: '2705'},
{emoji: '❌', name: 'x', codepoint: '274c'},
];
for (const {emoji, name, codepoint} of inputs) {
const parser = new Parser(emoji, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: emoji,
codepoints: codepoint,
name,
},
},
]);
}
});
test('dingbat emojis in text context', () => {
const input = 'Task complete ✅ but error ❌';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Task complete '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '✅',
codepoints: '2705',
name: 'white_check_mark',
},
},
{type: NodeType.Text, content: ' but error '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '❌',
codepoints: '274c',
name: 'x',
},
},
]);
});
test('malformed custom emoji edge cases', () => {
const malformedCases = [
'<:ab>',
'<:abc>',
'<:name:>',
'<:name:abc>',
'<:name:123abc>',
'<:name:12ab34>',
'<::123>',
];
for (const input of malformedCases) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Text,
content: input,
},
]);
}
});
test('empty custom emoji cases', () => {
const emptyCases = ['<::>', '<:name:>', '<::123>'];
for (const input of emptyCases) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Text,
content: input,
},
]);
}
});
test('custom emoji with invalid ID characters', () => {
const invalidIdCases = ['<:test:123a>', '<:name:12b34>', '<:emoji:abc123>', '<:custom:123-456>', '<:sample:12_34>'];
for (const input of invalidIdCases) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Text,
content: input,
},
]);
}
});
test('custom emoji with edge case names and IDs', () => {
const edgeCases = ['<::123>', '<: :123>'];
for (const input of edgeCases) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Text,
content: input,
},
]);
}
});
});
describe('Special Symbols Plaintext Rendering', () => {
test('trademark symbol should render as text without variation selector', () => {
const inputs = ['™', '™️'];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '™'}]);
}
});
test('copyright symbol should render as text without variation selector', () => {
const inputs = ['©', '©️'];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '©'}]);
}
});
test('registered symbol should render as text without variation selector', () => {
const inputs = ['®', '®️'];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '®'}]);
}
});
test('mixed emoji and special symbols', () => {
const input = '™️ ©️ ®️ 👍 ❤️';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '™ © ® '},
{
type: NodeType.Emoji,
kind: {
kind: 'Standard',
raw: '👍',
codepoints: '1f44d',
name: 'thumbsup',
},
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Emoji,
kind: {
kind: 'Standard',
raw: '❤️',
codepoints: '2764',
name: 'heart',
},
},
]);
});
test('special symbols in formatted text', () => {
const input = '**™️** *©️* __®__';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: '™'}],
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: '©'}],
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Underline,
children: [{type: NodeType.Text, content: '®'}],
},
]);
});
test('special symbols interspersed with text', () => {
const input = 'This product™ is copyright© and registered®';
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'This product™ is copyright© and registered®'}]);
});
});

View File

@@ -0,0 +1,336 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import emojiRegex from 'emoji-regex';
import {SKIN_TONE_SURROGATES} from '~/Constants';
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import * as EmojiUtils from '~/utils/EmojiUtils';
import {EmojiKind, NodeType} from '../types/enums';
import type {ParserResult} from '../types/nodes';
const VALID_EMOJI_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
const CUSTOM_EMOJI_REGEX = /^<(a)?:([a-zA-Z0-9_-]+):(\d+)>/;
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
const TEXT_PRESENTATION_MAP: Record<string, string> = {
'™️': '™',
'©️': '©',
'®️': '®',
};
const NEEDS_VARIATION_SELECTOR_CACHE = new Map<number, boolean>();
const SPECIAL_SHORTCODES: Record<string, string> = {
tm: '™',
copyright: '©',
registered: '®',
};
const EMOJI_NAME_CACHE = new Map<string, string | null>();
const EMOJI_BY_NAME_CACHE = new Map<string, import('~/lib/UnicodeEmojis').UnicodeEmoji | null>();
const EMOJI_REGEXP = emojiRegex();
function needsVariationSelector(codePoint: number): boolean {
if (NEEDS_VARIATION_SELECTOR_CACHE.has(codePoint)) {
return NEEDS_VARIATION_SELECTOR_CACHE.get(codePoint)!;
}
const result =
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
(codePoint >= 0x2900 && codePoint <= 0x297f);
NEEDS_VARIATION_SELECTOR_CACHE.set(codePoint, result);
return result;
}
function removeVariationSelectors(text: string): string {
if (text.length < 2 || text.indexOf('\uFE0F') === -1) {
return text;
}
let result = '';
let i = 0;
while (i < text.length) {
if (text.charCodeAt(i) === 0x2122 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '™';
i += 2;
} else if (text.charCodeAt(i) === 0xa9 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '©';
i += 2;
} else if (text.charCodeAt(i) === 0xae && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '®';
i += 2;
} else {
result += text.charAt(i);
i++;
}
}
return result;
}
export function parseStandardEmoji(text: string, start: number): ParserResult | null {
if (!text || start >= text.length || text.length - start < 1) {
return null;
}
const firstChar = text.charAt(start);
if (PLAINTEXT_SYMBOLS.has(firstChar)) {
return null;
}
const firstCharCode = text.charCodeAt(start);
if (
firstCharCode < 0x80 &&
firstCharCode !== 0x23 &&
firstCharCode !== 0x2a &&
firstCharCode !== 0x30 &&
firstCharCode !== 0x31 &&
(firstCharCode < 0x32 || firstCharCode > 0x39)
) {
return null;
}
EMOJI_REGEXP.lastIndex = 0;
const match = EMOJI_REGEXP.exec(text.slice(start));
if (match && match.index === 0) {
const candidate = match[0];
if (PLAINTEXT_SYMBOLS.has(candidate)) {
return null;
}
if (PLAINTEXT_SYMBOLS.has(candidate)) {
const textPresentation = TEXT_PRESENTATION_MAP[candidate];
if (textPresentation) {
return {
node: {type: NodeType.Text, content: textPresentation},
advance: candidate.length,
};
}
return {
node: {type: NodeType.Text, content: candidate},
advance: candidate.length,
};
}
const hasVariationSelector = candidate.indexOf('\uFE0F') !== -1;
const codePoint = candidate.codePointAt(0) || 0;
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) {
return null;
}
let name = EMOJI_NAME_CACHE.get(candidate);
if (name === undefined) {
name = UnicodeEmojis.getSurrogateName(candidate);
EMOJI_NAME_CACHE.set(candidate, name);
}
if (!name) {
return null;
}
const codepoints = EmojiUtils.convertToCodePoints(candidate);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: candidate,
codepoints,
name,
},
},
advance: candidate.length,
};
}
return null;
}
export function parseEmojiShortcode(text: string): ParserResult | null {
if (!text.startsWith(':') || text.length < 3) {
return null;
}
const endPos = text.indexOf(':', 1);
if (endPos === -1 || endPos === 1) {
return null;
}
const fullName = text.substring(1, endPos);
const specialSymbol = SPECIAL_SHORTCODES[fullName];
if (specialSymbol) {
return {
node: {type: NodeType.Text, content: specialSymbol},
advance: endPos + 1,
};
}
let baseName: string;
let skinTone: number | undefined;
const skinToneIndex = fullName.indexOf('::skin-tone-');
if (skinToneIndex !== -1 && skinToneIndex + 12 < fullName.length) {
const toneChar = fullName.charAt(skinToneIndex + 12);
const tone = Number.parseInt(toneChar, 10);
if (tone >= 1 && tone <= 5) {
baseName = fullName.substring(0, skinToneIndex);
skinTone = tone;
} else {
baseName = fullName;
}
} else {
baseName = fullName;
}
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
return null;
}
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
if (emoji === undefined) {
emoji = UnicodeEmojis.findEmojiByName(baseName);
EMOJI_BY_NAME_CACHE.set(baseName, emoji);
}
if (!emoji) {
return null;
}
const emojiSurrogate = emoji.surrogates;
if (PLAINTEXT_SYMBOLS.has(emojiSurrogate)) {
const textPresentation = TEXT_PRESENTATION_MAP[emojiSurrogate];
if (textPresentation) {
return {
node: {type: NodeType.Text, content: textPresentation},
advance: endPos + 1,
};
}
return {
node: {type: NodeType.Text, content: emojiSurrogate},
advance: endPos + 1,
};
}
let finalEmoji = emoji;
if (skinTone !== undefined) {
const skinToneKey = `${baseName}:tone-${skinTone}`;
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
if (skinToneEmoji === undefined) {
const skinToneSurrogate = SKIN_TONE_SURROGATES[skinTone - 1];
skinToneEmoji = UnicodeEmojis.findEmojiWithSkinTone(baseName, skinToneSurrogate);
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
}
if (skinToneEmoji) {
finalEmoji = skinToneEmoji;
}
}
if (!finalEmoji) {
return null;
}
const codepoints = EmojiUtils.convertToCodePoints(finalEmoji.surrogates);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: finalEmoji.surrogates,
codepoints,
name: baseName,
},
},
advance: endPos + 1,
};
}
export function parseCustomEmoji(text: string): ParserResult | null {
if (!(text.startsWith('<:') || text.startsWith('<a:'))) {
return null;
}
const lastIdx = text.indexOf('>');
if (lastIdx === -1 || lastIdx < 4) {
return null;
}
const match = CUSTOM_EMOJI_REGEX.exec(text);
if (!match) {
return null;
}
const animated = Boolean(match[1]);
const name = match[2];
const id = match[3];
const advance = match[0].length;
if (!name || !id || id.length === 0) {
return null;
}
for (let i = 0; i < id.length; i++) {
const charCode = id.charCodeAt(i);
if (charCode < 48 || charCode > 57) {
return null;
}
}
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name,
id,
animated,
},
},
advance,
};
}
export function applyTextPresentation(node: any): void {
if (node && node.type === NodeType.Text && typeof node.content === 'string') {
if (node.content.indexOf('\uFE0F') !== -1) {
node.content = removeVariationSelectors(node.content);
}
} else if (node && Array.isArray(node.children)) {
for (const child of node.children) {
applyTextPresentation(child);
}
}
}

View File

@@ -0,0 +1,685 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {NodeType, ParserFlags} from '../types/enums';
import type {TextNode} from '../types/nodes';
describe('Fluxer Markdown Parser', () => {
test('inline nodes', () => {
const input =
'This is **strong**, *emphasis*, __underline__, ~~strikethrough~~, `inline code`, and a [link](https://example.com). Also, visit https://rust-lang.org.';
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_AUTOLINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'This is '},
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.Underline, children: [{type: NodeType.Text, content: 'underline'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strikethrough'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.InlineCode, content: 'inline code'},
{type: NodeType.Text, content: ', and a '},
{
type: NodeType.Link,
text: {type: NodeType.Text, content: 'link'},
url: 'https://example.com/',
escaped: false,
},
{type: NodeType.Text, content: '. Also, visit '},
{
type: NodeType.Link,
text: undefined,
url: 'https://rust-lang.org/',
escaped: false,
},
{type: NodeType.Text, content: '.'},
]);
});
test('incomplete formatting', () => {
const input = '**incomplete strong *incomplete emphasis `incomplete code';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '*'},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'incomplete strong '}]},
{type: NodeType.Text, content: 'incomplete emphasis `incomplete code'},
]);
});
test('underscore emphasis', () => {
const input = 'This is _emphasized_ and *also emphasized*';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'This is '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'also emphasized'}]},
]);
});
test('alternate delimiters', () => {
const input = '__underscore *asterisk* mix__ and _single *with* under_';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Underline,
children: [
{type: NodeType.Text, content: 'underscore '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'asterisk'}]},
{type: NodeType.Text, content: ' mix'},
],
},
{type: NodeType.Text, content: ' and '},
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'single with under'}],
},
]);
});
test('inline spoiler', () => {
const input = 'This is a ||secret|| message';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'This is a '},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
{type: NodeType.Text, content: ' message'},
]);
});
test('formatted spoiler', () => {
const input = '||This is *emphasized* and **strong**||';
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: false,
children: [
{type: NodeType.Text, content: 'This is '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
],
},
]);
});
test('adjacent spoilers', () => {
const input = '||a|| ||b|| ||c||';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'a'}]},
{type: NodeType.Text, content: ' '},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'b'}]},
{type: NodeType.Text, content: ' '},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'c'}]},
]);
});
test('unclosed spoiler', () => {
const input = '||This spoiler never ends';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '||This spoiler never ends'}]);
});
test('consecutive pipes should create spoilers with pipe content', () => {
const input = '|||||||||||||||';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
]);
const fivePipes = '|||||';
const fiveParser = new Parser(fivePipes, ParserFlags.ALLOW_SPOILERS);
const {nodes: fiveAst} = fiveParser.parse();
expect(fiveAst).toEqual([
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
]);
const sixPipes = '||||||';
const sixParser = new Parser(sixPipes, ParserFlags.ALLOW_SPOILERS);
const {nodes: sixAst} = sixParser.parse();
expect(sixAst).toEqual([
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
{type: NodeType.Text, content: '|'},
]);
});
test('bold italics', () => {
const input = '***bolditalics***';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'bolditalics'}],
},
],
},
]);
});
test('complex nested formatting combinations', () => {
const input =
'***__bold italic underline__***\n**_bold and italic_**\n__***underline, bold, italic***__\n**_nested __underline inside italic__ text_**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Strong,
children: [
{
type: NodeType.Underline,
children: [{type: NodeType.Text, content: 'bold italic underline'}],
},
],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Strong,
children: [
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'bold and italic'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Underline,
children: [
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'underline, bold, italic'}],
},
],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Strong,
children: [
{
type: NodeType.Emphasis,
children: [
{type: NodeType.Text, content: 'nested '},
{
type: NodeType.Underline,
children: [{type: NodeType.Text, content: 'underline inside italic'}],
},
{type: NodeType.Text, content: ' text'},
],
},
],
},
]);
});
test('spoiler with various formatting combinations', () => {
const input =
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: false,
children: [
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'spoiler bold'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Strong,
children: [
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.Text, content: 'bold spoiler'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.Text, content: 'italic spoiler'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{type: NodeType.InlineCode, content: '||spoiler code||'},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
},
]);
});
test('spoiler with broken nesting and mixed formatting', () => {
const input =
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||\n||_**mixed || nesting madness**_||';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: false,
children: [
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'spoiler bold'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Strong,
children: [
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.Text, content: 'bold spoiler'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.Text, content: 'italic spoiler'}],
},
],
},
{type: NodeType.Text, content: '\n'},
{type: NodeType.InlineCode, content: '||spoiler code||'},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Spoiler,
isBlock: false,
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
},
{type: NodeType.Text, content: '\n'},
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '_**mixed '}]},
{type: NodeType.Text, content: ' nesting madness**_||'},
]);
});
test('text node splitting', () => {
const input = 'This is a link: [Rust](https://www.rust-lang.org).';
const flags = ParserFlags.ALLOW_MASKED_LINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'This is a link: '},
{
type: NodeType.Link,
text: {type: NodeType.Text, content: 'Rust'},
url: 'https://www.rust-lang.org/',
escaped: false,
},
{type: NodeType.Text, content: '.'},
]);
});
test('newline text handling', () => {
const input = 'First line.\nSecond line with `code`.\nThird line.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'First line.\nSecond line with '},
{type: NodeType.InlineCode, content: 'code'},
{type: NodeType.Text, content: '.\nThird line.'},
]);
});
test('underscore emphasis with constants', () => {
const input = 'THIS_IS_A_CONSTANT THIS _IS_ A_CONSTANT THIS _IS_. A CONSTANT';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'THIS_IS_A_CONSTANT THIS '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
{type: NodeType.Text, content: ' A_CONSTANT THIS '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
{type: NodeType.Text, content: '. A CONSTANT'},
]);
});
test('incomplete formatting in code', () => {
const input = '`function() { /* ** {{ __unclosed__ }} ** */ }`';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.InlineCode, content: 'function() { /* ** {{ __unclosed__ }} ** */ }'}]);
});
test('link with inline code', () => {
const input = '[`f38932b`](https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13)';
const flags = ParserFlags.ALLOW_MASKED_LINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Link,
text: {type: NodeType.InlineCode, content: 'f38932b'},
url: 'https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13',
escaped: false,
},
]);
});
test('link with all inline formatting', () => {
const input = '[**Bold**, *Italic*, ~~Strikethrough~~, and `Code`](https://example.com)';
const flags = ParserFlags.ALLOW_MASKED_LINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Link,
text: {
type: NodeType.Sequence,
children: [
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'Bold'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'Italic'}]},
{type: NodeType.Text, content: ', '},
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'Strikethrough'}]},
{type: NodeType.Text, content: ', and '},
{type: NodeType.InlineCode, content: 'Code'},
],
},
url: 'https://example.com/',
escaped: false,
},
]);
});
test('shrug emoticon should preserve backslash before underscore', () => {
const input = 'Check out this shrug: ¯\\_(ツ)_/¯';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Check out this shrug: ¯\\_(ツ)_/¯'}]);
expect((ast[0] as TextNode).content).toContain('¯\\_(');
});
test('regular escaped underscore should be handled correctly', () => {
const input = 'This is not \\_emphasized\\_ text';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'This is not _emphasized_ text'}]);
});
describe('Edge cases and complex nesting', () => {
test('double-space line breaks in formatted text', () => {
const input = '**bold \nacross \nmultiple lines**\n__underline \nwith breaks__';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.Strong);
expect(ast[1].type).toBe(NodeType.Text);
expect(ast[1]).toEqual({type: NodeType.Text, content: '\n'});
expect(ast[2].type).toBe(NodeType.Underline);
// TODO: Check for line break nodes within the formatted sections
});
test('escaped characters in formatting', () => {
const input = '**bold \\*with\\* escaped**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.Strong);
});
test('nested formatting behavior', () => {
const input = '**outer **inner** content**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBeGreaterThan(0);
});
test('empty double markers', () => {
const input = '****';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '****'}]);
});
test('incomplete formatting marker', () => {
const input = '**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '**'}]);
});
test('complex nested formatting behavior', () => {
const input = '**outer *middle **inner** content* end**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBeGreaterThan(0);
});
test('escaped backslash handling', () => {
const input = '**test \\\\escaped backslash**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.Strong);
});
test('extremely complex nested formatting with weird edge cases', () => {
const input =
'***bold _italic __underline bold italic__ italic_ bold***\n**_nested *italic inside bold inside italic*_**\n__**_underline bold italic **still going_**__\n**_this ends weirdly__**\n***bold *italic `code` inside***';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Strong,
children: [
{type: NodeType.Text, content: 'bold '},
{
type: NodeType.Emphasis,
children: [
{type: NodeType.Text, content: 'italic '},
{
type: NodeType.Underline,
children: [{type: NodeType.Text, content: 'underline bold italic'}],
},
{type: NodeType.Text, content: ' italic'},
],
},
{type: NodeType.Text, content: ' bold'},
],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Strong,
children: [
{
type: NodeType.Emphasis,
children: [
{type: NodeType.Text, content: 'nested '},
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'italic inside bold inside italic'}],
},
],
},
],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Underline,
children: [
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_underline bold italic '}]},
{type: NodeType.Text, content: 'still going_**'},
],
},
{type: NodeType.Text, content: '\n'},
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_this ends weirdly__'}]},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Emphasis,
children: [
{
type: NodeType.Strong,
children: [
{type: NodeType.Text, content: 'bold *italic '},
{type: NodeType.InlineCode, content: 'code'},
{type: NodeType.Text, content: ' inside'},
],
},
],
},
]);
});
test('complex mismatched formatting markers', () => {
const input =
'**bold *italic*\n__underline **bold__\n~~strike *italic~~ text*\n**__mixed but only one end__\n_italics __underline_';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Strong,
children: [
{type: NodeType.Text, content: 'bold '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'italic'}]},
{type: NodeType.Text, content: '\n__underline '},
],
},
{type: NodeType.Text, content: 'bold'},
{
type: NodeType.Underline,
children: [
{type: NodeType.Text, content: '\n'},
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strike *italic'}]},
{type: NodeType.Text, content: ' text'},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: '\n'}]},
{type: NodeType.Text, content: '*'},
],
},
{type: NodeType.Text, content: 'mixed but only one end'},
{type: NodeType.Underline, children: [{type: NodeType.Text, content: '\n_italics '}]},
{type: NodeType.Text, content: 'underline_'},
]);
});
});
});

View File

@@ -0,0 +1,863 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {FormattingContext} from '../parser/formatting-context';
import {MAX_LINE_LENGTH} from '../types/constants';
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
import type {Node, ParserResult} from '../types/nodes';
import * as ASTUtils from '../utils/ast-utils';
import * as StringUtils from '../utils/string-utils';
import * as EmojiParsers from './emoji-parsers';
import * as LinkParsers from './link-parsers';
import * as MentionParsers from './mention-parsers';
import * as TimestampParsers from './timestamp-parsers';
const BACKSLASH = 92;
const UNDERSCORE = 95;
const ASTERISK = 42;
const TILDE = 126;
const PIPE = 124;
const BACKTICK = 96;
const LESS_THAN = 60;
const AT_SIGN = 64;
const HASH = 35;
const AMPERSAND = 38;
const SLASH = 47;
const OPEN_BRACKET = 91;
const COLON = 58;
const LETTER_A = 97;
const LETTER_I = 105;
const LETTER_M = 109;
const LETTER_S = 115;
const LETTER_T = 116;
const PLUS_SIGN = 43;
const FORMATTING_CHARS = new Set([ASTERISK, UNDERSCORE, TILDE, PIPE, BACKTICK]);
const parseInlineCache = new Map<string, Array<Node>>();
const formattingMarkerCache = new Map<string, ReturnType<typeof getFormattingMarkerInfo>>();
const MAX_CACHE_SIZE = 500;
const cacheHitCount = new Map<string, number>();
export function parseInline(text: string, parserFlags: number): Array<Node> {
if (!text || text.length === 0) {
return [];
}
const cacheKey = `${text}:${parserFlags}`;
if (parseInlineCache.has(cacheKey)) {
const cachedResult = parseInlineCache.get(cacheKey)!;
const hitCount = cacheHitCount.get(cacheKey) || 0;
cacheHitCount.set(cacheKey, hitCount + 1);
return [...cachedResult];
}
const context = new FormattingContext();
const nodes = parseInlineWithContext(text, context, parserFlags);
ASTUtils.flattenAST(nodes);
if (text.length < 1000) {
parseInlineCache.set(cacheKey, [...nodes]);
cacheHitCount.set(cacheKey, 1);
if (parseInlineCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(cacheHitCount.entries())
.sort((a, b) => a[1] - b[1])
.slice(0, 100);
for (const [key] of entries) {
parseInlineCache.delete(key);
cacheHitCount.delete(key);
}
}
}
return nodes;
}
function parseInlineWithContext(text: string, context: FormattingContext, parserFlags: number): Array<Node> {
if (!text) {
return [];
}
const nodes: Array<Node> = [];
let accumulatedText = '';
let position = 0;
const textLength = text.length;
let characters: Array<string> | null = null;
while (position < textLength) {
const currentChar = text.charAt(position);
const currentCharCode = text.charCodeAt(position);
if (currentCharCode === BACKSLASH && position + 1 < textLength) {
const nextChar = text.charAt(position + 1);
if (nextChar === '_' && position > 0 && text.charAt(position - 1) === '¯') {
accumulatedText += `\\${nextChar}`;
position += 2;
continue;
}
if (StringUtils.isEscapableCharacter(nextChar)) {
accumulatedText += nextChar;
position += 2;
continue;
}
}
const remainingText = text.slice(position);
const insideQuotedAngleBracket = accumulatedText.endsWith('<"') || accumulatedText.endsWith("<'");
if (
!insideQuotedAngleBracket &&
parserFlags & ParserFlags.ALLOW_AUTOLINKS &&
StringUtils.startsWithUrl(remainingText)
) {
const urlResult = LinkParsers.extractUrlSegment(remainingText, parserFlags);
if (urlResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(urlResult.node);
position += urlResult.advance;
continue;
}
}
if (currentCharCode === UNDERSCORE) {
if (characters == null) {
characters = [...text];
}
const isDoubleUnderscore = position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
if (!isDoubleUnderscore) {
const isWordUnderscore = StringUtils.isWordUnderscore(characters, position);
if (isWordUnderscore) {
accumulatedText += '_';
position += 1;
continue;
}
}
}
const emojiResult = EmojiParsers.parseStandardEmoji(text, position);
if (emojiResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(emojiResult.node);
position += emojiResult.advance;
continue;
}
if (currentCharCode === LESS_THAN && position + 2 < textLength) {
const nextCharCode = text.charCodeAt(position + 1);
const thirdCharCode = position + 2 < textLength ? text.charCodeAt(position + 2) : 0;
if (nextCharCode === COLON || (nextCharCode === LETTER_A && thirdCharCode === COLON)) {
const customEmojiResult = EmojiParsers.parseCustomEmoji(remainingText);
if (customEmojiResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(customEmojiResult.node);
position += customEmojiResult.advance;
continue;
}
}
}
if (
currentCharCode === LESS_THAN &&
position + 3 < textLength &&
text.charCodeAt(position + 1) === LETTER_T &&
text.charCodeAt(position + 2) === COLON
) {
const timestampResult = TimestampParsers.parseTimestamp(remainingText);
if (timestampResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(timestampResult.node);
position += timestampResult.advance;
continue;
}
}
if (currentCharCode === COLON) {
const emojiResult = EmojiParsers.parseEmojiShortcode(remainingText);
if (emojiResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(emojiResult.node);
position += emojiResult.advance;
continue;
}
}
if (
currentCharCode === LESS_THAN &&
position + 1 < textLength &&
text.charCodeAt(position + 1) === PLUS_SIGN &&
parserFlags & ParserFlags.ALLOW_AUTOLINKS
) {
const phoneResult = LinkParsers.parsePhoneLink(remainingText, parserFlags);
if (phoneResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(phoneResult.node);
position += phoneResult.advance;
continue;
}
}
if (
currentCharCode === LESS_THAN &&
position + 4 < textLength &&
text.charCodeAt(position + 1) === LETTER_S &&
text.charCodeAt(position + 2) === LETTER_M &&
text.charCodeAt(position + 3) === LETTER_S &&
text.charCodeAt(position + 4) === COLON &&
parserFlags & ParserFlags.ALLOW_AUTOLINKS
) {
const smsResult = LinkParsers.parseSmsLink(remainingText, parserFlags);
if (smsResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(smsResult.node);
position += smsResult.advance;
continue;
}
}
if (currentCharCode === LESS_THAN && position + 1 < textLength) {
const nextCharCode = text.charCodeAt(position + 1);
if (
nextCharCode === AT_SIGN ||
nextCharCode === HASH ||
nextCharCode === AMPERSAND ||
nextCharCode === SLASH ||
nextCharCode === LETTER_I
) {
if (
nextCharCode === AT_SIGN &&
position + 2 < textLength &&
text.charCodeAt(position + 2) === AMPERSAND &&
parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS
) {
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
if (mentionResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(mentionResult.node);
position += mentionResult.advance;
continue;
}
} else if (nextCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
if (mentionResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(mentionResult.node);
position += mentionResult.advance;
continue;
}
} else if (nextCharCode === HASH && parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS) {
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
if (mentionResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(mentionResult.node);
position += mentionResult.advance;
continue;
}
} else if (nextCharCode === SLASH && parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
if (mentionResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(mentionResult.node);
position += mentionResult.advance;
continue;
}
} else if (
nextCharCode === LETTER_I &&
remainingText.startsWith('<id:') &&
parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS
) {
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
if (mentionResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(mentionResult.node);
position += mentionResult.advance;
continue;
}
}
}
if (parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
const autolinkResult = LinkParsers.parseAutolink(remainingText, parserFlags);
if (autolinkResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(autolinkResult.node);
position += autolinkResult.advance;
continue;
}
// Try email links: <user@example.com>
const emailLinkResult = LinkParsers.parseEmailLink(remainingText, parserFlags);
if (emailLinkResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(emailLinkResult.node);
position += emailLinkResult.advance;
continue;
}
}
}
if (currentCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
const isEscaped = position > 0 && text.charCodeAt(position - 1) === BACKSLASH;
if (!isEscaped && remainingText.startsWith('@everyone')) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push({
type: NodeType.Mention,
kind: {kind: MentionKind.Everyone},
});
position += 9;
continue;
}
if (!isEscaped && remainingText.startsWith('@here')) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push({
type: NodeType.Mention,
kind: {kind: MentionKind.Here},
});
position += 5;
continue;
}
}
const isDoubleUnderscore =
currentCharCode === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
if (
(FORMATTING_CHARS.has(currentCharCode) || currentCharCode === OPEN_BRACKET) &&
(isDoubleUnderscore ||
!(
currentCharCode === UNDERSCORE &&
accumulatedText.length > 0 &&
StringUtils.isAlphaNumeric(accumulatedText.charCodeAt(accumulatedText.length - 1))
))
) {
context.setCurrentText(accumulatedText);
const specialResult = parseSpecialSequence(remainingText, context, parserFlags);
if (specialResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(specialResult.node);
position += specialResult.advance;
continue;
}
}
accumulatedText += currentChar;
position += 1;
if (accumulatedText.length > MAX_LINE_LENGTH) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
break;
}
}
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
}
const result = ASTUtils.mergeTextNodes(nodes);
return result;
}
function parseSpecialSequence(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
if (text.length === 0) return null;
const firstCharCode = text.charCodeAt(0);
switch (firstCharCode) {
case LESS_THAN:
if (text.length > 1) {
const nextCharCode = text.charCodeAt(1);
if (nextCharCode === SLASH) {
if (parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
const mentionResult = MentionParsers.parseMention(text, parserFlags);
if (mentionResult) return mentionResult;
}
} else if (nextCharCode === LETTER_I && text.startsWith('<id:')) {
if (parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) {
const mentionResult = MentionParsers.parseMention(text, parserFlags);
if (mentionResult) return mentionResult;
}
} else if (nextCharCode === PLUS_SIGN && parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
const phoneResult = LinkParsers.parsePhoneLink(text, parserFlags);
if (phoneResult) return phoneResult;
} else if (
nextCharCode === LETTER_S &&
text.length > 4 &&
text.charCodeAt(2) === LETTER_S &&
text.charCodeAt(3) === COLON &&
parserFlags & ParserFlags.ALLOW_AUTOLINKS
) {
const smsResult = LinkParsers.parseSmsLink(text, parserFlags);
if (smsResult) return smsResult;
}
}
break;
case ASTERISK:
case UNDERSCORE:
case TILDE:
case PIPE:
case BACKTICK: {
const formattingResult = parseFormatting(text, context, parserFlags);
if (formattingResult) return formattingResult;
break;
}
case AT_SIGN:
if (parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
if (text.startsWith('@everyone')) {
return {
node: {
type: NodeType.Mention,
kind: {kind: MentionKind.Everyone},
},
advance: 9,
};
}
if (text.startsWith('@here')) {
return {
node: {
type: NodeType.Mention,
kind: {kind: MentionKind.Here},
},
advance: 5,
};
}
}
break;
case OPEN_BRACKET: {
const timestampResult = TimestampParsers.parseTimestamp(text);
if (timestampResult) return timestampResult;
if (parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
if (linkResult) return linkResult;
}
break;
}
}
if (firstCharCode !== OPEN_BRACKET) {
const timestampResult = TimestampParsers.parseTimestamp(text);
if (timestampResult) return timestampResult;
}
if (firstCharCode !== LESS_THAN && firstCharCode !== OPEN_BRACKET && parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
if (linkResult) return linkResult;
}
return null;
}
function parseFormatting(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
if (text.length < 2) {
return null;
}
let markerInfo: FormattingMarkerInfo | null | undefined;
const prefix = text.slice(0, Math.min(3, text.length));
if (formattingMarkerCache.has(prefix)) {
markerInfo = formattingMarkerCache.get(prefix);
const hitCount = cacheHitCount.get(prefix) || 0;
cacheHitCount.set(prefix, hitCount + 1);
} else {
markerInfo = getFormattingMarkerInfo(text);
formattingMarkerCache.set(prefix, markerInfo);
cacheHitCount.set(prefix, 1);
if (formattingMarkerCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(cacheHitCount.entries())
.filter(([key]) => formattingMarkerCache.has(key))
.sort((a, b) => a[1] - b[1])
.slice(0, 50);
for (const [key] of entries) {
formattingMarkerCache.delete(key);
cacheHitCount.delete(key);
}
}
}
if (!markerInfo) return null;
const {marker, nodeType, markerLength} = markerInfo;
if (nodeType === NodeType.Spoiler && !(parserFlags & ParserFlags.ALLOW_SPOILERS)) {
return null;
}
if (!context.canEnterFormatting(marker[0], marker.length > 1)) return null;
const endResult = findFormattingEnd(text, marker, markerLength, nodeType);
if (!endResult) return null;
const {endPosition, innerContent} = endResult;
const isBlock = context.isFormattingActive(marker[0], marker.length > 1);
const formattingNode = createFormattingNode(
nodeType,
innerContent,
marker,
isBlock,
(text: string, ctx: FormattingContext) => parseInlineWithContext(text, ctx, parserFlags),
);
return {node: formattingNode, advance: endPosition + markerLength};
}
interface FormattingMarkerInfo {
marker: string;
nodeType: NodeType;
markerLength: number;
}
function getFormattingMarkerInfo(text: string): FormattingMarkerInfo | null {
if (!text || text.length === 0) return null;
const firstCharCode = text.charCodeAt(0);
if (!FORMATTING_CHARS.has(firstCharCode)) return null;
const secondCharCode = text.length > 1 ? text.charCodeAt(1) : 0;
const thirdCharCode = text.length > 2 ? text.charCodeAt(2) : 0;
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK && thirdCharCode === ASTERISK) {
return {marker: '***', nodeType: NodeType.Emphasis, markerLength: 3};
}
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE && thirdCharCode === UNDERSCORE) {
return {marker: '___', nodeType: NodeType.Emphasis, markerLength: 3};
}
if (firstCharCode === PIPE && secondCharCode === PIPE) {
return {marker: '||', nodeType: NodeType.Spoiler, markerLength: 2};
}
if (firstCharCode === TILDE && secondCharCode === TILDE) {
return {marker: '~~', nodeType: NodeType.Strikethrough, markerLength: 2};
}
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK) {
return {marker: '**', nodeType: NodeType.Strong, markerLength: 2};
}
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE) {
return {marker: '__', nodeType: NodeType.Underline, markerLength: 2};
}
if (firstCharCode === BACKTICK) {
let backtickCount = 1;
while (backtickCount < text.length && text.charCodeAt(backtickCount) === BACKTICK) {
backtickCount++;
}
return {marker: '`'.repeat(backtickCount), nodeType: NodeType.InlineCode, markerLength: backtickCount};
}
if (firstCharCode === ASTERISK) {
return {marker: '*', nodeType: NodeType.Emphasis, markerLength: 1};
}
if (firstCharCode === UNDERSCORE) {
return {marker: '_', nodeType: NodeType.Emphasis, markerLength: 1};
}
return null;
}
function findFormattingEnd(
text: string,
marker: string,
markerLength: number,
nodeType: NodeType,
): {endPosition: number; innerContent: string} | null {
let position = markerLength;
let nestedLevel = 0;
let endPosition: number | null = null;
const textLength = text.length;
if (textLength < markerLength * 2) return null;
if (nodeType === NodeType.InlineCode && markerLength > 1) {
while (position < textLength) {
if (text.charCodeAt(position) === BACKTICK) {
let backtickCount = 0;
let checkPos = position;
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
backtickCount++;
checkPos++;
}
if (backtickCount === markerLength) {
endPosition = position;
break;
}
position = checkPos;
continue;
}
position++;
if (position > MAX_LINE_LENGTH) break;
}
if (endPosition == null) return null;
return {
endPosition,
innerContent: text.slice(markerLength, endPosition),
};
}
if (markerLength === 1 && (nodeType === NodeType.Emphasis || nodeType === NodeType.InlineCode)) {
const markerChar = marker.charCodeAt(0);
while (position < textLength) {
const currentChar = text.charCodeAt(position);
if (currentChar === BACKSLASH && position + 1 < textLength) {
position += 2;
continue;
}
if (currentChar === markerChar) {
if (markerChar === BACKTICK && position + 1 < textLength && text.charCodeAt(position + 1) === BACKTICK) {
let _backtickCount = 0;
let checkPos = position;
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
_backtickCount++;
checkPos++;
}
position = checkPos;
continue;
}
if (markerChar === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE) {
position += 2;
continue;
}
endPosition = position;
break;
}
position++;
if (position > MAX_LINE_LENGTH) break;
}
if (endPosition == null) return null;
return {
endPosition,
innerContent: text.slice(markerLength, endPosition),
};
}
if (nodeType === NodeType.InlineCode) {
while (position < textLength) {
if (text.charCodeAt(position) === BACKTICK) {
endPosition = position;
break;
}
position++;
if (position > MAX_LINE_LENGTH) break;
}
} else {
const firstMarkerChar = marker.charCodeAt(0);
const isDoubleMarker = marker.length > 1;
while (position < textLength) {
if (text.charCodeAt(position) === BACKSLASH && position + 1 < textLength) {
position += 2;
continue;
}
let isClosingMarker = true;
if (position + marker.length <= textLength) {
for (let i = 0; i < marker.length; i++) {
if (text.charCodeAt(position + i) !== marker.charCodeAt(i)) {
isClosingMarker = false;
break;
}
}
} else {
isClosingMarker = false;
}
if (isClosingMarker) {
if (nestedLevel === 0) {
if (nodeType === NodeType.Spoiler && position === markerLength && position + marker.length < textLength) {
position += 1;
continue;
}
endPosition = position;
break;
}
nestedLevel--;
position += marker.length;
continue;
}
if (
isDoubleMarker &&
position + 1 < textLength &&
text.charCodeAt(position) === firstMarkerChar &&
text.charCodeAt(position + 1) === firstMarkerChar
) {
nestedLevel++;
}
position++;
if (position > MAX_LINE_LENGTH) break;
}
}
if (endPosition == null) return null;
const innerContent = text.slice(markerLength, endPosition);
return {endPosition, innerContent};
}
function createFormattingNode(
nodeType: NodeType,
innerContent: string,
marker: string,
isBlock: boolean,
parseInlineWithContext: (text: string, context: FormattingContext) => Array<Node>,
): Node {
if (nodeType === NodeType.InlineCode) {
return {type: NodeType.InlineCode, content: innerContent};
}
if (innerContent.length === 0) {
return {
type: nodeType as any,
children: [],
...(isBlock ? {isBlock} : {}),
};
}
const newContext = new FormattingContext();
newContext.pushFormatting(marker[0], marker.length > 1);
if (marker === '***' || marker === '___') {
const emphasisContext = new FormattingContext();
emphasisContext.pushFormatting('*', true);
const innerNodes = parseInlineWithContext(innerContent, emphasisContext);
return {
type: NodeType.Emphasis,
children: [{type: NodeType.Strong, children: innerNodes}],
};
}
const innerNodes = parseInlineWithContext(innerContent, newContext);
return {
type: nodeType as any,
children: innerNodes,
...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,540 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
import {MAX_LINK_URL_LENGTH} from '../types/constants';
import {NodeType, ParserFlags} from '../types/enums';
import type {Node, ParserResult} from '../types/nodes';
import * as StringUtils from '../utils/string-utils';
import * as URLUtils from '../utils/url-utils';
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_DOMAIN_PATTERN =
/^(?:https?:\/\/)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\s[\]]*)?$/;
const OPEN_BRACKET = 91;
const CLOSE_BRACKET = 93;
const OPEN_PAREN = 40;
const CLOSE_PAREN = 41;
const BACKSLASH = 92;
const LESS_THAN = 60;
const GREATER_THAN = 62;
const DOUBLE_QUOTE = 34;
const SINGLE_QUOTE = 39;
const PLUS_SIGN = 43;
function containsLinkSyntax(text: string): boolean {
const bracketIndex = text.indexOf('[');
if (bracketIndex === -1) return false;
const closeBracketIndex = text.indexOf(']', bracketIndex);
if (closeBracketIndex === -1) return false;
if (closeBracketIndex + 1 < text.length && text[closeBracketIndex + 1] === '(') {
return true;
}
return containsLinkSyntax(text.substring(closeBracketIndex + 1));
}
export function parseLink(
text: string,
_parserFlags: number,
parseInline: (text: string) => Array<Node>,
): ParserResult | null {
if (text.charCodeAt(0) !== OPEN_BRACKET) return null;
const linkParts = extractLinkParts(text);
if (!linkParts) {
if (SPOOFED_LINK_PATTERN.test(text)) {
return {
node: {type: NodeType.Text, content: text},
advance: text.length,
};
}
const bracketResult = findClosingBracket(text);
if (bracketResult) {
const {bracketPosition, linkText} = bracketResult;
if (containsLinkSyntax(linkText)) {
return {
node: {type: NodeType.Text, content: text},
advance: text.length,
};
}
return {
node: {type: NodeType.Text, content: text.slice(0, bracketPosition + 1)},
advance: bracketPosition + 1,
};
}
return null;
}
try {
const normalizedUrl = URLUtils.normalizeUrl(linkParts.url);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (linkParts.url.startsWith('/') && !linkParts.url.startsWith('//')) {
return {
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
advance: linkParts.advanceBy,
};
}
let finalUrl = normalizedUrl;
if (finalUrl.startsWith('tel:') || finalUrl.startsWith('sms:')) {
const protocol = finalUrl.substring(0, finalUrl.indexOf(':') + 1);
const phoneNumber = finalUrl.substring(finalUrl.indexOf(':') + 1);
if (phoneNumber.startsWith('+')) {
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
finalUrl = protocol + normalizedPhone;
}
} else {
finalUrl = URLUtils.convertToAsciiUrl(finalUrl);
}
const inlineNodes = parseInline(linkParts.linkText);
return {
node: {
type: NodeType.Link,
text: inlineNodes.length === 1 ? inlineNodes[0] : {type: NodeType.Sequence, children: inlineNodes},
url: finalUrl,
escaped: linkParts.isEscaped,
},
advance: linkParts.advanceBy,
};
}
} catch {
return {
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
advance: linkParts.advanceBy,
};
}
return null;
}
function extractLinkParts(text: string): {linkText: string; url: string; isEscaped: boolean; advanceBy: number} | null {
const bracketResult = findClosingBracket(text);
if (!bracketResult) return null;
const {bracketPosition, linkText} = bracketResult;
if (bracketPosition + 1 >= text.length || text.charCodeAt(bracketPosition + 1) !== OPEN_PAREN) return null;
const trimmedLinkText = linkText.trim();
if (containsLinkSyntax(trimmedLinkText)) {
return null;
}
const isEmailSpoofing = EMAIL_PATTERN.test(trimmedLinkText);
const isSpoofedLink = SPOOFED_LINK_PATTERN.test(text);
if (isEmailSpoofing || isSpoofedLink) {
return null;
}
const urlInfo = extractUrl(text, bracketPosition + 2);
if (!urlInfo) return null;
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
return null;
}
const isUrlOrDomainLike = URL_DOMAIN_PATTERN.test(trimmedLinkText);
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
if (isLinkTextUrlWithProtocol && isUrlOrDomainLike) {
try {
const textDomain = extractDomainFromString(trimmedLinkText);
const urlDomain = extractDomainFromString(urlInfo.url);
if (!textDomain || (urlDomain && textDomain !== urlDomain)) {
return null;
}
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
return null;
}
} catch {
return null;
}
}
return {
linkText,
...urlInfo,
};
}
function findClosingBracket(text: string): {bracketPosition: number; linkText: string} | null {
let position = 1;
let nestedBrackets = 0;
const textLength = text.length;
while (position < textLength) {
const currentChar = text.charCodeAt(position);
if (currentChar === OPEN_BRACKET) {
nestedBrackets++;
position++;
} else if (currentChar === CLOSE_BRACKET) {
if (nestedBrackets > 0) {
nestedBrackets--;
position++;
} else {
return {
bracketPosition: position,
linkText: text.slice(1, position),
};
}
} else if (currentChar === BACKSLASH && position + 1 < textLength) {
position += 2;
} else {
position++;
}
if (position > MAX_LINK_URL_LENGTH) break;
}
return null;
}
function extractUrl(text: string, startPos: number): {url: string; isEscaped: boolean; advanceBy: number} | null {
if (startPos >= text.length) return null;
return text.charCodeAt(startPos) === LESS_THAN
? extractEscapedUrl(text, startPos + 1)
: extractUnescapedUrl(text, startPos);
}
function extractEscapedUrl(
text: string,
urlStart: number,
): {url: string; isEscaped: boolean; advanceBy: number} | null {
const textLength = text.length;
let currentPos = urlStart;
while (currentPos < textLength) {
if (text.charCodeAt(currentPos) === GREATER_THAN) {
const url = text.slice(urlStart, currentPos);
currentPos++;
while (currentPos < textLength && text.charCodeAt(currentPos) !== CLOSE_PAREN) {
currentPos++;
}
return {
url,
isEscaped: true,
advanceBy: currentPos + 1,
};
}
currentPos++;
}
return null;
}
function extractUnescapedUrl(
text: string,
urlStart: number,
): {url: string; isEscaped: boolean; advanceBy: number} | null {
const textLength = text.length;
let currentPos = urlStart;
let nestedParens = 0;
while (currentPos < textLength) {
const currentChar = text.charCodeAt(currentPos);
if (currentChar === OPEN_PAREN) {
nestedParens++;
currentPos++;
} else if (currentChar === CLOSE_PAREN) {
if (nestedParens > 0) {
nestedParens--;
currentPos++;
} else {
const url = text.slice(urlStart, currentPos);
return {
url,
isEscaped: false,
advanceBy: currentPos + 1,
};
}
} else {
currentPos++;
}
}
return null;
}
export function extractUrlSegment(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
let prefixLength = 0;
if (text.startsWith('https://')) {
prefixLength = 8;
} else if (text.startsWith('http://')) {
prefixLength = 7;
} else if (text.startsWith(APP_PROTOCOL_PREFIX)) {
prefixLength = APP_PROTOCOL_PREFIX.length;
} else {
return null;
}
let end = prefixLength;
const textLength = text.length;
while (end < textLength && !StringUtils.isUrlTerminationChar(text[end])) {
end++;
if (end - prefixLength > MAX_LINK_URL_LENGTH) break;
}
let urlString = text.slice(0, end);
const punctuation = '.,;:!?';
while (
urlString.length > 0 &&
punctuation.includes(urlString[urlString.length - 1]) &&
!urlString.match(/\.[a-zA-Z]{2,}$/)
) {
urlString = urlString.slice(0, -1);
end--;
}
const isInQuotes =
text.charAt(0) === '"' ||
text.charAt(0) === "'" ||
(end < textLength && (text.charAt(end) === '"' || text.charAt(end) === "'"));
try {
const normalizedUrl = URLUtils.normalizeUrl(urlString);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
return null;
}
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
return {
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: isInQuotes},
advance: urlString.length,
};
}
} catch (_e) {}
return null;
}
export function parseAutolink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
if (text.length > 1 && (text.charCodeAt(1) === DOUBLE_QUOTE || text.charCodeAt(1) === SINGLE_QUOTE)) {
return null;
}
if (!StringUtils.startsWithUrl(text.slice(1))) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const urlString = text.slice(1, end);
if (urlString.length > MAX_LINK_URL_LENGTH) return null;
try {
const normalizedUrl = URLUtils.normalizeUrl(urlString);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
return null;
}
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
return {
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: true},
advance: end + 1,
};
}
} catch (_e) {}
return null;
}
export function parseEmailLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
if (content.startsWith('http://') || content.startsWith('https://')) return null;
if (content.charCodeAt(0) === PLUS_SIGN) return null;
if (content.indexOf('@') === -1) return null;
const isValid = URLUtils.isValidEmail(content);
if (isValid) {
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: content},
url: `mailto:${content}`,
escaped: true,
},
advance: end + 1,
};
}
return null;
}
export function parsePhoneLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
if (content.charCodeAt(0) !== PLUS_SIGN) return null;
const isValid = URLUtils.isValidPhoneNumber(content);
if (isValid) {
const normalizedPhone = URLUtils.normalizePhoneNumber(content);
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: content},
url: `tel:${normalizedPhone}`,
escaped: true,
},
advance: end + 1,
};
}
return null;
}
export function parseSmsLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
if (!text.startsWith('<sms:')) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
const phoneNumber = content.slice(4);
if (phoneNumber.charCodeAt(0) !== PLUS_SIGN || !URLUtils.isValidPhoneNumber(phoneNumber)) {
return null;
}
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: phoneNumber},
url: `sms:${normalizedPhone}`,
escaped: true,
},
advance: end + 1,
};
}
function extractDomainFromString(input: string): string {
if (!input) return '';
try {
const normalized = input.normalize('NFKC').trim();
if (!normalized) {
return '';
}
let urlCandidate = normalized;
if (!normalized.includes('://')) {
urlCandidate = normalized.startsWith('//') ? `https:${normalized}` : `https://${normalized}`;
}
try {
const url = new URL(urlCandidate);
return url.hostname.toLowerCase();
} catch {
const match = urlCandidate.match(/^(?:https?:\/\/)([^/?#]+)/i);
if (match?.[1]) {
return match[1].toLowerCase();
}
return '';
}
} catch {
return '';
}
}
function shouldTreatAsMaskedLink(trimmedLinkText: string, url: string): boolean {
const normalizedText = trimmedLinkText.trim();
try {
const normalizedUrl = URLUtils.normalizeUrl(url);
const urlObj = new URL(normalizedUrl);
const textUrl = new URL(normalizedText);
if (
urlObj.origin === textUrl.origin &&
urlObj.pathname === textUrl.pathname &&
urlObj.search === textUrl.search &&
urlObj.hash === textUrl.hash
) {
return false;
}
} catch {}
return true;
}

View File

@@ -0,0 +1,658 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {NodeType, ParserFlags} from '../types/enums';
import type {CodeBlockNode, ListNode, TextNode} from '../types/nodes';
describe('Fluxer Markdown Parser - Lists', () => {
describe('Basic list functionality', () => {
test('unordered list', () => {
const input = '- Item 1\n- Item 2\n- Item 3';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}]},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
{children: [{type: NodeType.Text, content: 'Item 3'}]},
],
});
});
test('ordered list', () => {
const input = '1. Item 1\n2. Item 2\n3. Item 3';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'Item 2'}], ordinal: 2},
{children: [{type: NodeType.Text, content: 'Item 3'}], ordinal: 3},
],
});
});
test('mixed list types should create separate lists', () => {
const input = '1. First\n- Unordered\n2. Second';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.List);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [{children: [{type: NodeType.Text, content: 'First'}], ordinal: 1}],
});
expect(ast[1].type).toBe(NodeType.List);
expect(ast[1]).toEqual({
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content: 'Unordered'}]}],
});
expect(ast[2].type).toBe(NodeType.List);
expect(ast[2]).toEqual({
type: NodeType.List,
ordered: true,
items: [{children: [{type: NodeType.Text, content: 'Second'}], ordinal: 2}],
});
});
test('list with asterisks', () => {
const input = '* Item 1\n* Item 2\n* Item 3';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}]},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
{children: [{type: NodeType.Text, content: 'Item 3'}]},
],
});
});
});
describe('Custom ordering in lists', () => {
test('custom ordered list numbering', () => {
const input = '1. First item\n3. Third item\n5. Fifth item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 3},
{children: [{type: NodeType.Text, content: 'Fifth item'}], ordinal: 5},
],
});
});
test('list with all same number', () => {
const input = '1. Item one\n1. Item two\n1. Item three';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [
{children: [{type: NodeType.Text, content: 'Item one'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'Item two'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'Item three'}], ordinal: 1},
],
});
});
test('list starting with non-1', () => {
const input = '5. First item\n6. Second item\n7. Third item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 5},
{children: [{type: NodeType.Text, content: 'Second item'}], ordinal: 6},
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 7},
],
});
});
test('mixed pattern ordered list', () => {
const input = '1. a\n1. b\n3. c\n4. d';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0]).toEqual({
type: NodeType.List,
ordered: true,
items: [
{children: [{type: NodeType.Text, content: 'a'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'b'}], ordinal: 1},
{children: [{type: NodeType.Text, content: 'c'}], ordinal: 3},
{children: [{type: NodeType.Text, content: 'd'}], ordinal: 4},
],
});
});
});
describe('Nested lists', () => {
test('simple nested list', () => {
const input = '- Parent 1\n - Child 1\n - Child 2\n- Parent 2';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.items.length).toBe(2);
expect(listNode.items[0].children.length).toBe(2);
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
const nestedList = listNode.items[0].children[1] as ListNode;
expect(nestedList.items.length).toBe(2);
expect(listNode.items[1].children.length).toBe(1);
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
});
test('ordered list with nested unordered list', () => {
const input = '1. First item\n - Nested unordered 1\n - Nested unordered 2\n2. Second item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.ordered).toBe(true);
expect(listNode.items.length).toBe(2);
expect(listNode.items[0].children.length).toBe(2);
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
expect(listNode.items[0].ordinal).toBe(1);
const nestedList = listNode.items[0].children[1] as ListNode;
expect(nestedList.ordered).toBe(false);
expect(listNode.items[1].children.length).toBe(1);
expect(listNode.items[1].ordinal).toBe(2);
});
test('unordered list with nested ordered list', () => {
const input = '- First item\n 1. Nested ordered 1\n 2. Nested ordered 2\n- Second item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.ordered).toBe(false);
const nestedList = listNode.items[0].children[1] as ListNode;
expect(nestedList.ordered).toBe(true);
expect(nestedList.items[0].ordinal).toBe(1);
expect(nestedList.items[1].ordinal).toBe(2);
});
test('multi-level nesting', () => {
const input =
'1. Level 1\n - Level 2\n - Level 3\n 1. Level 4\n - Back to level 2\n2. Back to level 1';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.items[0].ordinal).toBe(1);
const level2List = listNode.items[0].children[1] as ListNode;
expect(level2List.ordered).toBe(false);
const level3List = level2List.items[0].children[1] as ListNode;
expect(level3List.ordered).toBe(false);
const level4List = level3List.items[0].children[1] as ListNode;
expect(level4List.ordered).toBe(true);
expect(level4List.items[0].ordinal).toBe(1);
expect(listNode.items[1].ordinal).toBe(2);
});
});
describe('Lists with other content', () => {
test('list with formatted text', () => {
const input = '- **Bold item**\n- *Italic item*\n- `Code item`';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
expect(listNode.items[2].children[0].type).toBe(NodeType.InlineCode);
});
test('list with blank lines between paragraphs', () => {
const input = '1. First paragraph\n\n Second paragraph\n2. Another item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.List);
const firstList = ast[0] as ListNode;
expect(firstList.items.length).toBe(1);
expect(firstList.items[0].children[0].type).toBe(NodeType.Text);
expect((firstList.items[0].children[0] as TextNode).content).toBe('First paragraph');
expect(ast[1].type).toBe(NodeType.Text);
expect(ast[2].type).toBe(NodeType.List);
const secondList = ast[2] as ListNode;
expect(secondList.items.length).toBe(1);
expect(secondList.items[0].children[0].type).toBe(NodeType.Text);
expect((secondList.items[0].children[0] as TextNode).content).toBe('Another item');
});
test('list before and after paragraph', () => {
const input = '- List item 1\n- List item 2\n\nParagraph text\n\n- List item 3\n- List item 4';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.List);
expect(ast[1].type).toBe(NodeType.Text);
expect(ast[2].type).toBe(NodeType.List);
expect((ast[1] as TextNode).content).toBe('\nParagraph text\n\n');
});
});
describe('Lists with code blocks', () => {
test('list with code block', () => {
const input =
'1. Item with code block:\n' +
' ```\n' +
' function example() {\n' +
' return "test";\n' +
' }\n' +
' ```\n' +
'2. Next item';
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.items.length).toBe(2);
expect(listNode.items[0].children.length).toBe(2);
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
expect(codeBlock.content).toContain('function example()');
expect(listNode.items[1].children.length).toBe(1);
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
});
test('code block with language specified', () => {
const input =
'- Item with JavaScript:\n' + ' ```javascript\n' + ' const x = 42;\n' + ' console.log(x);\n' + ' ```';
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
const listNode = ast[0] as ListNode;
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
expect(codeBlock.language).toBe('javascript');
expect(codeBlock.content).toContain('const x = 42;');
});
test('list with multiple code blocks', () => {
const input =
'1. First code block:\n' +
' ```\n' +
' Block 1\n' +
' ```\n' +
'2. Second code block:\n' +
' ```\n' +
' Block 2\n' +
' ```';
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
const listNode = ast[0] as ListNode;
expect(listNode.items.length).toBe(2);
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
expect(listNode.items[1].children[1].type).toBe(NodeType.CodeBlock);
const firstCodeBlock = listNode.items[0].children[1] as CodeBlockNode;
const secondCodeBlock = listNode.items[1].children[1] as CodeBlockNode;
expect(firstCodeBlock.content).toContain('Block 1');
expect(secondCodeBlock.content).toContain('Block 2');
});
});
describe('Edge cases and special scenarios', () => {
test('deeply nested list beyond max depth (9 levels)', () => {
const input =
'* fdf\n * dffsdf\n * dfsdfs\n * fdfsf\n * test\n * test2\n * test3\n * test4\n * test5';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.ordered).toBe(false);
expect(listNode.items.length).toBe(1);
let currentLevel: ListNode = listNode;
const expectedContents = ['fdf', 'dffsdf', 'dfsdfs', 'fdfsf', 'test', 'test2', 'test3', 'test4', 'test5'];
for (let i = 0; i < expectedContents.length; i++) {
expect(currentLevel.items.length).toBeGreaterThan(0);
const item = currentLevel.items[0];
expect(item.children.length).toBeGreaterThan(0);
const textNode = item.children[0] as TextNode;
expect(textNode.type).toBe(NodeType.Text);
expect(textNode.content).toBe(expectedContents[i]);
if (i < expectedContents.length - 1) {
expect(item.children.length).toBe(2);
expect(item.children[1].type).toBe(NodeType.List);
currentLevel = item.children[1] as ListNode;
} else {
expect(item.children.length).toBe(1);
}
}
});
test('list with invalid indentation', () => {
const input = '1. First\n - Invalid subitem with 1 space\n2. Second';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.items[0].children.length).toBe(2);
expect(listNode.items[0].children[1].type).toBe(NodeType.Text);
expect((listNode.items[0].children[1] as TextNode).content).toContain('Invalid subitem');
});
test('empty list items', () => {
const input = '- \n- Item 2\n- ';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.items.length).toBe(3);
expect(listNode.items[1].children.length).toBeGreaterThan(0);
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
expect((listNode.items[1].children[0] as TextNode).content).toBe('Item 2');
});
test('list with very large numbers', () => {
const input = '9999999. First item\n10000000. Second item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
const listNode = ast[0] as ListNode;
expect(listNode.items[0].ordinal).toBe(9999999);
expect(listNode.items[1].ordinal).toBe(10000000);
});
test('lists disabled by parser flags', () => {
const input = '- Item 1\n- Item 2';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.Text);
expect((ast[0] as TextNode).content).toBe('- Item 1- Item 2');
});
});
describe('Interaction with other block elements', () => {
test('list adjacent to heading', () => {
const input = '# Heading\n- List item\n## Next heading';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.Heading);
expect(ast[1].type).toBe(NodeType.List);
expect(ast[2].type).toBe(NodeType.Heading);
});
test('list adjacent to blockquote', () => {
const input = '> Blockquote\n- List item\n> Another blockquote';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.Blockquote);
expect(ast[1].type).toBe(NodeType.List);
expect(ast[2].type).toBe(NodeType.Blockquote);
});
test('nested list with blank lines between items', () => {
const input = '- Parent item\n\n - Child item 1\n\n - Child item 2';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBeGreaterThan(0);
expect(ast[0].type).toBe(NodeType.List);
});
test('empty list continuation handling', () => {
const input = '- Item 1\n Continuation text\n- Item 2';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.items).toHaveLength(2);
expect(listNode.items[0].children).toHaveLength(2);
expect(listNode.items[0].children[1]).toEqual({
type: NodeType.Text,
content: 'Continuation text',
});
});
test('list with multiple continuation lines', () => {
const input = '- Item 1\n Line 2\n Line 3\n- Item 2';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.items[0].children).toHaveLength(3);
});
test('deeply nested list structure', () => {
const input = '- Level 1\n - Level 2\n - Level 3\n Text continuation';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
});
test('list continuation with empty items array', () => {
const input = 'Some text\nContinuation line\n- Actual list item';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(2);
expect(ast[0].type).toBe(NodeType.Text);
expect(ast[1].type).toBe(NodeType.List);
});
test('invalid list marker patterns', () => {
const invalidPatterns = [
'1 Not a list item',
'1.Not a list item',
'- ',
'1. ',
' -Not a list',
' 1.Not a list',
];
for (const pattern of invalidPatterns) {
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(pattern, flags);
const {nodes: ast} = parser.parse();
if (pattern.trim() === '-' || pattern.trim() === '1.') {
continue;
}
expect(ast[0].type).toBe(NodeType.Text);
}
});
test('complex list with nested bullets and formatting', () => {
const input =
'1. **Bold list**\n2. *Italic list*\n3. - Nested bullet\n * **Bold nested**\n4. Text with\n line breaks \n **and bold**';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.List);
const listNode = ast[0] as ListNode;
expect(listNode.ordered).toBe(true);
expect(listNode.items.length).toBe(4);
expect(listNode.items[0].ordinal).toBe(1);
expect(listNode.items[0].children.length).toBe(1);
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
expect(listNode.items[1].ordinal).toBe(2);
expect(listNode.items[1].children.length).toBe(1);
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
expect(listNode.items[2].ordinal).toBe(3);
expect(listNode.items[2].children.length).toBe(1);
expect(listNode.items[2].children[0].type).toBe(NodeType.List);
const nestedList = listNode.items[2].children[0] as ListNode;
expect(nestedList.ordered).toBe(false);
expect(nestedList.items.length).toBe(2);
expect(nestedList.items[0].children.length).toBe(1);
expect(nestedList.items[0].children[0].type).toBe(NodeType.Text);
expect((nestedList.items[0].children[0] as TextNode).content).toBe('Nested bullet');
expect(nestedList.items[1].children.length).toBe(1);
expect(nestedList.items[1].children[0].type).toBe(NodeType.Strong);
expect(listNode.items[3].ordinal).toBe(4);
expect(listNode.items[3].children.length).toBeGreaterThan(1);
const item4Children = listNode.items[3].children;
expect(item4Children.some((child) => child.type === NodeType.Text)).toBe(true);
expect(item4Children.some((child) => child.type === NodeType.Strong)).toBe(true);
});
});
});

View File

@@ -0,0 +1,439 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MAX_AST_NODES} from '../types/constants';
import {NodeType} from '../types/enums';
import type {ListItem, ListNode, Node} from '../types/nodes';
import {parseCodeBlock} from './block-parsers';
interface ListParseResult {
node: ListNode;
newLineIndex: number;
newNodeCount: number;
}
export function parseList(
lines: Array<string>,
currentLineIndex: number,
isOrdered: boolean,
indentLevel: number,
depth: number,
parserFlags: number,
nodeCount: number,
parseInline: (text: string) => Array<Node>,
): ListParseResult {
const items: Array<ListItem> = [];
const startLine = currentLineIndex;
const firstLineContent = lines[startLine];
let newLineIndex = currentLineIndex;
let newNodeCount = nodeCount;
while (newLineIndex < lines.length) {
if (newNodeCount > MAX_AST_NODES) break;
const currentLine = lines[newLineIndex];
const trimmed = currentLine.trimStart();
if (isBlockBreak(trimmed)) break;
const listMatch = matchListItem(currentLine);
if (listMatch) {
const [itemOrdered, itemIndent, content, ordinal] = listMatch;
if (itemIndent < indentLevel) break;
if (itemIndent === indentLevel) {
if (itemOrdered !== isOrdered) {
if (newLineIndex === startLine) {
const simpleList = createSimpleList(firstLineContent);
return {
node: simpleList,
newLineIndex: newLineIndex + 1,
newNodeCount: newNodeCount + 1,
};
}
break;
}
const result = handleSameIndentLevel(
items,
content,
indentLevel,
depth,
parseInline,
(parentIndent, depth) => {
const tryResult = tryParseNestedContent(
lines,
newLineIndex + 1,
parentIndent,
depth,
(isOrdered, indentLevel, depth) =>
parseList(
lines,
newLineIndex + 1,
isOrdered,
indentLevel,
depth,
parserFlags,
newNodeCount,
parseInline,
),
);
return tryResult;
},
newLineIndex,
ordinal,
);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else if (itemIndent === indentLevel + 1) {
const result = handleNestedIndentLevel(
items,
currentLine,
itemOrdered,
itemIndent,
depth,
(isOrdered, indentLevel, depth) =>
parseList(lines, newLineIndex, isOrdered, indentLevel, depth, parserFlags, newNodeCount, parseInline),
newLineIndex,
newNodeCount,
);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else {
break;
}
} else if (isBulletPointText(currentLine)) {
const result = handleBulletPointText(items, currentLine, newLineIndex, newNodeCount);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else if (isListContinuation(currentLine, indentLevel)) {
const result = handleListContinuation(items, currentLine, newLineIndex, newNodeCount, parseInline);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else {
break;
}
if (items.length > MAX_AST_NODES) break;
}
if (items.length === 0 && newLineIndex === startLine) {
const simpleList = createSimpleList(firstLineContent);
return {
node: simpleList,
newLineIndex: newLineIndex + 1,
newNodeCount: newNodeCount + 1,
};
}
return {
node: {
type: NodeType.List,
ordered: isOrdered,
items,
},
newLineIndex,
newNodeCount,
};
}
function isBlockBreak(trimmed: string): boolean {
return trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('>>> ');
}
function createSimpleList(content: string): ListNode {
return {
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content}]}],
};
}
function handleSameIndentLevel(
items: Array<ListItem>,
content: string,
indentLevel: number,
depth: number,
parseInline: (text: string) => Array<Node>,
tryParseNestedContent: (parentIndent: number, depth: number) => {node: Node | null; newLineIndex: number},
currentLineIndex: number,
ordinal?: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
const itemNodes: Array<Node> = [];
let newNodeCount = 0;
let newLineIndex = currentLineIndex + 1;
const contentListMatch = matchListItem(content);
if (contentListMatch) {
const nestedContent = tryParseNestedContent(indentLevel, depth);
const [isInlineOrdered, _, inlineItemContent] = contentListMatch;
const inlineItemNodes = parseInline(inlineItemContent);
const nestedListItems: Array<ListItem> = [{children: inlineItemNodes}];
if (nestedContent.node && nestedContent.node.type === NodeType.List) {
const nestedList = nestedContent.node as ListNode;
nestedListItems.push(...nestedList.items);
newLineIndex = nestedContent.newLineIndex;
}
const nestedList: ListNode = {
type: NodeType.List,
ordered: isInlineOrdered,
items: nestedListItems,
};
itemNodes.push(nestedList);
newNodeCount++;
} else {
const parsedNodes = parseInline(content);
itemNodes.push(...parsedNodes);
newNodeCount = itemNodes.length;
const nestedContent = tryParseNestedContent(indentLevel, depth);
if (nestedContent.node) {
itemNodes.push(nestedContent.node);
newNodeCount++;
newLineIndex = nestedContent.newLineIndex;
}
}
items.push({
children: itemNodes,
...(ordinal !== undefined ? {ordinal} : {}),
});
return {
newItems: items,
newLineIndex,
newNodeCount,
};
}
function handleNestedIndentLevel(
items: Array<ListItem>,
currentLine: string,
isOrdered: boolean,
indentLevel: number,
depth: number,
parseList: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
currentLineIndex: number,
nodeCount: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (depth >= 9) {
if (items.length > 0) {
items[items.length - 1].children.push({
type: NodeType.Text,
content: currentLine.trim(),
});
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
const nested = parseList(isOrdered, indentLevel, depth + 1);
if (items.length > 0) {
items[items.length - 1].children.push(nested.node);
}
return {
newItems: items,
newLineIndex: nested.newLineIndex,
newNodeCount: nested.newNodeCount,
};
}
function handleBulletPointText(
items: Array<ListItem>,
currentLine: string,
currentLineIndex: number,
nodeCount: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (items.length > 0) {
items[items.length - 1].children.push({
type: NodeType.Text,
content: currentLine.trim(),
});
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
function handleListContinuation(
items: Array<ListItem>,
currentLine: string,
currentLineIndex: number,
nodeCount: number,
parseInline: (text: string) => Array<Node>,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (items.length > 0) {
const content = currentLine.trimStart();
const parsedNodes = parseInline(content);
items[items.length - 1].children.push(...parsedNodes);
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + parsedNodes.length,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
function tryParseNestedContent(
lines: Array<string>,
currentLineIndex: number,
parentIndent: number,
depth: number,
parseListFactory: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
): {node: Node | null; newLineIndex: number} {
if (currentLineIndex >= lines.length) return {node: null, newLineIndex: currentLineIndex};
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
const result = parseCodeBlock(lines, currentLineIndex);
return {
node: result.node,
newLineIndex: result.newLineIndex,
};
}
const listMatch = matchListItem(line);
if (listMatch) {
const [isOrdered, indent, _] = listMatch;
if (indent > parentIndent && depth < 9) {
const result = parseListFactory(isOrdered, indent, depth + 1);
return {
node: result.node,
newLineIndex: result.newLineIndex,
};
}
}
return {node: null, newLineIndex: currentLineIndex};
}
function isListContinuation(line: string, indentLevel: number): boolean {
let spaceCount = 0;
for (let i = 0; i < line.length; i++) {
if (line[i] === ' ') spaceCount++;
else break;
}
return spaceCount > indentLevel * 2;
}
function isBulletPointText(text: string): boolean {
const listMatch = matchListItem(text);
if (listMatch) return false;
const trimmed = text.trimStart();
return trimmed.startsWith('- ') && !text.startsWith(' ');
}
export function matchListItem(line: string): [boolean, number, string, number?] | null {
let indent = 0;
let pos = 0;
while (pos < line.length && line[pos] === ' ') {
indent++;
pos++;
}
if (indent > 0 && indent < 2) return null;
const indentLevel = Math.floor(indent / 2);
if (pos >= line.length) return null;
const marker = line[pos];
if (marker === '*' || marker === '-') {
return handleUnorderedListMarker(line, pos, indentLevel);
}
if (/[0-9]/.test(marker)) {
return handleOrderedListMarker(line, pos, indentLevel);
}
return null;
}
function handleUnorderedListMarker(
line: string,
pos: number,
indentLevel: number,
): [boolean, number, string, undefined] | null {
if (line[pos + 1] === ' ') {
return [false, indentLevel, line.slice(pos + 2), undefined];
}
return null;
}
function handleOrderedListMarker(
line: string,
pos: number,
indentLevel: number,
): [boolean, number, string, number] | null {
let currentPos = pos;
let ordinalStr = '';
while (currentPos < line.length && /[0-9]/.test(line[currentPos])) {
ordinalStr += line[currentPos];
currentPos++;
}
if (line[currentPos] === '.' && line[currentPos + 1] === ' ') {
const ordinal = Number.parseInt(ordinalStr, 10);
return [true, indentLevel, line.slice(currentPos + 2), ordinal];
}
return null;
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '../types/enums';
describe('Fluxer Markdown Parser', () => {
test('user mentions', () => {
const input = 'Hello <@1234567890> and <@!9876543210>';
const flags = ParserFlags.ALLOW_USER_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello '},
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '1234567890'}},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '9876543210'}},
]);
});
test('channel mention', () => {
const input = 'Please check <#103735883630395392>';
const flags = ParserFlags.ALLOW_CHANNEL_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Please check '},
{type: NodeType.Mention, kind: {kind: MentionKind.Channel, id: '103735883630395392'}},
]);
});
test('role mention', () => {
const input = 'This is for <@&165511591545143296>';
const flags = ParserFlags.ALLOW_ROLE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'This is for '},
{type: NodeType.Mention, kind: {kind: MentionKind.Role, id: '165511591545143296'}},
]);
});
test('slash command mention', () => {
const input = 'Use </airhorn:816437322781949972>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Use '},
{
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: 'airhorn',
subcommandGroup: undefined,
subcommand: undefined,
id: '816437322781949972',
},
},
]);
});
test('slash command with subcommands', () => {
const input = 'Try </app group sub:1234567890>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Try '},
{
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: 'app',
subcommandGroup: 'group',
subcommand: 'sub',
id: '1234567890',
},
},
]);
});
test('guild nav customize', () => {
const input = 'Go to <id:customize> now!';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Go to '},
{type: NodeType.Mention, kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.Customize}},
{type: NodeType.Text, content: ' now!'},
]);
});
test('guild nav linked roles', () => {
const input = 'Check <id:linked-roles:123456> settings';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Check '},
{
type: NodeType.Mention,
kind: {
kind: MentionKind.GuildNavigation,
navigationType: GuildNavKind.LinkedRoles,
id: '123456',
},
},
{type: NodeType.Text, content: ' settings'},
]);
});
test('invalid guild nav', () => {
const input = 'Invalid <12345:customize>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Invalid <12345:customize>'}]);
});
test('everyone and here mentions', () => {
const input = '@everyone and @here are both important.';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
{type: NodeType.Text, content: ' are both important.'},
]);
});
test('escaped everyone and here mentions', () => {
const input = '\\@everyone and \\@here should not be parsed.';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
});
test('mentions inside inline code', () => {
const input = '`@everyone` and `@here` should remain unchanged.';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.InlineCode, content: '@everyone'},
{type: NodeType.Text, content: ' and '},
{type: NodeType.InlineCode, content: '@here'},
{type: NodeType.Text, content: ' should remain unchanged.'},
]);
});
test('mentions inside code block', () => {
const input = '```\n@everyone\n@here\n```';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.CodeBlock,
language: undefined,
content: '@everyone\n@here\n',
},
]);
});
test('mentions with flags disabled', () => {
const input = '@everyone and @here should not be parsed.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
});
test('mentions followed by punctuation', () => {
const input = 'Hello @everyone! Are you there, @here?';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello '},
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
{type: NodeType.Text, content: '! Are you there, '},
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
{type: NodeType.Text, content: '?'},
]);
});
test('mentions adjacent to other symbols', () => {
const input = 'Check this out:@everyone@here!';
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Check this out:'},
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
{type: NodeType.Text, content: '!'},
]);
});
});

View File

@@ -0,0 +1,206 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '../types/enums';
import type {MentionNode, ParserResult} from '../types/nodes';
const LESS_THAN = 60;
const AT_SIGN = 64;
const HASH = 35;
const AMPERSAND = 38;
const SLASH = 47;
const LETTER_I = 105;
const LETTER_D = 100;
const COLON = 58;
const DIGIT_ZERO = 48;
const DIGIT_NINE = 57;
export function parseMention(text: string, parserFlags: number): ParserResult | null {
if (text.length < 2 || text.charCodeAt(0) !== LESS_THAN) {
return null;
}
const end = text.indexOf('>');
if (end === -1) {
return null;
}
const secondCharCode = text.charCodeAt(1);
let mentionNode: MentionNode | null = null;
if (secondCharCode === AT_SIGN) {
mentionNode = parseUserOrRoleMention(text.slice(1, end), parserFlags);
} else if (secondCharCode === HASH) {
mentionNode = parseChannelMention(text.slice(1, end), parserFlags);
} else if (secondCharCode === SLASH) {
mentionNode = parseCommandMention(text.slice(1, end), parserFlags);
} else if (
secondCharCode === LETTER_I &&
text.length > 3 &&
text.charCodeAt(2) === LETTER_D &&
text.charCodeAt(3) === COLON
) {
mentionNode = parseGuildNavigation(text.slice(1, end), parserFlags);
}
return mentionNode ? {node: mentionNode, advance: end + 1} : null;
}
function isDigitOnly(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < DIGIT_ZERO || charCode > DIGIT_NINE) {
return false;
}
}
return text.length > 0;
}
function parseUserOrRoleMention(inner: string, parserFlags: number): MentionNode | null {
if (inner.length < 2 || inner.charCodeAt(0) !== AT_SIGN) {
return null;
}
if (inner.length > 2 && inner.charCodeAt(1) === AMPERSAND) {
const roleId = inner.slice(2);
if (isDigitOnly(roleId) && parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.Role, id: roleId},
};
}
} else {
const userId = inner.startsWith('@!') ? inner.slice(2) : inner.slice(1);
if (isDigitOnly(userId) && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.User, id: userId},
};
}
}
return null;
}
function parseChannelMention(inner: string, parserFlags: number): MentionNode | null {
if (inner.length < 2 || inner.charCodeAt(0) !== HASH || !(parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS)) {
return null;
}
const channelId = inner.slice(1);
if (isDigitOnly(channelId)) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.Channel, id: channelId},
};
}
return null;
}
function parseCommandMention(inner: string, parserFlags: number): MentionNode | null {
if (!(parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) || inner.length < 2 || inner.charCodeAt(0) !== SLASH) {
return null;
}
const colonIndex = inner.indexOf(':');
if (colonIndex === -1) return null;
const commandPart = inner.slice(0, colonIndex);
const idPart = inner.slice(colonIndex + 1);
if (!idPart || !isDigitOnly(idPart)) return null;
const segments = commandPart.slice(1).trim().split(' ');
if (segments.length === 0) return null;
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: segments[0],
subcommandGroup: segments.length === 3 ? segments[1] : undefined,
subcommand: segments.length >= 2 ? segments[segments.length - 1] : undefined,
id: idPart,
},
};
}
function parseGuildNavigation(inner: string, parserFlags: number): MentionNode | null {
if (!(parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) || inner.length < 5) {
return null;
}
if (inner.charCodeAt(0) !== LETTER_I || inner.charCodeAt(1) !== LETTER_D || inner.charCodeAt(2) !== COLON) {
return null;
}
const parts = inner.split(':');
if (parts.length < 2 || parts.length > 3) return null;
const [idLabel, navType, navId] = parts;
if (idLabel !== 'id') return null;
const navigationType = getNavigationType(navType);
if (!navigationType) return null;
if (navigationType === GuildNavKind.LinkedRoles) {
return createLinkedRolesNavigation(parts.length === 3 ? navId : undefined);
}
if (parts.length !== 2) return null;
return createBasicNavigation(navigationType);
}
function getNavigationType(navTypeLower: string): GuildNavKind | null {
switch (navTypeLower) {
case 'customize':
return GuildNavKind.Customize;
case 'browse':
return GuildNavKind.Browse;
case 'guide':
return GuildNavKind.Guide;
case 'linked-roles':
return GuildNavKind.LinkedRoles;
default:
return null;
}
}
function createLinkedRolesNavigation(id?: string): MentionNode {
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.GuildNavigation,
navigationType: GuildNavKind.LinkedRoles,
id,
},
};
}
function createBasicNavigation(navigationType: GuildNavKind): MentionNode {
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.GuildNavigation,
navigationType,
},
};
}

View File

@@ -0,0 +1,306 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {NodeType, ParserFlags, TableAlignment} from '../types/enums';
import type {InlineCodeNode, TableNode, TextNode} from '../types/nodes';
describe('Fluxer Markdown Parser', () => {
describe('Table Parser', () => {
test('basic table', () => {
const input = `| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
expect(ast[0].type).toBe(NodeType.Table);
const tableNode = ast[0] as TableNode;
expect(tableNode.header.cells.length).toBe(2);
expect(tableNode.rows.length).toBe(2);
expect(tableNode.header.cells[0].children[0].type).toBe(NodeType.Text);
expect((tableNode.header.cells[0].children[0] as TextNode).content).toBe('Header 1');
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Text);
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('Cell 1');
});
test('table with alignments', () => {
const input = `| Left | Center | Right |
|:-----|:------:|------:|
| 1 | 2 | 3 |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.alignments).toEqual([TableAlignment.Left, TableAlignment.Center, TableAlignment.Right]);
});
test('table with escaped pipes', () => {
const input = `| Function | Integral |
|----------|----------|
| 1/x | ln\\|x\\| + C |
| tan x | -ln\\|cos x\\| + C |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
expect(ast[0].type).toBe(NodeType.Table);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows.length).toBe(2);
const cell1 = tableNode.rows[0].cells[1].children[0] as TextNode;
const cell2 = tableNode.rows[1].cells[1].children[0] as TextNode;
expect(cell1.content).toBe('ln|x| + C');
expect(cell2.content).toBe('-ln|cos x| + C');
});
test('table with formatted content', () => {
const input = `| Formatting | Example |
|------------|---------|
| **Bold** | *Italic* |
| ~~Strike~~ | \`Code\` |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Strong);
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.Emphasis);
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strikethrough);
expect(tableNode.rows[1].cells[1].children[0].type).toBe(NodeType.InlineCode);
});
test('multi-row integral table with escaped pipes', () => {
const input = `| Funktion | Integral |
|----------|----------|
| x^n (n ≠ -1) | x^(n+1)/(n+1) + C |
| 1/x | ln\\|x\\| + C |
| e^x | e^x + C |
| a^x | a^x/ln a + C |
| sin x | -cos x + C |
| cos x | sin x + C |
| tan x | -ln\\|cos x\\| + C |
| sec²x | tan x + C |
| 1/√(1-x²) | arcsin x + C |
| 1/(1+x²) | arctan x + C |`;
const flags = ParserFlags.ALLOW_TABLES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
expect(ast[0].type).toBe(NodeType.Table);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows.length).toBe(10);
const row3Cell = tableNode.rows[1].cells[1].children[0] as TextNode;
const row7Cell = tableNode.rows[6].cells[1].children[0] as TextNode;
expect(row3Cell.content).toBe('ln|x| + C');
expect(row7Cell.content).toBe('-ln|cos x| + C');
});
test('absolute value notation with escaped pipes', () => {
const input = `| Expression | Value |
|------------|-------|
| \\|x\\| | Absolute value of x |
| \\|\\|x\\|\\| | Double absolute value |
| \\|x + y\\| | Absolute value of sum |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows.length).toBe(3);
const cell1 = tableNode.rows[0].cells[0].children[0] as TextNode;
const cell2 = tableNode.rows[1].cells[0].children[0] as TextNode;
const cell3 = tableNode.rows[2].cells[0].children[0] as TextNode;
expect(cell1.content).toBe('|x|');
expect(cell2.content).toBe('||x||');
expect(cell3.content).toBe('|x + y|');
});
test('mixed mathematical notations with escaped pipes', () => {
const input = `| Function | Example |
|----------|---------|
| log | log\\|x\\| |
| ln | ln\\|x\\| |
| sin | sin(\\|x\\|) |
| abs | \\|f(x)\\| |
| complex | \\|x\\|² + \\|y\\|² |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows.length).toBe(5);
expect((tableNode.rows[0].cells[1].children[0] as TextNode).content).toBe('log|x|');
expect((tableNode.rows[1].cells[1].children[0] as TextNode).content).toBe('ln|x|');
expect((tableNode.rows[2].cells[1].children[0] as TextNode).content).toBe('sin(|x|)');
expect((tableNode.rows[3].cells[1].children[0] as TextNode).content).toBe('|f(x)|');
expect((tableNode.rows[4].cells[1].children[0] as TextNode).content).toBe('|x|² + |y|²');
});
test('set notation with escaped pipes', () => {
const input = `| Notation | Meaning |
|----------|---------|
| {x \\| x > 0} | Set of positive numbers |
| \\|{x \\| x > 0}\\| | Cardinality of positive numbers |
| A ∩ {x \\| \\|x\\| < 1} | Intersection with unit ball |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows.length).toBe(3);
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('{x | x > 0}');
expect((tableNode.rows[1].cells[0].children[0] as TextNode).content).toBe('|{x | x > 0}|');
expect((tableNode.rows[2].cells[0].children[0] as TextNode).content).toBe('A ∩ {x | |x| < 1}');
});
test('table with links and code', () => {
const input = `| Description | Example |
|-------------|---------|
| [Link](https://example.com) | \`code\` |
| **[Bold Link](https://example.org)** | *\`inline code\`* |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_MASKED_LINKS);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Link);
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.InlineCode);
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strong);
});
test('invalid table formats', () => {
const inputs = ['| Header |\n| Cell |', '| H1 |\n|====|\n| C1 |', '| |\n|--|\n| |'];
for (const input of inputs) {
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
const isTable = ast.some((node) => node.type === NodeType.Table);
expect(isTable).toBe(false);
}
});
test('table with empty cells', () => {
const input = `| H1 | | H3 |
|----|----|----|
| C1 | | C3 |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
expect(ast[0].type).toBe(NodeType.Table);
const tableNode = ast[0] as TableNode;
const emptyCell = tableNode.rows[0].cells[1].children[0] as TextNode;
expect(emptyCell.type).toBe(NodeType.Text);
expect(emptyCell.content).toBe('');
});
test('table with inconsistent column count', () => {
const input = `| A | B | C |
|---|---|---|
| 1 | 2 |
| 3 | 4 | 5 | 6 |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
expect(tableNode.rows[0].cells.length).toBe(3);
expect(tableNode.rows[1].cells.length).toBe(3);
const emptyCell = tableNode.rows[0].cells[2].children[0] as TextNode;
expect(emptyCell.content).toBe('');
const mergedCell = tableNode.rows[1].cells[2].children[0] as TextNode;
expect(mergedCell.content.includes('5')).toBe(true);
});
test('table with pipes in code examples', () => {
const input = `| Language | Pipe Example |
|----------|-------------|
| Bash | \`echo "hello" \\| grep "h"\` |
| JavaScript | \`const x = condition \\|\\| defaultValue;\` |
| C++ | \`if (a \\|\\| b && c) {...}\` |`;
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
const {nodes: ast} = parser.parse();
expect(ast.length).toBe(1);
const tableNode = ast[0] as TableNode;
const cell1 = tableNode.rows[0].cells[1].children[0] as InlineCodeNode;
const cell2 = tableNode.rows[1].cells[1].children[0] as InlineCodeNode;
const cell3 = tableNode.rows[2].cells[1].children[0] as InlineCodeNode;
expect(cell1.type).toBe(NodeType.InlineCode);
expect(cell2.type).toBe(NodeType.InlineCode);
expect(cell3.type).toBe(NodeType.InlineCode);
expect(cell1.content).toContain('echo "hello" | grep "h"');
expect(cell2.content).toContain('condition || defaultValue');
expect(cell3.content).toContain('if (a || b && c)');
});
test('tables disabled', () => {
const input = `| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |`;
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast.every((node) => node.type === NodeType.Text)).toBe(true);
});
});
});

View File

@@ -0,0 +1,329 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {NodeType, TableAlignment} from '../types/enums';
import type {Node, TableCellNode, TableNode, TableRowNode} from '../types/nodes';
interface TableParseResult {
node: TableNode | null;
newLineIndex: number;
}
const PIPE = 124;
const SPACE = 32;
const BACKSLASH = 92;
const DASH = 45;
const COLON = 58;
const HASH = 35;
const GREATER_THAN = 62;
const ASTERISK = 42;
const DIGIT_0 = 48;
const DIGIT_9 = 57;
const PERIOD = 46;
const MAX_CACHE_SIZE = 1000;
const inlineContentCache = new Map<string, Array<Node>>();
export function parseTable(
lines: Array<string>,
currentLineIndex: number,
_parserFlags: number,
parseInline: (text: string) => Array<Node>,
): TableParseResult {
const startIndex = currentLineIndex;
if (startIndex + 2 >= lines.length) {
return {node: null, newLineIndex: currentLineIndex};
}
const headerLine = lines[currentLineIndex];
const alignmentLine = lines[currentLineIndex + 1];
if (!containsPipe(headerLine) || !containsPipe(alignmentLine)) {
return {node: null, newLineIndex: currentLineIndex};
}
try {
const headerCells = fastSplitTableCells(headerLine.trim());
if (headerCells.length === 0 || !hasContent(headerCells)) {
return {node: null, newLineIndex: currentLineIndex};
}
const headerRow = createTableRow(headerCells, parseInline);
const columnCount = headerRow.cells.length;
currentLineIndex++;
const alignmentCells = fastSplitTableCells(alignmentLine.trim());
if (!validateAlignmentRow(alignmentCells)) {
return {node: null, newLineIndex: startIndex};
}
const alignments = parseAlignments(alignmentCells);
if (!alignments || headerRow.cells.length !== alignments.length) {
return {node: null, newLineIndex: startIndex};
}
currentLineIndex++;
const rows: Array<TableRowNode> = [];
while (currentLineIndex < lines.length) {
const line = lines[currentLineIndex];
if (!containsPipe(line)) break;
const trimmed = line.trim();
if (isBlockBreakFast(trimmed)) break;
const cellContents = fastSplitTableCells(trimmed);
if (cellContents.length !== columnCount) {
normalizeColumnCount(cellContents, columnCount);
}
const row = createTableRow(cellContents, parseInline);
rows.push(row);
currentLineIndex++;
}
if (rows.length === 0) {
return {node: null, newLineIndex: startIndex};
}
let hasAnyContent = hasRowContent(headerRow);
if (!hasAnyContent) {
for (const row of rows) {
if (hasRowContent(row)) {
hasAnyContent = true;
break;
}
}
}
if (!hasAnyContent) {
return {node: null, newLineIndex: startIndex};
}
if (inlineContentCache.size > MAX_CACHE_SIZE) {
inlineContentCache.clear();
}
return {
node: {
type: NodeType.Table,
header: headerRow,
alignments: alignments,
rows,
},
newLineIndex: currentLineIndex,
};
} catch (_err) {
return {node: null, newLineIndex: startIndex};
}
}
function containsPipe(text: string): boolean {
return text.indexOf('|') !== -1;
}
function hasContent(cells: Array<string>): boolean {
for (const cell of cells) {
if (cell.trim().length > 0) {
return true;
}
}
return false;
}
function hasRowContent(row: TableRowNode): boolean {
for (const cell of row.cells) {
if (
cell.children.length > 0 &&
!(cell.children.length === 1 && cell.children[0].type === NodeType.Text && cell.children[0].content.trim() === '')
) {
return true;
}
}
return false;
}
function validateAlignmentRow(cells: Array<string>): boolean {
if (cells.length === 0) return false;
for (const cell of cells) {
const trimmed = cell.trim();
if (trimmed.length === 0 || trimmed.indexOf('-') === -1) {
return false;
}
for (let i = 0; i < trimmed.length; i++) {
const charCode = trimmed.charCodeAt(i);
if (charCode !== SPACE && charCode !== COLON && charCode !== DASH && charCode !== PIPE) {
return false;
}
}
}
return true;
}
function fastSplitTableCells(line: string): Array<string> {
let start = 0;
let end = line.length;
if (line.length > 0 && line.charCodeAt(0) === PIPE) {
start = 1;
}
if (line.length > 0 && end > start && line.charCodeAt(end - 1) === PIPE) {
end--;
}
if (start >= end) {
return [];
}
const content = line.substring(start, end);
const cells: Array<string> = [];
let currentCell = '';
let i = 0;
while (i < content.length) {
if (content.charCodeAt(i) === BACKSLASH && i + 1 < content.length && content.charCodeAt(i + 1) === PIPE) {
currentCell += '|';
i += 2;
continue;
}
if (content.charCodeAt(i) === PIPE) {
cells.push(currentCell);
currentCell = '';
i++;
continue;
}
currentCell += content[i];
i++;
}
cells.push(currentCell);
return cells;
}
function parseAlignments(cells: Array<string>): Array<TableAlignment> | null {
if (cells.length === 0) return null;
const alignments: Array<TableAlignment> = [];
for (const cell of cells) {
const trimmed = cell.trim();
if (!trimmed || trimmed.indexOf('-') === -1) return null;
const left = trimmed.charCodeAt(0) === COLON;
const right = trimmed.charCodeAt(trimmed.length - 1) === COLON;
if (left && right) {
alignments.push(TableAlignment.Center);
} else if (left) {
alignments.push(TableAlignment.Left);
} else if (right) {
alignments.push(TableAlignment.Right);
} else {
alignments.push(TableAlignment.None);
}
}
return alignments;
}
function createTableRow(cellContents: Array<string>, parseInline: (text: string) => Array<Node>): TableRowNode {
const cells: Array<TableCellNode> = [];
for (const cellContent of cellContents) {
const trimmed = cellContent.trim();
let inlineNodes: Array<Node>;
if (inlineContentCache.has(trimmed)) {
inlineNodes = inlineContentCache.get(trimmed)!;
} else {
inlineNodes = parseInline(trimmed);
inlineContentCache.set(trimmed, inlineNodes);
}
cells.push({
type: NodeType.TableCell,
children: inlineNodes.length > 0 ? inlineNodes : [{type: NodeType.Text, content: trimmed}],
});
}
return {type: NodeType.TableRow, cells};
}
function normalizeColumnCount(cells: Array<string>, expectedColumns: number): void {
if (cells.length > expectedColumns) {
const lastCellIndex = expectedColumns - 1;
cells[lastCellIndex] = `${cells[lastCellIndex]}|${cells.slice(expectedColumns).join('|')}`;
cells.length = expectedColumns;
} else {
while (cells.length < expectedColumns) {
cells.push('');
}
}
}
function isBlockBreakFast(text: string): boolean {
if (!text || text.length === 0) return false;
const firstChar = text.charCodeAt(0);
if (firstChar === HASH || firstChar === GREATER_THAN || firstChar === DASH || firstChar === ASTERISK) {
return true;
}
if (
text.length >= 4 &&
text.charCodeAt(0) === GREATER_THAN &&
text.charCodeAt(1) === GREATER_THAN &&
text.charCodeAt(2) === GREATER_THAN &&
text.charCodeAt(3) === SPACE
) {
return true;
}
if (text.length >= 2 && text.charCodeAt(0) === DASH && text.charCodeAt(1) === HASH) {
return true;
}
if (firstChar >= DIGIT_0 && firstChar <= DIGIT_9) {
for (let i = 1; i < Math.min(text.length, 4); i++) {
if (text.charCodeAt(i) === PERIOD) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,298 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {Parser} from '../parser/parser';
import {EmojiKind, NodeType, ParserFlags, TimestampStyle} from '../types/enums';
describe('Fluxer Markdown Parser', () => {
test('timestamp default', () => {
const input = 'Current time: <t:1618953630>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Current time: '},
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDateTime,
},
]);
});
test('timestamp with style', () => {
const input = '<t:1618953630:d> is the date.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDate,
},
{type: NodeType.Text, content: ' is the date.'},
]);
});
test('timestamp with short date & short time style', () => {
const input = '<t:1618953630:s>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDateShortTime,
},
]);
});
test('timestamp with short date & medium time style', () => {
const input = '<t:1618953630:S>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDateMediumTime,
},
]);
});
test('timestamp invalid style', () => {
const input = 'Check this <t:1618953630:z> time.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Check this <t:1618953630:z> time.'}]);
});
test('timestamp non numeric', () => {
const input = 'Check <t:abc123> time.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Check <t:abc123> time.'}]);
});
test('timestamp with milliseconds should not parse', () => {
const input = 'Time: <t:1618953630.123>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'Time: <t:1618953630.123>'}]);
});
test('timestamp with partial milliseconds should not parse', () => {
const input = '<t:1618953630.1>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.1>'}]);
});
test('timestamp with excess millisecond precision should not parse', () => {
const input = '<t:1618953630.123456>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123456>'}]);
});
test('timestamp with milliseconds and style should not parse', () => {
const input = '<t:1618953630.123:R>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123:R>'}]);
});
test('timestamp in mixed content', () => {
const input = 'Hello <a:wave:12345> 🦶 <t:1618953630:d> <:smile:9876>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name: 'wave',
id: '12345',
animated: true,
},
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: '🦶',
codepoints: '1f9b6',
name: expect.any(String),
},
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDate,
},
{type: NodeType.Text, content: ' '},
{
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name: 'smile',
id: '9876',
animated: false,
},
},
]);
});
test('timestamp edge cases', () => {
const inputs = ['<t:>', '<t:1618953630:>', '<t::d>', '<t:1618953630::d>', '<t:1618953630:d:R>'];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
}
});
test('very large valid timestamp', () => {
const input = '<t:9999999999>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Timestamp,
timestamp: 9999999999,
style: TimestampStyle.ShortDateTime,
},
]);
});
test('timestamp with leading zeros', () => {
const input = '<t:0001618953630>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Timestamp,
timestamp: 1618953630,
style: TimestampStyle.ShortDateTime,
},
]);
});
test('timestamp in code block should not be parsed', () => {
const input = '```\n<t:1618953630>\n```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.CodeBlock,
language: undefined,
content: '<t:1618953630>\n',
},
]);
});
test('timestamp in inline code should not be parsed', () => {
const input = '`<t:1618953630>`';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.InlineCode,
content: '<t:1618953630>',
},
]);
});
test('timestamp with non-digit characters should not parse', () => {
const inputs = [
'<t:12a34>',
'<t:12.34>',
'<t:12,34>',
'<t:1234e5>',
'<t:+1234>',
'<t:-1234>',
'<t:1234 >',
'<t: 1234>',
'<t:1_234>',
'<t:0x1234>',
'<t:0b1010>',
'<t:123.456.789>',
];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
}
});
test('timestamp with valid integer formats', () => {
const inputs = ['<t:1234>', '<t:01234>', '<t:9999999999>'];
for (const input of inputs) {
const parser = new Parser(input, 0);
const {nodes: ast} = parser.parse();
expect(ast[0].type).toBe(NodeType.Timestamp);
}
});
test('timestamp with zero value should not parse', () => {
const parser = new Parser('<t:0>', 0);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<t:0>'}]);
});
});

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {NodeType, TimestampStyle} from '../types/enums';
import type {ParserResult} from '../types/nodes';
const LESS_THAN = 60;
const LETTER_T = 116;
const COLON = 58;
export function parseTimestamp(text: string): ParserResult | null {
if (
text.length < 4 ||
text.charCodeAt(0) !== LESS_THAN ||
text.charCodeAt(1) !== LETTER_T ||
text.charCodeAt(2) !== COLON
) {
return null;
}
const end = text.indexOf('>');
if (end === -1) {
return null;
}
const inner = text.slice(3, end);
const allParts = inner.split(':');
if (allParts.length > 2) {
return null;
}
const [timestampPart, stylePart] = allParts;
if (!/^\d+$/.test(timestampPart)) {
return null;
}
const timestamp = Number(timestampPart);
if (timestamp === 0) {
return null;
}
let style: TimestampStyle;
if (stylePart !== undefined) {
if (stylePart === '') {
return null;
}
const styleChar = stylePart[0];
const parsedStyle = getTimestampStyle(styleChar);
if (!parsedStyle) {
return null;
}
style = parsedStyle;
} else {
style = TimestampStyle.ShortDateTime;
}
return {
node: {
type: NodeType.Timestamp,
timestamp,
style,
},
advance: end + 1,
};
}
function getTimestampStyle(char: string): TimestampStyle | null {
switch (char) {
case 't':
return TimestampStyle.ShortTime;
case 'T':
return TimestampStyle.LongTime;
case 'd':
return TimestampStyle.ShortDate;
case 'D':
return TimestampStyle.LongDate;
case 'f':
return TimestampStyle.ShortDateTime;
case 'F':
return TimestampStyle.LongDateTime;
case 's':
return TimestampStyle.ShortDateShortTime;
case 'S':
return TimestampStyle.ShortDateMediumTime;
case 'R':
return TimestampStyle.RelativeTime;
default:
return null;
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const MAX_AST_NODES = 10000;
export const MAX_INLINE_DEPTH = 10;
export const MAX_LINES = 10000;
export const MAX_LINE_LENGTH = 4096;
export const MAX_LINK_URL_LENGTH = 2048;

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const ParserFlags = {
ALLOW_SPOILERS: 1 << 0,
ALLOW_HEADINGS: 1 << 1,
ALLOW_LISTS: 1 << 2,
ALLOW_CODE_BLOCKS: 1 << 3,
ALLOW_MASKED_LINKS: 1 << 4,
ALLOW_COMMAND_MENTIONS: 1 << 5,
ALLOW_GUILD_NAVIGATIONS: 1 << 6,
ALLOW_USER_MENTIONS: 1 << 7,
ALLOW_ROLE_MENTIONS: 1 << 8,
ALLOW_CHANNEL_MENTIONS: 1 << 9,
ALLOW_EVERYONE_MENTIONS: 1 << 10,
ALLOW_BLOCKQUOTES: 1 << 11,
ALLOW_MULTILINE_BLOCKQUOTES: 1 << 12,
ALLOW_SUBTEXT: 1 << 13,
ALLOW_TABLES: 1 << 14,
ALLOW_ALERTS: 1 << 15,
ALLOW_AUTOLINKS: 1 << 16,
} as const;
export type ParserFlags = (typeof ParserFlags)[keyof typeof ParserFlags];
export const NodeType = {
Text: 'Text',
Blockquote: 'Blockquote',
Strong: 'Strong',
Emphasis: 'Emphasis',
Underline: 'Underline',
Strikethrough: 'Strikethrough',
Spoiler: 'Spoiler',
Heading: 'Heading',
Subtext: 'Subtext',
List: 'List',
CodeBlock: 'CodeBlock',
InlineCode: 'InlineCode',
Sequence: 'Sequence',
Link: 'Link',
Mention: 'Mention',
Timestamp: 'Timestamp',
Emoji: 'Emoji',
Table: 'Table',
TableRow: 'TableRow',
TableCell: 'TableCell',
Alert: 'Alert',
} as const;
export type NodeType = (typeof NodeType)[keyof typeof NodeType];
export const AlertType = {
Note: 'Note',
Tip: 'Tip',
Important: 'Important',
Warning: 'Warning',
Caution: 'Caution',
} as const;
export type AlertType = (typeof AlertType)[keyof typeof AlertType];
export const TableAlignment = {
Left: 'Left',
Center: 'Center',
Right: 'Right',
None: 'None',
} as const;
export type TableAlignment = (typeof TableAlignment)[keyof typeof TableAlignment];
export const TimestampStyle = {
ShortTime: 'ShortTime',
LongTime: 'LongTime',
ShortDate: 'ShortDate',
LongDate: 'LongDate',
ShortDateTime: 'ShortDateTime',
LongDateTime: 'LongDateTime',
ShortDateShortTime: 'ShortDateShortTime',
ShortDateMediumTime: 'ShortDateMediumTime',
RelativeTime: 'RelativeTime',
} as const;
export type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle];
export const GuildNavKind = {
Customize: 'Customize',
Browse: 'Browse',
Guide: 'Guide',
LinkedRoles: 'LinkedRoles',
} as const;
export type GuildNavKind = (typeof GuildNavKind)[keyof typeof GuildNavKind];
export const MentionKind = {
User: 'User',
Channel: 'Channel',
Role: 'Role',
Command: 'Command',
GuildNavigation: 'GuildNavigation',
Everyone: 'Everyone',
Here: 'Here',
} as const;
export type MentionKind = (typeof MentionKind)[keyof typeof MentionKind];
export const EmojiKind = {
Standard: 'Standard',
Custom: 'Custom',
} as const;
export type EmojiKind = (typeof EmojiKind)[keyof typeof EmojiKind];

View File

@@ -0,0 +1,187 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AlertType, EmojiKind, GuildNavKind, MentionKind, NodeType, TableAlignment, TimestampStyle} from './enums';
interface BaseNode {
type: (typeof NodeType)[keyof typeof NodeType];
}
export interface TextNode extends BaseNode {
type: typeof NodeType.Text;
content: string;
}
export interface BlockquoteNode extends BaseNode {
type: typeof NodeType.Blockquote;
children: Array<Node>;
}
export interface FormattingNode extends BaseNode {
type:
| typeof NodeType.Strong
| typeof NodeType.Emphasis
| typeof NodeType.Underline
| typeof NodeType.Strikethrough
| typeof NodeType.Spoiler
| typeof NodeType.Sequence;
children: Array<Node>;
}
export interface HeadingNode extends BaseNode {
type: typeof NodeType.Heading;
level: number;
children: Array<Node>;
}
export interface SubtextNode extends BaseNode {
type: typeof NodeType.Subtext;
children: Array<Node>;
}
export interface ListNode extends BaseNode {
type: typeof NodeType.List;
ordered: boolean;
items: Array<ListItem>;
}
export interface ListItem {
children: Array<Node>;
ordinal?: number;
}
export interface CodeBlockNode extends BaseNode {
type: typeof NodeType.CodeBlock;
language?: string;
content: string;
}
export interface InlineCodeNode extends BaseNode {
type: typeof NodeType.InlineCode;
content: string;
}
export interface LinkNode extends BaseNode {
type: typeof NodeType.Link;
text?: Node;
url: string;
escaped: boolean;
}
export interface MentionNode extends BaseNode {
type: typeof NodeType.Mention;
kind: MentionType;
}
export interface TimestampNode extends BaseNode {
type: typeof NodeType.Timestamp;
timestamp: number;
style: (typeof TimestampStyle)[keyof typeof TimestampStyle];
}
export interface EmojiNode extends BaseNode {
type: typeof NodeType.Emoji;
kind: EmojiType;
}
export interface SequenceNode extends BaseNode {
type: typeof NodeType.Sequence;
children: Array<Node>;
}
export interface TableNode extends BaseNode {
type: typeof NodeType.Table;
header: TableRowNode;
alignments: Array<(typeof TableAlignment)[keyof typeof TableAlignment]>;
rows: Array<TableRowNode>;
}
export interface TableRowNode extends BaseNode {
type: typeof NodeType.TableRow;
cells: Array<TableCellNode>;
}
export interface TableCellNode extends BaseNode {
type: typeof NodeType.TableCell;
children: Array<Node>;
}
export interface AlertNode extends BaseNode {
type: typeof NodeType.Alert;
alertType: (typeof AlertType)[keyof typeof AlertType];
children: Array<Node>;
}
export interface SpoilerNode extends BaseNode {
type: typeof NodeType.Spoiler;
children: Array<Node>;
isBlock: boolean;
}
export type Node =
| TextNode
| BlockquoteNode
| FormattingNode
| HeadingNode
| SubtextNode
| ListNode
| CodeBlockNode
| InlineCodeNode
| LinkNode
| MentionNode
| TimestampNode
| EmojiNode
| SequenceNode
| TableNode
| TableRowNode
| TableCellNode
| AlertNode
| SpoilerNode;
type MentionType =
| {kind: typeof MentionKind.User; id: string}
| {kind: typeof MentionKind.Channel; id: string}
| {kind: typeof MentionKind.Role; id: string}
| {
kind: typeof MentionKind.Command;
name: string;
subcommandGroup?: string;
subcommand?: string;
id: string;
}
| {
kind: typeof MentionKind.GuildNavigation;
navigationType: typeof GuildNavKind.Customize | typeof GuildNavKind.Browse | typeof GuildNavKind.Guide;
}
| {
kind: typeof MentionKind.GuildNavigation;
navigationType: typeof GuildNavKind.LinkedRoles;
id?: string;
}
| {kind: typeof MentionKind.Everyone}
| {kind: typeof MentionKind.Here};
type EmojiType =
| {kind: typeof EmojiKind.Standard; raw: string; codepoints: string; name: string}
| {kind: typeof EmojiKind.Custom; name: string; id: string; animated: boolean};
export interface ParserResult {
node: Node;
advance: number;
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {NodeType} from '../types/enums';
import type {FormattingNode, Node, TextNode} from '../types/nodes';
import {addTextNode, combineAdjacentTextNodes, flattenSameType, isFormattingNode, mergeTextNodes} from './ast-utils';
describe('AST Utils', () => {
describe('isFormattingNode', () => {
test('should identify formatting nodes', () => {
const emphasisNode: FormattingNode = {
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'test'}],
};
const strongNode: FormattingNode = {
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'test'}],
};
const textNode: TextNode = {
type: NodeType.Text,
content: 'test',
};
expect(isFormattingNode(emphasisNode)).toBe(true);
expect(isFormattingNode(strongNode)).toBe(true);
expect(isFormattingNode(textNode)).toBe(false);
});
});
describe('flattenSameType', () => {
test('should flatten nodes of the same type', () => {
const children: Array<Node> = [
{type: NodeType.Text, content: 'first'},
{
type: NodeType.Emphasis,
children: [
{type: NodeType.Text, content: 'nested1'},
{type: NodeType.Text, content: 'nested2'},
],
},
{type: NodeType.Text, content: 'last'},
];
flattenSameType(children, NodeType.Emphasis);
expect(children).toHaveLength(4);
});
test('should handle empty children arrays', () => {
const children: Array<Node> = [];
flattenSameType(children, NodeType.Text);
expect(children).toEqual([]);
});
});
describe('combineAdjacentTextNodes', () => {
test('should combine adjacent text nodes', () => {
const nodes: Array<Node> = [
{type: NodeType.Text, content: 'first'},
{type: NodeType.Text, content: 'second'},
{type: NodeType.Text, content: 'third'},
];
combineAdjacentTextNodes(nodes);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'firstsecondthird'});
});
test('should not combine non-text nodes', () => {
const nodes: Array<Node> = [
{type: NodeType.Text, content: 'text'},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
{type: NodeType.Text, content: 'more text'},
];
combineAdjacentTextNodes(nodes);
expect(nodes).toHaveLength(3);
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'text'});
expect(nodes[1].type).toBe(NodeType.Emphasis);
expect(nodes[2]).toEqual({type: NodeType.Text, content: 'more text'});
});
});
describe('mergeTextNodes', () => {
test('should merge text nodes', () => {
const nodes: Array<Node> = [
{type: NodeType.Text, content: 'hello '},
{type: NodeType.Text, content: 'world'},
];
const result = mergeTextNodes(nodes);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({type: NodeType.Text, content: 'hello world'});
});
test('should handle mixed node types', () => {
const nodes: Array<Node> = [
{type: NodeType.Text, content: 'start'},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'middle'}]},
{type: NodeType.Text, content: 'end'},
];
const result = mergeTextNodes(nodes);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({type: NodeType.Text, content: 'start'});
expect(result[1].type).toBe(NodeType.Emphasis);
expect(result[2]).toEqual({type: NodeType.Text, content: 'end'});
});
test('should handle empty arrays', () => {
const result = mergeTextNodes([]);
expect(result).toEqual([]);
});
});
describe('addTextNode', () => {
test('should add text node to array', () => {
const nodes: Array<Node> = [];
addTextNode(nodes, 'test content');
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'test content'});
});
test('should handle empty text', () => {
const nodes: Array<Node> = [];
addTextNode(nodes, '');
expect(nodes).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,522 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {NodeType} from '../types/enums';
import type {
AlertNode,
BlockquoteNode,
FormattingNode,
HeadingNode,
LinkNode,
ListNode,
Node,
SequenceNode,
SubtextNode,
TableCellNode,
TableNode,
TableRowNode,
TextNode,
} from '../types/nodes';
const NT_TEXT = NodeType.Text;
const NT_STRONG = NodeType.Strong;
const NT_EMPHASIS = NodeType.Emphasis;
const NT_UNDERLINE = NodeType.Underline;
const NT_STRIKETHROUGH = NodeType.Strikethrough;
const NT_SPOILER = NodeType.Spoiler;
const NT_SEQUENCE = NodeType.Sequence;
const NT_HEADING = NodeType.Heading;
const NT_SUBTEXT = NodeType.Subtext;
const NT_BLOCKQUOTE = NodeType.Blockquote;
const NT_LIST = NodeType.List;
const NT_LINK = NodeType.Link;
const NT_TABLE = NodeType.Table;
const NT_TABLE_ROW = NodeType.TableRow;
const NT_TABLE_CELL = NodeType.TableCell;
const NT_ALERT = NodeType.Alert;
const FORMATTING_NODE_TYPES: Set<(typeof NodeType)[keyof typeof NodeType]> = new Set([
NT_STRONG,
NT_EMPHASIS,
NT_UNDERLINE,
NT_STRIKETHROUGH,
NT_SPOILER,
NT_SEQUENCE,
]);
export function flattenAST(nodes: Array<Node>): void {
const nodeCount = nodes.length;
if (nodeCount <= 1) {
return;
}
for (let i = 0; i < nodeCount; i++) {
flattenNode(nodes[i]);
}
flattenChildren(nodes, false);
}
function flattenNode(node: Node): void {
const nodeType = node.type;
if (nodeType === NT_TEXT) {
return;
}
if (FORMATTING_NODE_TYPES.has(nodeType)) {
const formattingNode = node as FormattingNode;
const children = formattingNode.children;
const childCount = children.length;
if (childCount === 0) {
return;
}
for (let i = 0; i < childCount; i++) {
flattenNode(children[i]);
}
flattenChildren(children, false);
return;
}
switch (nodeType) {
case NT_HEADING:
case NT_SUBTEXT: {
const typedNode = node as HeadingNode | SubtextNode;
const children = typedNode.children;
const childCount = children.length;
for (let i = 0; i < childCount; i++) {
flattenNode(children[i]);
}
flattenChildren(children, false);
break;
}
case NT_BLOCKQUOTE: {
const blockquoteNode = node as BlockquoteNode;
const children = blockquoteNode.children;
const childCount = children.length;
for (let i = 0; i < childCount; i++) {
flattenNode(children[i]);
}
flattenChildren(children, true);
break;
}
case NT_LIST: {
const listNode = node as ListNode;
const items = listNode.items;
const itemCount = items.length;
for (let i = 0; i < itemCount; i++) {
const item = items[i];
const itemChildren = item.children;
const itemChildCount = itemChildren.length;
for (let j = 0; j < itemChildCount; j++) {
flattenNode(itemChildren[j]);
}
flattenChildren(itemChildren, false);
}
break;
}
case NT_LINK: {
const linkNode = node as LinkNode;
const text = linkNode.text;
if (text) {
flattenNode(text);
if (text.type === NT_SEQUENCE) {
const sequenceNode = text as SequenceNode;
const seqChildren = sequenceNode.children;
const seqChildCount = seqChildren.length;
for (let i = 0; i < seqChildCount; i++) {
flattenNode(seqChildren[i]);
}
flattenChildren(seqChildren, false);
}
}
break;
}
case NT_TABLE: {
const tableNode = node as TableNode;
flattenTableRow(tableNode.header);
const rows = tableNode.rows;
const rowCount = rows.length;
for (let i = 0; i < rowCount; i++) {
flattenTableRow(rows[i]);
}
break;
}
case NT_TABLE_ROW:
flattenTableRow(node as TableRowNode);
break;
case NT_TABLE_CELL: {
const cellNode = node as TableCellNode;
const cellChildren = cellNode.children;
const cellChildCount = cellChildren.length;
for (let i = 0; i < cellChildCount; i++) {
flattenNode(cellChildren[i]);
}
flattenChildren(cellChildren, false);
break;
}
case NT_ALERT: {
const alertNode = node as AlertNode;
const alertChildren = alertNode.children;
const alertChildCount = alertChildren.length;
for (let i = 0; i < alertChildCount; i++) {
flattenNode(alertChildren[i]);
}
flattenChildren(alertChildren, false);
break;
}
}
}
function flattenTableRow(row: TableRowNode): void {
const cells = row.cells;
const cellCount = cells.length;
for (let i = 0; i < cellCount; i++) {
const cell = cells[i];
const cellChildren = cell.children;
const childCount = cellChildren.length;
if (childCount === 0) continue;
for (let j = 0; j < childCount; j++) {
flattenNode(cellChildren[j]);
}
flattenChildren(cellChildren, false);
}
}
export function flattenChildren(nodes: Array<Node>, insideBlockquote = false): void {
const nodeCount = nodes.length;
if (nodeCount <= 1) {
return;
}
flattenFormattingNodes(nodes);
combineAdjacentTextNodes(nodes, insideBlockquote);
removeEmptyTextNodesBetweenAlerts(nodes);
}
function flattenFormattingNodes(nodes: Array<Node>): void {
if (nodes.length <= 1) {
return;
}
let i = 0;
while (i < nodes.length) {
const node = nodes[i];
if (FORMATTING_NODE_TYPES.has(node.type)) {
const formattingNode = node as FormattingNode;
flattenSameType(formattingNode.children, node.type);
}
i++;
}
}
export function isFormattingNode(node: Node): boolean {
return FORMATTING_NODE_TYPES.has(node.type);
}
export function flattenSameType(children: Array<Node>, nodeType: NodeType): void {
if (children.length <= 1) {
return;
}
let needsFlattening = false;
for (let i = 0; i < children.length; i++) {
if (children[i].type === nodeType) {
needsFlattening = true;
break;
}
}
if (!needsFlattening) {
return;
}
let i = 0;
const result: Array<Node> = [];
while (i < children.length) {
const child = children[i];
if (child.type === nodeType && 'children' in child) {
const innerNodes = (child as FormattingNode).children;
for (let j = 0; j < innerNodes.length; j++) {
result.push(innerNodes[j]);
}
} else {
result.push(child);
}
i++;
}
children.length = 0;
for (let i = 0; i < result.length; i++) {
children.push(result[i]);
}
}
export function combineAdjacentTextNodes(nodes: Array<Node>, insideBlockquote = false): void {
const nodeCount = nodes.length;
if (nodeCount <= 1) {
return;
}
let hasAdjacentTextNodes = false;
let lastWasText = false;
for (let i = 0; i < nodeCount; i++) {
const isText = nodes[i].type === NT_TEXT;
if (isText && lastWasText) {
hasAdjacentTextNodes = true;
break;
}
lastWasText = isText;
}
if (!hasAdjacentTextNodes && !insideBlockquote) {
return;
}
const result: Array<Node> = [];
let currentText = '';
let nonTextNodeSeen = false;
if (insideBlockquote) {
for (let i = 0; i < nodeCount; i++) {
const node = nodes[i];
const isTextNode = node.type === NT_TEXT;
if (isTextNode) {
if (nonTextNodeSeen) {
if (currentText) {
result.push({type: NT_TEXT, content: currentText});
currentText = '';
}
nonTextNodeSeen = false;
}
currentText += (node as TextNode).content;
} else {
if (currentText) {
result.push({type: NT_TEXT, content: currentText});
currentText = '';
}
result.push(node);
nonTextNodeSeen = true;
}
}
if (currentText) {
result.push({type: NT_TEXT, content: currentText});
}
} else {
let currentTextNode: TextNode | null = null;
for (let i = 0; i < nodeCount; i++) {
const node = nodes[i];
if (node.type === NT_TEXT) {
const textNode = node as TextNode;
const content = textNode.content;
let isMalformedContent = false;
if (content && (content[0] === '#' || (content[0] === '-' && content.length > 1 && content[1] === '#'))) {
const trimmed = content.trim();
isMalformedContent = trimmed.startsWith('#') || trimmed.startsWith('-#');
}
if (isMalformedContent) {
if (currentTextNode) {
result.push(currentTextNode);
currentTextNode = null;
}
result.push({type: NT_TEXT, content});
} else if (currentTextNode) {
const hasDoubleNewline = content.includes('\n\n');
if (hasDoubleNewline) {
result.push(currentTextNode);
result.push({type: NT_TEXT, content});
currentTextNode = null;
} else {
currentTextNode.content += content;
}
} else {
currentTextNode = {type: NT_TEXT, content};
}
} else {
if (currentTextNode) {
result.push(currentTextNode);
currentTextNode = null;
}
result.push(node);
}
}
if (currentTextNode) {
result.push(currentTextNode);
}
}
nodes.length = 0;
for (let i = 0; i < result.length; i++) {
nodes.push(result[i]);
}
}
function removeEmptyTextNodesBetweenAlerts(nodes: Array<Node>): void {
const nodeCount = nodes.length;
if (nodeCount < 3) {
return;
}
let hasAlert = false;
let hasTextNode = false;
for (let i = 0; i < nodeCount; i++) {
const type = nodes[i].type;
hasAlert ||= type === NT_ALERT;
hasTextNode ||= type === NT_TEXT;
if (hasAlert && hasTextNode) break;
}
if (!hasAlert || !hasTextNode) {
return;
}
let emptyTextBetweenAlerts = false;
for (let i = 1; i < nodeCount - 1; i++) {
const current = nodes[i];
if (
current.type === NT_TEXT &&
nodes[i - 1].type === NT_ALERT &&
nodes[i + 1].type === NT_ALERT &&
(current as TextNode).content.trim() === ''
) {
emptyTextBetweenAlerts = true;
break;
}
}
if (!emptyTextBetweenAlerts) {
return;
}
const result: Array<Node> = [];
for (let i = 0; i < nodeCount; i++) {
const current = nodes[i];
if (
i > 0 &&
i < nodeCount - 1 &&
current.type === NT_TEXT &&
(current as TextNode).content.trim() === '' &&
nodes[i - 1].type === NT_ALERT &&
nodes[i + 1].type === NT_ALERT
) {
continue;
}
result.push(current);
}
nodes.length = 0;
for (let i = 0; i < result.length; i++) {
nodes.push(result[i]);
}
}
export function mergeTextNodes(nodes: Array<Node>): Array<Node> {
const nodeCount = nodes.length;
if (nodeCount <= 1) {
return nodes;
}
let hasConsecutiveTextNodes = false;
let prevWasText = false;
for (let i = 0; i < nodeCount; i++) {
const isText = nodes[i].type === NT_TEXT;
if (isText && prevWasText) {
hasConsecutiveTextNodes = true;
break;
}
prevWasText = isText;
}
if (!hasConsecutiveTextNodes) {
return nodes;
}
const mergedNodes: Array<Node> = [];
let currentText = '';
for (let i = 0; i < nodeCount; i++) {
const node = nodes[i];
if (node.type === NT_TEXT) {
currentText += (node as TextNode).content;
} else {
if (currentText) {
mergedNodes.push({type: NT_TEXT, content: currentText});
currentText = '';
}
mergedNodes.push(node);
}
}
if (currentText) {
mergedNodes.push({type: NT_TEXT, content: currentText});
}
return mergedNodes;
}
export function addTextNode(nodes: Array<Node>, text: string): void {
if (text && text.length > 0) {
nodes.push({type: NT_TEXT, content: text});
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {isAlphaNumericChar, matchMarker, startsWithUrl} from './string-utils';
describe('String Utils', () => {
describe('isAlphaNumericChar', () => {
test('should return true for alphanumeric characters', () => {
expect(isAlphaNumericChar('a')).toBe(true);
expect(isAlphaNumericChar('Z')).toBe(true);
expect(isAlphaNumericChar('5')).toBe(true);
expect(isAlphaNumericChar('0')).toBe(true);
});
test('should return false for non-alphanumeric characters', () => {
expect(isAlphaNumericChar('!')).toBe(false);
expect(isAlphaNumericChar(' ')).toBe(false);
expect(isAlphaNumericChar('@')).toBe(false);
expect(isAlphaNumericChar('-')).toBe(false);
});
test('should return false for multi-character strings', () => {
expect(isAlphaNumericChar('ab')).toBe(false);
expect(isAlphaNumericChar('12')).toBe(false);
expect(isAlphaNumericChar('')).toBe(false);
});
});
describe('startsWithUrl', () => {
test('should return true for valid HTTP URLs', () => {
expect(startsWithUrl('http://example.com')).toBe(true);
expect(startsWithUrl('http://sub.domain.com/path')).toBe(true);
});
test('should return true for valid HTTPS URLs', () => {
expect(startsWithUrl('https://example.com')).toBe(true);
expect(startsWithUrl('https://secure.site.org/page')).toBe(true);
});
test('should return false for URLs with quotes in protocol', () => {
expect(startsWithUrl('ht"tp://example.com')).toBe(false);
expect(startsWithUrl("htt'p://example.com")).toBe(false);
expect(startsWithUrl('https"://example.com')).toBe(false);
expect(startsWithUrl("http's://example.com")).toBe(false);
});
test('should return false for too short strings', () => {
expect(startsWithUrl('http')).toBe(false);
expect(startsWithUrl('https')).toBe(false);
expect(startsWithUrl('http:/')).toBe(false);
expect(startsWithUrl('')).toBe(false);
});
test('should return false for non-URL strings', () => {
expect(startsWithUrl('ftp://example.com')).toBe(false);
expect(startsWithUrl('mailto:test@example.com')).toBe(false);
expect(startsWithUrl('not a url')).toBe(false);
});
});
describe('matchMarker', () => {
test('should match single character markers', () => {
const chars = ['a', 'b', 'c', 'd'];
expect(matchMarker(chars, 0, 'a')).toBe(true);
expect(matchMarker(chars, 1, 'b')).toBe(true);
expect(matchMarker(chars, 0, 'x')).toBe(false);
});
test('should match two character markers', () => {
const chars = ['a', 'b', 'c', 'd'];
expect(matchMarker(chars, 0, 'ab')).toBe(true);
expect(matchMarker(chars, 1, 'bc')).toBe(true);
expect(matchMarker(chars, 0, 'ac')).toBe(false);
});
test('should match longer markers', () => {
const chars = ['h', 'e', 'l', 'l', 'o'];
expect(matchMarker(chars, 0, 'hello')).toBe(true);
expect(matchMarker(chars, 1, 'ello')).toBe(true);
expect(matchMarker(chars, 0, 'help')).toBe(false);
});
test('should return false when marker extends beyond array', () => {
const chars = ['a', 'b'];
expect(matchMarker(chars, 0, 'abc')).toBe(false);
expect(matchMarker(chars, 1, 'bc')).toBe(false);
expect(matchMarker(chars, 2, 'c')).toBe(false);
});
test('should handle empty marker', () => {
const chars = ['a', 'b', 'c'];
expect(matchMarker(chars, 0, '')).toBe(true);
expect(matchMarker(chars, 1, '')).toBe(true);
});
test('should handle edge positions', () => {
const chars = ['x', 'y', 'z'];
expect(matchMarker(chars, 0, 'x')).toBe(true);
expect(matchMarker(chars, 2, 'z')).toBe(true);
expect(matchMarker(chars, 3, 'a')).toBe(false);
});
});
});

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
const HTTP_PREFIX = 'http://';
const HTTPS_PREFIX = 'https://';
const WORD_CHARS = new Set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_');
const ESCAPABLE_CHARS = new Set('[]()\\*_~`@!#$%^&+={}|:;"\'<>,.?/');
const URL_TERMINATION_CHARS = new Set(' \t\n\r)\'"');
function isWordCharacter(char: string): boolean {
return char.length === 1 && WORD_CHARS.has(char);
}
export function isEscapableCharacter(char: string): boolean {
return char.length === 1 && ESCAPABLE_CHARS.has(char);
}
export function isUrlTerminationChar(char: string): boolean {
return char.length === 1 && URL_TERMINATION_CHARS.has(char);
}
export function isWordUnderscore(chars: Array<string>, pos: number): boolean {
if (chars[pos] !== '_') return false;
const prevChar = pos > 0 ? chars[pos - 1] : '';
const nextChar = pos + 1 < chars.length ? chars[pos + 1] : '';
return isWordCharacter(prevChar) && isWordCharacter(nextChar);
}
export function isAlphaNumeric(charCode: number): boolean {
return (
(charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)
);
}
export function isAlphaNumericChar(char: string): boolean {
return char.length === 1 && isAlphaNumeric(char.charCodeAt(0));
}
export function startsWithUrl(text: string): boolean {
if (text.length < 8) return false;
if (text.startsWith(HTTP_PREFIX)) {
const prefixEnd = 7;
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
}
if (text.startsWith(HTTPS_PREFIX)) {
const prefixEnd = 8;
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
}
if (text.startsWith(APP_PROTOCOL_PREFIX)) {
const prefixEnd = APP_PROTOCOL_PREFIX.length;
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
}
return false;
}
export function matchMarker(chars: Array<string>, pos: number, marker: string): boolean {
if (pos + marker.length > chars.length) return false;
if (marker.length === 1) {
return chars[pos] === marker;
}
if (marker.length === 2) {
return chars[pos] === marker[0] && chars[pos + 1] === marker[1];
}
for (let i = 0; i < marker.length; i++) {
if (chars[pos + i] !== marker[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as idna from 'idna-uts46-hx';
const HTTP_PROTOCOL = 'http:';
const HTTPS_PROTOCOL = 'https:';
const MAILTO_PROTOCOL = 'mailto:';
const TEL_PROTOCOL = 'tel:';
const SMS_PROTOCOL = 'sms:';
const FLUXER_PROTOCOL = 'fluxer:';
const EMAIL_REGEX =
/^[a-zA-Z0-9._%+-]+@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const PHONE_REGEX = /^\+[1-9][\d\s\-()]+$/;
const SPECIAL_PROTOCOLS_REGEX = /^(mailto:|tel:|sms:|fluxer:)/;
const PROTOCOL_REGEX = /:\/\//;
const TRAILING_SLASH_REGEX = /\/+$/;
const NORMALIZE_PHONE_REGEX = /[\s\-()]/g;
function createUrlObject(url: string): URL | null {
if (typeof url !== 'string') return null;
try {
if (!PROTOCOL_REGEX.test(url)) {
return null;
}
return new URL(url);
} catch {
return null;
}
}
export function isValidEmail(email: string): boolean {
return typeof email === 'string' && EMAIL_REGEX.test(email);
}
export function normalizePhoneNumber(phoneNumber: string): string {
return phoneNumber.replace(NORMALIZE_PHONE_REGEX, '');
}
export function isValidPhoneNumber(phoneNumber: string): boolean {
if (typeof phoneNumber !== 'string' || !PHONE_REGEX.test(phoneNumber)) return false;
return normalizePhoneNumber(phoneNumber).length >= 7;
}
export function normalizeUrl(url: string): string {
if (typeof url !== 'string') return url;
if (SPECIAL_PROTOCOLS_REGEX.test(url)) {
return url.replace(TRAILING_SLASH_REGEX, '');
}
const urlObj = createUrlObject(url);
return urlObj ? urlObj.toString() : url;
}
function idnaEncodeURL(url: string): string {
const urlObj = createUrlObject(url);
if (!urlObj) return url;
try {
urlObj.hostname = idna.toAscii(urlObj.hostname).toLowerCase();
urlObj.username = '';
urlObj.password = '';
return urlObj.toString();
} catch {
return url;
}
}
export function convertToAsciiUrl(url: string): string {
if (SPECIAL_PROTOCOLS_REGEX.test(url)) return url;
const urlObj = createUrlObject(url);
return urlObj ? idnaEncodeURL(url) : url;
}
export function isValidUrl(urlStr: string): boolean {
if (typeof urlStr !== 'string') return false;
if (SPECIAL_PROTOCOLS_REGEX.test(urlStr)) return true;
const urlObj = createUrlObject(urlStr);
if (!urlObj) return false;
const {protocol} = urlObj;
return (
protocol === HTTP_PROTOCOL ||
protocol === HTTPS_PROTOCOL ||
protocol === MAILTO_PROTOCOL ||
protocol === TEL_PROTOCOL ||
protocol === SMS_PROTOCOL ||
protocol === FLUXER_PROTOCOL
);
}

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import {Parser} from './parser/parser/parser';
import {EmojiKind, GuildNavKind, MentionKind, NodeType} from './parser/types/enums';
import type {
AlertNode,
BlockquoteNode,
CodeBlockNode,
EmojiNode,
FormattingNode,
HeadingNode,
InlineCodeNode,
LinkNode,
ListNode,
MentionNode,
Node,
SequenceNode,
SpoilerNode,
SubtextNode,
TableNode,
TextNode,
TimestampNode,
} from './parser/types/nodes';
import {formatTimestamp} from './utils/date-formatter';
interface PlaintextRenderOptions {
channelId?: string;
preserveMarkdown?: boolean;
includeEmojiNames?: boolean;
i18n?: I18n;
}
function renderNodeToPlaintext(node: Node, options: PlaintextRenderOptions = {}): string {
switch (node.type) {
case NodeType.Text:
return (node as TextNode).content;
case NodeType.Strong: {
const strongNode = node as FormattingNode;
const strongContent = renderNodesToPlaintext(strongNode.children, options);
return options.preserveMarkdown ? `**${strongContent}**` : strongContent;
}
case NodeType.Emphasis: {
const emphasisNode = node as FormattingNode;
const emphasisContent = renderNodesToPlaintext(emphasisNode.children, options);
return options.preserveMarkdown ? `*${emphasisContent}*` : emphasisContent;
}
case NodeType.Underline: {
const underlineNode = node as FormattingNode;
const underlineContent = renderNodesToPlaintext(underlineNode.children, options);
return options.preserveMarkdown ? `__${underlineContent}__` : underlineContent;
}
case NodeType.Strikethrough: {
const strikethroughNode = node as FormattingNode;
const strikethroughContent = renderNodesToPlaintext(strikethroughNode.children, options);
return options.preserveMarkdown ? `~~${strikethroughContent}~~` : strikethroughContent;
}
case NodeType.Spoiler: {
const spoilerNode = node as SpoilerNode;
const spoilerContent = renderNodesToPlaintext(spoilerNode.children, options);
return options.preserveMarkdown ? `||${spoilerContent}||` : spoilerContent;
}
case NodeType.Heading: {
const headingNode = node as HeadingNode;
const headingContent = renderNodesToPlaintext(headingNode.children, options);
const headingPrefix = options.preserveMarkdown ? `${'#'.repeat(headingNode.level)} ` : '';
return `${headingPrefix}${headingContent}`;
}
case NodeType.Subtext: {
const subtextNode = node as SubtextNode;
return renderNodesToPlaintext(subtextNode.children, options);
}
case NodeType.List: {
const listNode = node as ListNode;
return listNode.items
.map((item, index) => {
const content = renderNodesToPlaintext(item.children, options);
if (listNode.ordered) {
const ordinal = item.ordinal ?? index + 1;
return `${ordinal}. ${content}`;
}
return `${content}`;
})
.join('\n');
}
case NodeType.CodeBlock: {
const codeBlockNode = node as CodeBlockNode;
return options.preserveMarkdown
? `\`\`\`${codeBlockNode.language || ''}\n${codeBlockNode.content}\`\`\``
: codeBlockNode.content;
}
case NodeType.InlineCode: {
const inlineCodeNode = node as InlineCodeNode;
return options.preserveMarkdown ? `\`${inlineCodeNode.content}\`` : inlineCodeNode.content;
}
case NodeType.Link: {
const linkNode = node as LinkNode;
if (linkNode.text) {
const linkText = renderNodeToPlaintext(linkNode.text, options);
return options.preserveMarkdown ? `[${linkText}](${linkNode.url})` : linkText;
}
return linkNode.url;
}
case NodeType.Mention:
return renderMentionToPlaintext(node as MentionNode, options);
case NodeType.Timestamp: {
const timestampNode = node as TimestampNode;
return formatTimestamp(timestampNode.timestamp, timestampNode.style, options.i18n!);
}
case NodeType.Emoji:
return renderEmojiToPlaintext(node as EmojiNode, options);
case NodeType.Blockquote: {
const blockquoteNode = node as BlockquoteNode;
const blockquoteContent = renderNodesToPlaintext(blockquoteNode.children, options);
if (options.preserveMarkdown) {
return blockquoteContent
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
return blockquoteContent;
}
case NodeType.Sequence: {
const sequenceNode = node as SequenceNode;
return renderNodesToPlaintext(sequenceNode.children, options);
}
case NodeType.Table: {
const tableNode = node as TableNode;
const headerContent = tableNode.header.cells
.map((cell) => renderNodesToPlaintext(cell.children, options))
.join(' | ');
const rowsContent = tableNode.rows
.map((row) => row.cells.map((cell) => renderNodesToPlaintext(cell.children, options)).join(' | '))
.join('\n');
return `${headerContent}\n${rowsContent}`;
}
case NodeType.Alert: {
const alertNode = node as AlertNode;
const alertContent = renderNodesToPlaintext(alertNode.children, options);
const alertPrefix = `[${alertNode.alertType.toUpperCase()}] `;
return `${alertPrefix}${alertContent}`;
}
case NodeType.TableRow:
case NodeType.TableCell:
return '';
default: {
const nodeType = typeof (node as {type?: unknown}).type === 'string' ? (node as {type: string}).type : 'unknown';
console.warn(`Unknown node type for plaintext rendering: ${nodeType}`);
return '';
}
}
}
function renderMentionToPlaintext(node: MentionNode, options: PlaintextRenderOptions): string {
const {kind} = node;
const i18n = options.i18n!;
switch (kind.kind) {
case MentionKind.User: {
const user = UserStore.getUser(kind.id);
if (!user) {
return `@${kind.id}`;
}
let name = user.displayName;
if (options.channelId) {
const channel = ChannelStore.getChannel(options.channelId);
if (channel?.guildId) {
name = NicknameUtils.getNickname(user, channel.guildId) || name;
}
}
return `@${name}`;
}
case MentionKind.Role: {
const channel = options.channelId ? ChannelStore.getChannel(options.channelId) : null;
const guild = GuildStore.getGuild(channel?.guildId ?? '');
const role = guild ? guild.roles[kind.id] : null;
if (!role) {
return `@${i18n._(msg`unknown-role`)}`;
}
return `@${role.name}`;
}
case MentionKind.Channel: {
const channel = ChannelStore.getChannel(kind.id);
if (!channel || !TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
return `#${i18n._(msg`unknown-channel`)}`;
}
return `#${channel.name}`;
}
case MentionKind.Everyone:
return '@everyone';
case MentionKind.Here:
return '@here';
case MentionKind.Command: {
const {name, subcommandGroup, subcommand} = kind;
let commandName = `/${name}`;
if (subcommandGroup) {
commandName += ` ${subcommandGroup}`;
}
if (subcommand) {
commandName += ` ${subcommand}`;
}
return commandName;
}
case MentionKind.GuildNavigation: {
const {navigationType} = kind;
switch (navigationType) {
case GuildNavKind.Customize:
return '#customize';
case GuildNavKind.Browse:
return '#browse';
case GuildNavKind.Guide:
return '#guide';
case GuildNavKind.LinkedRoles: {
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
return linkedRolesId ? `#linked-roles:${linkedRolesId}` : '#linked-roles';
}
default:
return `#${navigationType}`;
}
}
default:
return `@${i18n._(msg`unknown-mention`)}`;
}
}
function renderEmojiToPlaintext(node: EmojiNode, options: PlaintextRenderOptions): string {
const {kind} = node;
if (kind.kind === EmojiKind.Standard) {
return kind.raw;
}
if (options.includeEmojiNames !== false) {
return `:${kind.name}:`;
}
return '';
}
function renderNodesToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
return nodes.map((node) => renderNodeToPlaintext(node, options)).join('');
}
function renderToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
const result = renderNodesToPlaintext(nodes, options);
return result
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function parseAndRenderToPlaintext(
content: string,
parserFlags: number,
options: PlaintextRenderOptions = {},
): string {
try {
const parser = new Parser(content, parserFlags);
const {nodes} = parser.parse();
return renderToPlaintext(nodes, options);
} catch (error) {
console.error('Error parsing content for plaintext rendering:', error);
return content;
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.jumpLinkButton {
border: 1px solid var(--markup-mention-border);
padding: 0.1rem 0.35rem;
margin: 0;
font: inherit;
display: inline-flex;
align-items: center;
line-height: 1;
vertical-align: middle;
background-color: var(--markup-jump-link-fill);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
}
.jumpLinkButton:hover {
background-color: var(--markup-jump-link-hover-fill);
}
.jumpLinkInfo {
display: inline-flex;
align-items: center;
gap: 0.2rem;
line-height: 1;
vertical-align: middle;
min-height: 1rem;
padding-bottom: 0.05rem;
}
.jumpLinkGuild {
display: inline-flex;
align-items: center;
gap: 0.2rem;
line-height: 1;
flex-shrink: 0;
transform: translateY(-0.12rem);
}
.jumpLinkGuildIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
line-height: 0;
}
.jumpLinkGuildIcon > svg,
.jumpLinkGuildIcon > img {
display: block;
}
.jumpLinkGuildName {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
line-height: 1;
vertical-align: middle;
}
.jumpLinkCaret {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
vertical-align: middle;
}
.jumpLinkLabel {
display: inline-flex;
align-items: center;
font-weight: 500;
white-space: nowrap;
line-height: 1;
transform: translateY(-0.12rem);
color: inherit;
}
.jumpLinkDM {
display: inline-flex;
align-items: center;
gap: 0.3rem;
line-height: 1;
color: inherit;
}
.jumpLinkDMName {
font-weight: 500;
color: inherit;
line-height: 1;
transform: translateY(-0.12rem);
}
.jumpLinkMessage {
display: inline-flex;
align-items: center;
gap: 0.1rem;
line-height: 1;
vertical-align: middle;
flex-shrink: 0;
}
.jumpLinkMessageIcon {
width: 0.9rem;
height: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
vertical-align: middle;
line-height: 0;
padding-bottom: 0.05rem;
}
.jumpLinkMessageIcon > svg {
display: block;
}
.jumpLinkChannel {
display: inline-flex;
align-items: center;
gap: 0.2rem;
line-height: 1;
}
.jumpLinkChannelIcon {
width: 0.9rem;
height: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
flex-shrink: 0;
}
.jumpLinkChannelIcon > svg {
display: block;
color: inherit;
}
.jumpLinkChannelName {
font-weight: 500;
color: inherit;
white-space: nowrap;
line-height: 1;
transform: translateY(-0.12rem);
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {
CircleWavyWarningIcon,
InfoIcon,
LightbulbFilamentIcon,
WarningCircleIcon,
WarningIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React, {useEffect, useRef} from 'react';
import markupStyles from '~/styles/Markup.module.css';
import {AlertType} from '../../parser/types/enums';
import type {
AlertNode,
BlockquoteNode,
HeadingNode,
ListItem,
ListNode,
SequenceNode,
SubtextNode,
TableNode,
} from '../../parser/types/nodes';
import {MarkdownContext, type RendererProps} from '..';
export const BlockquoteRenderer = observer(function BlockquoteRenderer({
node,
id,
renderChildren,
}: RendererProps<BlockquoteNode>): React.ReactElement {
return (
<div key={id} className={markupStyles.blockquoteContainer}>
<div className={markupStyles.blockquoteDivider} />
<blockquote className={markupStyles.blockquoteContent}>{renderChildren(node.children)}</blockquote>
</div>
);
});
export const ListRenderer = observer(function ListRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<ListNode>): React.ReactElement {
const Tag = node.ordered ? 'ol' : 'ul';
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
if (!node.ordered) {
return (
<Tag key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
{node.items.map((item, i) => (
<li key={`${id}-item-${i}`} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
{renderChildren(item.children)}
</li>
))}
</Tag>
);
}
const segments: Array<{startOrdinal: number; items: Array<ListItem>}> = [];
let currentSegment: Array<ListItem> = [];
let currentOrdinal = node.items[0]?.ordinal || 1;
node.items.forEach((item, i) => {
const itemOrdinal = item.ordinal !== undefined ? item.ordinal : i === 0 ? 1 : currentOrdinal + 1;
if (itemOrdinal !== currentOrdinal && i > 0) {
segments.push({
startOrdinal: currentSegment[0].ordinal || 1,
items: [...currentSegment],
});
currentSegment = [];
}
currentSegment.push({...item, ordinal: itemOrdinal});
currentOrdinal = itemOrdinal + 1;
});
if (currentSegment.length > 0) {
segments.push({
startOrdinal: currentSegment[0].ordinal || 1,
items: [...currentSegment],
});
}
return (
<React.Fragment key={id}>
{segments.map((segment, segmentIndex) => {
let maxDigits = 1;
if (node.items.length > 0) {
const largestNumber = Math.max(segment.startOrdinal, segment.startOrdinal + segment.items.length - 1);
maxDigits = String(largestNumber).length;
}
const listStyle = {
'--totalCharacters': maxDigits,
} as React.CSSProperties;
return (
<Tag
key={`${id}-segment-${segmentIndex}`}
className={isInlineContext ? markupStyles.inlineFormat : undefined}
start={segment.startOrdinal}
style={listStyle}
>
{segment.items.map((item, itemIndex) => (
<li
key={`${id}-segment-${segmentIndex}-item-${itemIndex}`}
className={clsx(isInlineContext && markupStyles.inlineFormat)}
>
{renderChildren(item.children)}
</li>
))}
</Tag>
);
})}
</React.Fragment>
);
});
export const HeadingRenderer = observer(function HeadingRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<HeadingNode>): React.ReactElement {
const Tag = `h${node.level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (headingRef.current && !isInlineContext && node.level <= 3) {
const headingId = headingRef.current.textContent
?.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
if (headingId) {
headingRef.current.id = headingId;
}
}
}, [isInlineContext, node.level]);
return (
<Tag ref={headingRef} key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
{renderChildren(node.children)}
</Tag>
);
});
export const SubtextRenderer = observer(function SubtextRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<SubtextNode>): React.ReactElement {
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
return (
<small key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
{renderChildren(node.children)}
</small>
);
});
export const SequenceRenderer = observer(function SequenceRenderer({
node,
id,
renderChildren,
}: RendererProps<SequenceNode>): React.ReactElement {
return <React.Fragment key={id}>{renderChildren(node.children)}</React.Fragment>;
});
export const TableRenderer = observer(function TableRenderer(_props: RendererProps<TableNode>): React.ReactElement {
throw new Error('unsupported');
});
export const AlertRenderer = observer(function AlertRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<AlertNode>): React.ReactElement {
const i18n = options.i18n!;
const alertConfig: Record<
AlertType,
{
Icon: React.ComponentType<{className?: string}>;
className: string;
title: string;
}
> = {
[AlertType.Note]: {Icon: InfoIcon, className: markupStyles.alertNote, title: i18n._(msg`Note`)},
[AlertType.Tip]: {Icon: LightbulbFilamentIcon, className: markupStyles.alertTip, title: i18n._(msg`Tip`)},
[AlertType.Important]: {Icon: WarningIcon, className: markupStyles.alertImportant, title: i18n._(msg`Important`)},
[AlertType.Warning]: {
Icon: CircleWavyWarningIcon,
className: markupStyles.alertWarning,
title: i18n._(msg`Warning`),
},
[AlertType.Caution]: {Icon: WarningCircleIcon, className: markupStyles.alertCaution, title: i18n._(msg`Caution`)},
};
const {Icon, className, title} = alertConfig[node.alertType] || alertConfig[AlertType.Note];
return (
<div key={id} className={clsx(markupStyles.alert, className)}>
<div className={markupStyles.alertTitle}>
<Icon className={markupStyles.alertIcon} />
{title}
</div>
<div className={markupStyles.alertContent}>{renderChildren(node.children)}</div>
</div>
);
});

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import highlight from 'highlight.js';
import katex from 'katex';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useState} from 'react';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import codeElementsStyles from '~/styles/CodeElements.module.css';
import markupStyles from '~/styles/Markup.module.css';
import type {CodeBlockNode, InlineCodeNode} from '../../parser/types/nodes';
import type {RendererProps} from '..';
export const CodeBlockRenderer = observer(function CodeBlockRenderer({
node,
id,
options,
}: RendererProps<CodeBlockNode>): React.ReactElement {
const i18n = options.i18n!;
const {content, language} = node;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = () => {
TextCopyActionCreators.copy(i18n, content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
const copyButton = (
<div className={markupStyles.codeActions}>
<button
type="button"
onClick={handleCopy}
aria-label={isCopied ? i18n._(msg`Copied!`) : i18n._(msg`Copy code`)}
className={clsx(isCopied && markupStyles.codeActionsVisible)}
>
{isCopied ? (
<CheckCircleIcon className={codeElementsStyles.icon} />
) : (
<ClipboardIcon className={codeElementsStyles.icon} />
)}
</button>
</div>
);
if (language?.toLowerCase() === 'latex' || language?.toLowerCase() === 'tex') {
try {
const html = katex.renderToString(content, {
displayMode: true,
throwOnError: false,
errorColor: 'var(--accent-danger)',
trust: false,
strict: false,
output: 'html',
});
return (
<div key={id} className={markupStyles.latexCodeBlock}>
<div className={markupStyles.codeContainer}>
{copyButton}
<div
className={markupStyles.latexContent}
// biome-ignore lint/security/noDangerouslySetInnerHtml: KaTeX output is sanitized
dangerouslySetInnerHTML={{__html: html}}
/>
</div>
</div>
);
} catch (error) {
console.error('KaTeX rendering error:', error);
return (
<div key={id} className={markupStyles.codeContainer}>
{copyButton}
<pre>
<code className={markupStyles.hljs}>
{i18n._(msg`Error rendering LaTeX: ${(error as Error).message || i18n._(msg`Unknown error`)}`)}
</code>
</pre>
</div>
);
}
}
let highlightedContent: React.ReactElement;
if (language && highlight.getLanguage(language)) {
try {
const highlighted = highlight.highlight(content, {
language: language,
ignoreIllegals: true,
});
highlightedContent = (
// biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js output is sanitized
<code className={clsx(markupStyles.hljs, language)} dangerouslySetInnerHTML={{__html: highlighted.value}} />
);
} catch (error) {
console.error('Syntax highlighting error:', error);
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
}
} else {
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
}
return (
<div key={id} className={markupStyles.codeContainer}>
{copyButton}
<pre>{highlightedContent}</pre>
</div>
);
});
export const InlineCodeRenderer = observer(function InlineCodeRenderer({
node,
id,
}: RendererProps<InlineCodeNode>): React.ReactElement {
const normalizedContent = node.content.replace(/\s+/g, ' ');
return (
<code key={id} className={markupStyles.inline}>
{normalizedContent}
</code>
);
});

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo} from 'react';
import markupStyles from '~/styles/Markup.module.css';
import {normalizeUrl, useSpoilerState} from '~/utils/SpoilerUtils';
import {NodeType} from '../../parser/types/enums';
import type {FormattingNode, Node} from '../../parser/types/nodes';
import type {RendererProps} from '..';
export const StrongRenderer = observer(function StrongRenderer({
node,
id,
renderChildren,
}: RendererProps<FormattingNode>): React.ReactElement {
return <strong key={id}>{renderChildren(node.children)}</strong>;
});
export const EmphasisRenderer = observer(function EmphasisRenderer({
node,
id,
renderChildren,
}: RendererProps<FormattingNode>): React.ReactElement {
return <em key={id}>{renderChildren(node.children)}</em>;
});
export const UnderlineRenderer = observer(function UnderlineRenderer({
node,
id,
renderChildren,
}: RendererProps<FormattingNode>): React.ReactElement {
return <u key={id}>{renderChildren(node.children)}</u>;
});
export const StrikethroughRenderer = observer(function StrikethroughRenderer({
node,
id,
renderChildren,
}: RendererProps<FormattingNode>): React.ReactElement {
return <s key={id}>{renderChildren(node.children)}</s>;
});
interface SpoilerNode extends FormattingNode {
type: typeof NodeType.Spoiler;
isBlock: boolean;
}
export const SpoilerRenderer = observer(function SpoilerRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<SpoilerNode>): React.ReactElement {
const i18n = options.i18n!;
const collectUrls = useCallback((nodes: Array<Node>): Array<string> => {
const urls: Array<string> = [];
for (const child of nodes) {
if (child.type === NodeType.Link) {
const normalized = normalizeUrl(child.url);
if (normalized) urls.push(normalized);
}
if ('children' in child && Array.isArray((child as {children?: Array<Node>}).children)) {
urls.push(...collectUrls((child as {children: Array<Node>}).children));
}
}
return urls;
}, []);
const spoilerUrls = useMemo(() => Array.from(new Set(collectUrls(node.children))), [collectUrls, node.children]);
const {hidden, reveal, autoRevealed} = useSpoilerState(true, options.channelId, spoilerUrls);
const handleClick = useCallback(() => {
if (hidden) {
reveal();
}
}, [hidden, reveal]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
},
[handleClick],
);
const isBlock = node.isBlock;
const wrapperClass = isBlock ? markupStyles.blockSpoilerWrapper : markupStyles.spoilerWrapper;
const spoilerClass = isBlock ? markupStyles.blockSpoiler : markupStyles.spoiler;
const shouldReveal = !hidden || autoRevealed;
return (
<span key={id} className={wrapperClass}>
{shouldReveal ? (
<span className={spoilerClass} data-revealed={shouldReveal}>
<span className={markupStyles.spoilerContent} aria-hidden={!shouldReveal}>
{renderChildren(node.children)}
</span>
</span>
) : (
<span
className={spoilerClass}
data-revealed={shouldReveal}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={i18n._(msg`Click to reveal spoiler`)}
>
<span className={markupStyles.spoilerContent} aria-hidden>
{renderChildren(node.children)}
</span>
</span>
)}
</span>
);
});

View File

@@ -0,0 +1,280 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {FloatingPortal} from '@floating-ui/react';
import {msg} from '@lingui/core/macro';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {EmojiInfoBottomSheet} from '~/components/bottomsheets/EmojiInfoBottomSheet';
import {EmojiInfoContent} from '~/components/emojis/EmojiInfoContent';
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useReactionTooltip} from '~/hooks/useReactionTooltip';
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import {EmojiKind} from '../parser/types/enums';
import type {EmojiNode} from '../parser/types/nodes';
import {getEmojiRenderData} from '../utils/emoji-detector';
import type {RendererProps} from '.';
interface EmojiBottomSheetState {
isOpen: boolean;
emoji: {id?: string; name: string; animated?: boolean} | null;
}
interface EmojiWithTooltipProps {
children: React.ReactElement<Record<string, unknown> & {ref?: React.Ref<HTMLElement>}>;
emojiUrl: string | null;
nativeEmoji?: React.ReactNode;
emojiName: string;
emojiForSubtext: Emoji;
}
const EmojiWithTooltip = observer(
({children, emojiUrl, nativeEmoji, emojiName, emojiForSubtext}: EmojiWithTooltipProps) => {
const tooltipPortalRoot = useTooltipPortalRoot();
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
const childRef = children.props.ref ?? null;
const mergedRef = useMergeRefs([targetRef, childRef]);
return (
<>
{React.cloneElement(children, {
ref: mergedRef,
...handlers,
} as Record<string, unknown>)}
{state.isOpen && (
<FloatingPortal root={tooltipPortalRoot}>
<AnimatePresence>
<motion.div
ref={(node) => {
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (node && targetRef.current) {
updatePosition();
}
}}
style={{
position: 'fixed',
left: state.x,
top: state.y,
zIndex: 'var(--z-index-tooltip)',
visibility: state.isReady ? 'visible' : 'hidden',
}}
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 0.98}}
transition={{
opacity: {duration: 0.1},
scale: {type: 'spring', damping: 25, stiffness: 500},
}}
{...tooltipHandlers}
>
<EmojiTooltipContent
emoji={nativeEmoji}
emojiUrl={emojiUrl}
emojiAlt={emojiName}
primaryContent={emojiName}
subtext={<EmojiInfoContent emoji={emojiForSubtext} />}
/>
</motion.div>
</AnimatePresence>
</FloatingPortal>
)}
</>
);
},
);
const EmojiRendererInner = observer(function EmojiRendererInner({
node,
id,
options,
}: RendererProps<EmojiNode>): React.ReactElement {
const {shouldJumboEmojis, guildId, messageId, disableAnimatedEmoji} = options;
const i18n = options.i18n!;
const emojiData = getEmojiRenderData(node, guildId, disableAnimatedEmoji);
const isMobile = MobileLayoutStore.enabled;
const [bottomSheetState, setBottomSheetState] = React.useState<EmojiBottomSheetState>({
isOpen: false,
emoji: null,
});
const className = clsx('emoji', shouldJumboEmojis && 'jumboable');
const size = shouldJumboEmojis ? 240 : 96;
const qualitySuffix = `?size=${size}&quality=lossless`;
const tooltipEmojiSize = 240;
const tooltipQualitySuffix = `?size=${tooltipEmojiSize}&quality=lossless`;
const isCustomEmoji = node.kind.kind === EmojiKind.Custom;
const emojiRecord = isCustomEmoji && emojiData.id ? EmojiStore.getEmojiById(emojiData.id) : null;
const handleOpenBottomSheet = React.useCallback(() => {
if (!isMobile) return;
const emojiInfo = {
name: node.kind.name,
id: isCustomEmoji ? (node.kind as {id: string}).id : undefined,
animated: isCustomEmoji ? (node.kind as {animated: boolean}).animated : false,
};
setBottomSheetState({isOpen: true, emoji: emojiInfo});
}, [isMobile, node.kind, isCustomEmoji]);
const handleCloseBottomSheet = React.useCallback(() => {
setBottomSheetState({isOpen: false, emoji: null});
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleOpenBottomSheet();
}
},
[handleOpenBottomSheet],
);
const buildEmojiForSubtext = React.useCallback((): Emoji => {
if (emojiRecord) {
return emojiRecord;
}
return {
name: node.kind.name,
allNamesString: node.kind.name,
uniqueName: node.kind.name,
};
}, [emojiRecord, node.kind.name]);
const getTooltipData = React.useCallback(() => {
const emojiUrl =
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard
? null
: `${emojiData.url}${emojiData.id ? tooltipQualitySuffix : ''}`;
const nativeEmoji =
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard ? (
<span className={clsx('emoji', 'jumboable')}>{node.kind.raw}</span>
) : undefined;
const emojiForSubtext = buildEmojiForSubtext();
return {emojiUrl, nativeEmoji, emojiForSubtext};
}, [emojiData, node.kind, buildEmojiForSubtext, tooltipQualitySuffix]);
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
const target = e.target as HTMLImageElement;
target.style.opacity = '0.5';
target.alt = `${emojiData.name} ${i18n._(msg`(failed to load)`)}`;
};
if (shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard) {
if (isMobile) {
return (
<>
<span
className={className}
data-message-id={messageId}
onClick={handleOpenBottomSheet}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
>
{node.kind.raw}
</span>
<EmojiInfoBottomSheet
isOpen={bottomSheetState.isOpen}
onClose={handleCloseBottomSheet}
emoji={bottomSheetState.emoji}
/>
</>
);
}
const tooltipData = getTooltipData();
return (
<EmojiWithTooltip
key={id}
emojiUrl={tooltipData.emojiUrl}
nativeEmoji={tooltipData.nativeEmoji}
emojiName={emojiData.name}
emojiForSubtext={tooltipData.emojiForSubtext}
>
<span className={className} data-message-id={messageId}>
{node.kind.raw}
</span>
</EmojiWithTooltip>
);
}
if (isMobile) {
return (
<>
<span onClick={handleOpenBottomSheet} onKeyDown={handleKeyDown} role="button" tabIndex={0}>
<img
draggable={false}
className={className}
alt={emojiData.name}
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
data-message-id={messageId}
data-emoji-id={emojiData.id}
data-animated={emojiData.isAnimated}
onError={handleImageError}
loading="lazy"
/>
</span>
<EmojiInfoBottomSheet
isOpen={bottomSheetState.isOpen}
onClose={handleCloseBottomSheet}
emoji={bottomSheetState.emoji}
/>
</>
);
}
const tooltipData = getTooltipData();
return (
<EmojiWithTooltip
key={id}
emojiUrl={tooltipData.emojiUrl}
nativeEmoji={tooltipData.nativeEmoji}
emojiName={emojiData.name}
emojiForSubtext={tooltipData.emojiForSubtext}
>
<img
draggable={false}
className={className}
alt={emojiData.name}
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
data-message-id={messageId}
data-emoji-id={emojiData.id}
data-animated={emojiData.isAnimated}
onError={handleImageError}
loading="lazy"
/>
</EmojiWithTooltip>
);
});
export const EmojiRenderer = EmojiRendererInner;

View File

@@ -0,0 +1,209 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {i18n} from '@lingui/core';
import React from 'react';
import markupStyles from '~/styles/Markup.module.css';
import {TABLE_PARSING_FLAG} from '../config';
import {Parser} from '../parser/parser/parser';
import {NodeType, ParserFlags} from '../parser/types/enums';
import type {Node} from '../parser/types/nodes';
import {shouldRenderJumboEmojis} from '../utils/jumbo-detector';
import {
AlertRenderer,
BlockquoteRenderer,
HeadingRenderer,
ListRenderer,
SequenceRenderer,
SubtextRenderer,
TableRenderer,
} from './common/block-elements';
import {CodeBlockRenderer, InlineCodeRenderer} from './common/code-elements';
import {
EmphasisRenderer,
SpoilerRenderer,
StrikethroughRenderer,
StrongRenderer,
UnderlineRenderer,
} from './common/formatting-elements';
import {EmojiRenderer} from './emoji-renderer';
import {LinkRenderer} from './link-renderer';
import {MentionRenderer} from './mention-renderer';
import {TextRenderer} from './text-renderer';
import {TimestampRenderer} from './timestamp-renderer';
export const MarkdownContext = {
STANDARD_WITH_JUMBO: 0,
RESTRICTED_INLINE_REPLY: 1,
RESTRICTED_USER_BIO: 2,
RESTRICTED_EMBED_DESCRIPTION: 3,
STANDARD_WITHOUT_JUMBO: 4,
} as const;
export type MarkdownContext = (typeof MarkdownContext)[keyof typeof MarkdownContext];
export interface MarkdownParseOptions {
context: MarkdownContext;
disableAnimatedEmoji?: boolean;
channelId?: string;
messageId?: string;
guildId?: string;
}
interface MarkdownRenderOptions extends MarkdownParseOptions {
shouldJumboEmojis: boolean;
i18n: I18n;
}
export interface RendererProps<T extends Node = Node> {
node: T;
id: string;
renderChildren: (nodes: Array<Node>) => React.ReactNode;
options: MarkdownRenderOptions;
}
const STANDARD_FLAGS =
ParserFlags.ALLOW_SPOILERS |
ParserFlags.ALLOW_HEADINGS |
ParserFlags.ALLOW_LISTS |
ParserFlags.ALLOW_CODE_BLOCKS |
ParserFlags.ALLOW_MASKED_LINKS |
ParserFlags.ALLOW_COMMAND_MENTIONS |
ParserFlags.ALLOW_GUILD_NAVIGATIONS |
ParserFlags.ALLOW_USER_MENTIONS |
ParserFlags.ALLOW_ROLE_MENTIONS |
ParserFlags.ALLOW_CHANNEL_MENTIONS |
ParserFlags.ALLOW_EVERYONE_MENTIONS |
ParserFlags.ALLOW_BLOCKQUOTES |
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
ParserFlags.ALLOW_SUBTEXT |
TABLE_PARSING_FLAG |
ParserFlags.ALLOW_ALERTS |
ParserFlags.ALLOW_AUTOLINKS;
const RESTRICTED_INLINE_REPLY_FLAGS =
STANDARD_FLAGS &
~(
ParserFlags.ALLOW_BLOCKQUOTES |
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
ParserFlags.ALLOW_SUBTEXT |
ParserFlags.ALLOW_TABLES |
ParserFlags.ALLOW_ALERTS |
ParserFlags.ALLOW_CODE_BLOCKS |
ParserFlags.ALLOW_HEADINGS |
ParserFlags.ALLOW_LISTS
);
const RESTRICTED_USER_BIO_FLAGS =
STANDARD_FLAGS &
~(
ParserFlags.ALLOW_HEADINGS |
ParserFlags.ALLOW_CODE_BLOCKS |
ParserFlags.ALLOW_ROLE_MENTIONS |
ParserFlags.ALLOW_EVERYONE_MENTIONS |
ParserFlags.ALLOW_SUBTEXT |
ParserFlags.ALLOW_TABLES |
ParserFlags.ALLOW_ALERTS
);
const RESTRICTED_EMBED_DESCRIPTION_FLAGS =
STANDARD_FLAGS &
~(ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_ALERTS | ParserFlags.ALLOW_AUTOLINKS);
export function getParserFlagsForContext(context: MarkdownContext): number {
switch (context) {
case MarkdownContext.RESTRICTED_INLINE_REPLY:
return RESTRICTED_INLINE_REPLY_FLAGS;
case MarkdownContext.RESTRICTED_USER_BIO:
return RESTRICTED_USER_BIO_FLAGS;
case MarkdownContext.RESTRICTED_EMBED_DESCRIPTION:
return RESTRICTED_EMBED_DESCRIPTION_FLAGS;
default:
return STANDARD_FLAGS;
}
}
export function parse({content, context}: {content: string; context: MarkdownContext}) {
const flags = getParserFlagsForContext(context);
const parser = new Parser(content, flags);
return parser.parse();
}
const renderers: Record<NodeType, React.ComponentType<RendererProps<any>>> = {
[NodeType.Sequence]: SequenceRenderer,
[NodeType.Text]: TextRenderer,
[NodeType.Strong]: StrongRenderer,
[NodeType.Emphasis]: EmphasisRenderer,
[NodeType.Underline]: UnderlineRenderer,
[NodeType.Strikethrough]: StrikethroughRenderer,
[NodeType.Spoiler]: SpoilerRenderer,
[NodeType.Timestamp]: TimestampRenderer,
[NodeType.Blockquote]: BlockquoteRenderer,
[NodeType.CodeBlock]: CodeBlockRenderer,
[NodeType.InlineCode]: InlineCodeRenderer,
[NodeType.Link]: LinkRenderer,
[NodeType.Mention]: MentionRenderer,
[NodeType.Emoji]: EmojiRenderer,
[NodeType.List]: ListRenderer,
[NodeType.Heading]: HeadingRenderer,
[NodeType.Subtext]: SubtextRenderer,
[NodeType.Table]: TableRenderer,
[NodeType.TableRow]: () => null,
[NodeType.TableCell]: () => null,
[NodeType.Alert]: AlertRenderer,
};
function renderNode(node: Node, id: string, options: MarkdownRenderOptions): React.ReactNode {
const renderer = renderers[node.type];
if (!renderer) {
console.warn(`No renderer found for node type: ${node.type}`);
return null;
}
const renderChildrenFn = (children: Array<Node>) =>
children.map((child, i) => renderNode(child, `${id}-${i}`, options));
return React.createElement(renderer, {
node,
id,
renderChildren: renderChildrenFn,
options,
key: id,
});
}
export function render(nodes: Array<Node>, options: MarkdownParseOptions): React.ReactNode {
const shouldJumboEmojis = options.context === MarkdownContext.STANDARD_WITH_JUMBO && shouldRenderJumboEmojis(nodes);
const renderOptions: MarkdownRenderOptions = {
...options,
shouldJumboEmojis,
i18n,
};
return nodes.map((node, i) => renderNode(node, `${options.context}-${i}`, renderOptions));
}
export function wrapRenderedContent(content: React.ReactNode, context: MarkdownContext): React.ReactNode {
if (context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
return <div className={markupStyles.inlineFormat}>{content}</div>;
}
return content;
}

View File

@@ -0,0 +1,279 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {CaretRightIcon, ChatTeardropIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ThemeActionCreators from '~/actions/ThemeActionCreators';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ExternalLinkWarningModal} from '~/components/modals/ExternalLinkWarningModal';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import ChannelStore from '~/stores/ChannelStore';
import DeveloperModeStore from '~/stores/DeveloperModeStore';
import GuildStore from '~/stores/GuildStore';
import TrustedDomainStore from '~/stores/TrustedDomainStore';
import markupStyles from '~/styles/Markup.module.css';
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
import {getDMDisplayName, getIcon, getName} from '~/utils/ChannelUtils';
import {
isInternalChannelHost,
parseChannelJumpLink,
parseChannelUrl,
parseMessageJumpLink,
} from '~/utils/DeepLinkUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {goToMessage} from '~/utils/MessageNavigator';
import {openExternalUrl} from '~/utils/NativeUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import * as ThemeUtils from '~/utils/ThemeUtils';
import type {LinkNode} from '../parser/types/nodes';
import type {RendererProps} from '.';
import jumpLinkStyles from './MessageJumpLink.module.css';
interface JumpLinkMentionProps {
channel: ChannelRecord;
guild: GuildRecord | null;
messageId?: string;
i18n: I18n;
}
const JumpLinkMention = observer(function JumpLinkMention({channel, guild, messageId, i18n}: JumpLinkMentionProps) {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (messageId) {
goToMessage(channel.id, messageId);
return;
}
const channelPath = channel.guildId
? Routes.guildChannel(channel.guildId, channel.id)
: Routes.dmChannel(channel.id);
RouterUtils.transitionTo(channelPath);
},
[channel.id, channel.guildId, messageId],
);
const displayName = channel.isPrivate() ? getDMDisplayName(channel) : (channel.name ?? channel.id);
const labelText = guild ? guild.name : displayName;
const shouldShowChannelInfo = !messageId && Boolean(channel.guildId);
const channelDisplayName = channel.name ?? getName(channel);
const isDMChannel = channel.isPrivate() && !channel.guildId;
const shouldShowDMIconLabel = isDMChannel && !messageId;
const hasDetailChunk = Boolean(messageId) || shouldShowChannelInfo;
const ariaLabel = messageId
? labelText
? i18n._(msg`Jump to the message in ${labelText}`)
: i18n._(msg`Jump to the linked message`)
: labelText
? i18n._(msg`Jump to ${labelText}`)
: i18n._(msg`Jump to the linked channel`);
return (
<button
type="button"
className={clsx(markupStyles.mention, markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
aria-label={ariaLabel}
>
<span className={jumpLinkStyles.jumpLinkInfo}>
{guild ? (
<span className={jumpLinkStyles.jumpLinkGuild}>
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={jumpLinkStyles.jumpLinkGuildIcon} />
<span className={jumpLinkStyles.jumpLinkGuildName}>{guild.name}</span>
</span>
) : shouldShowDMIconLabel ? (
<span className={jumpLinkStyles.jumpLinkDM}>
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
<span className={jumpLinkStyles.jumpLinkDMName}>{displayName}</span>
</span>
) : (
<span className={jumpLinkStyles.jumpLinkLabel}>{displayName}</span>
)}
{hasDetailChunk && (
<span className={jumpLinkStyles.jumpLinkMessage} aria-hidden="true">
<CaretRightIcon size={6} weight="bold" className={jumpLinkStyles.jumpLinkCaret} />
{messageId ? (
<span className={jumpLinkStyles.jumpLinkMessageIcon}>
<ChatTeardropIcon size={12} weight="fill" />
</span>
) : (
shouldShowChannelInfo && (
<span className={jumpLinkStyles.jumpLinkChannel}>
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
<span className={jumpLinkStyles.jumpLinkChannelName}>{channelDisplayName}</span>
</span>
)
)}
</span>
)}
</span>
</button>
);
});
export const LinkRenderer = observer(function LinkRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<LinkNode>): React.ReactElement {
const i18n = options.i18n!;
const {url, text} = node;
const inviteCode = InviteUtils.findInvite(url);
const themeCode = ThemeUtils.findTheme(url);
const messageJumpTarget = parseMessageJumpLink(url);
const jumpTarget = messageJumpTarget ?? parseChannelJumpLink(url);
const jumpChannel = jumpTarget ? (ChannelStore.getChannel(jumpTarget.channelId) ?? null) : null;
const jumpGuild = jumpChannel?.guildId ? (GuildStore.getGuild(jumpChannel.guildId) ?? null) : null;
if (jumpTarget && jumpChannel) {
return (
<FocusRing key={id}>
<JumpLinkMention channel={jumpChannel} guild={jumpGuild} messageId={messageJumpTarget?.messageId} i18n={i18n} />
</FocusRing>
);
}
const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel);
let isInternal = false;
let handleClick: ((e: React.MouseEvent) => void) | undefined;
if (shouldShowAccessDeniedModal) {
handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Channel Access Denied`)}
description={i18n._(msg`You do not have access to the channel where this message was sent.`)}
primaryText={i18n._(msg`Okay`)}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
/>
)),
);
};
isInternal = true;
} else if (url === `${APP_PROTOCOL_PREFIX}dev`) {
handleClick = (e) => {
e.preventDefault();
if (DeveloperModeStore.isDeveloper) {
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="developer_options" />));
} else {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title="Secret Link Found!"
description="You found a secret link, but it wasn't meant for you!"
primaryText="Okay"
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
/>
)),
);
}
};
isInternal = true;
} else {
try {
const parsed = new URL(url);
isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/');
if (inviteCode) {
handleClick = (e) => {
e.preventDefault();
InviteActionCreators.acceptAndTransitionToChannel(inviteCode, i18n);
};
} else if (themeCode) {
handleClick = (e) => {
e.preventDefault();
ThemeActionCreators.openAcceptModal(themeCode, i18n);
};
isInternal = true;
} else if (isInternal) {
const channelPath = parseChannelUrl(url);
if (channelPath) {
handleClick = (e) => {
e.preventDefault();
RouterUtils.transitionTo(channelPath);
};
} else {
isInternal = false;
}
}
if (!isInternal && !inviteCode) {
const isTrusted = TrustedDomainStore.isTrustedDomain(parsed.hostname);
if (!isTrusted) {
handleClick = (e) => {
e.preventDefault();
ModalActionCreators.push(modal(() => <ExternalLinkWarningModal url={url} hostname={parsed.hostname} />));
};
}
}
} catch (_error) {
console.warn('Invalid URL in link:', url);
}
}
const content = text ? renderChildren([text]) : url;
return (
<FocusRing key={id}>
<a
href={url}
target={isInternal ? undefined : '_blank'}
rel={isInternal ? undefined : 'noopener noreferrer'}
onClick={(e) => {
e.stopPropagation();
if (handleClick) {
handleClick(e);
return;
}
if (!isInternal) {
e.preventDefault();
void openExternalUrl(url);
}
}}
className={markupStyles.link}
>
{content}
</a>
</FocusRing>
);
});

View File

@@ -0,0 +1,284 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ChannelTypes, Permissions} from '~/Constants';
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {ChannelContextMenu} from '~/components/uikit/ContextMenu/ChannelContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Routes} from '~/Routes';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import UserStore from '~/stores/UserStore';
import markupStyles from '~/styles/Markup.module.css';
import mentionRendererStyles from '~/styles/MentionRenderer.module.css';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as ColorUtils from '~/utils/ColorUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import {GuildNavKind, MentionKind} from '../parser/types/enums';
import type {MentionNode} from '../parser/types/nodes';
import type {RendererProps} from '.';
interface UserInfo {
userId: string | null;
name: string | null;
}
function getUserInfo(userId: string, channelId?: string): UserInfo {
if (!userId) {
return {userId: null, name: null};
}
const user = UserStore.getUser(userId);
if (!user) {
return {userId, name: userId};
}
let name = user.displayName;
if (channelId) {
const channel = ChannelStore.getChannel(channelId);
if (channel?.guildId) {
name = NicknameUtils.getNickname(user, channel.guildId) || name;
}
}
return {userId: user.id, name};
}
export const MentionRenderer = observer(function MentionRenderer({
node,
id,
options,
}: RendererProps<MentionNode>): React.ReactElement {
const {kind} = node;
const {channelId} = options;
const i18n = options.i18n!;
switch (kind.kind) {
case MentionKind.User: {
const {userId, name} = getUserInfo(kind.id, channelId);
const genericMention = (
<span key={id} className={markupStyles.mention}>
@{name || kind.id}
</span>
);
if (!userId) {
return genericMention;
}
const user = UserStore.getUser(userId);
if (!user) {
return genericMention;
}
const channel = channelId ? ChannelStore.getChannel(channelId) : undefined;
const guildId = channel?.guildId || '';
return (
<PreloadableUserPopout key={id} user={user} isWebhook={false} guildId={guildId} position="right-start">
<FocusRing>
<span
role="button"
tabIndex={0}
className={clsx(markupStyles.mention, markupStyles.interactive)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
>
@{name || user.displayName}
</span>
</FocusRing>
</PreloadableUserPopout>
);
}
case MentionKind.Role: {
const selectedGuildId = SelectedGuildStore.selectedGuildId;
const guild = selectedGuildId != null ? GuildStore.getGuild(selectedGuildId) : null;
const role = guild?.roles[kind.id];
if (!role) {
return (
<span key={id} className={markupStyles.mention}>
@{i18n._(msg`Unknown Role`)}
</span>
);
}
const roleColor = role.color ? ColorUtils.int2rgb(role.color) : undefined;
const roleBgColor = role.color ? ColorUtils.int2rgba(role.color, 0.1) : undefined;
const roleBgHoverColor = role.color ? ColorUtils.int2rgba(role.color, 0.2) : undefined;
const style = {
color: roleColor,
backgroundColor: roleBgColor,
transition: 'background-color var(--transition-fast)',
'--hover-bg': roleBgHoverColor,
} as React.CSSProperties;
return (
<span key={id} className={markupStyles.mention} style={style}>
@{role.name}
</span>
);
}
case MentionKind.Channel: {
const unknownMention = (
<span key={id} className={markupStyles.mention}>
{ChannelUtils.getIcon({type: ChannelTypes.GUILD_TEXT}, {className: mentionRendererStyles.channelIcon})}
{i18n._(msg`unknown-channel`)}
</span>
);
const channel = ChannelStore.getChannel(kind.id);
if (!channel) {
return unknownMention;
}
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
return <span key={id}>#{channel.name}</span>;
}
if (
channel.type !== ChannelTypes.GUILD_TEXT &&
channel.type !== ChannelTypes.GUILD_VOICE &&
channel.type !== ChannelTypes.GUILD_LINK
) {
return unknownMention;
}
return (
<FocusRing key={id}>
<button
className={clsx(markupStyles.mention, markupStyles.interactive)}
onClick={(e) => {
e.stopPropagation();
if (channel.type === ChannelTypes.GUILD_VOICE) {
const canConnect = PermissionStore.can(Permissions.CONNECT, channel);
if (!canConnect) {
ModalActionCreators.push(
modal(() => (
<GenericErrorModal
title={i18n._(msg`Missing Permissions`)}
message={i18n._(msg`You don't have permission to connect to this voice channel.`)}
/>
)),
);
return;
}
}
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId!, channel.id));
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<ChannelContextMenu channel={channel} onClose={onClose} />
));
}}
type="button"
>
{ChannelUtils.getIcon(channel, {className: mentionRendererStyles.channelIcon})}
{channel.name}
</button>
</FocusRing>
);
}
case MentionKind.Everyone: {
return (
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.everyoneMention)}>
@everyone
</span>
);
}
case MentionKind.Here: {
return (
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.hereMention)}>
@here
</span>
);
}
case MentionKind.Command: {
const {name} = kind;
return (
<span key={id} className={markupStyles.mention}>
{name}
</span>
);
}
case MentionKind.GuildNavigation: {
const {navigationType} = kind;
let content: string;
switch (navigationType) {
case GuildNavKind.Customize:
content = '<id:customize>';
break;
case GuildNavKind.Browse:
content = '<id:browse>';
break;
case GuildNavKind.Guide:
content = '<id:guide>';
break;
case GuildNavKind.LinkedRoles: {
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
content = linkedRolesId ? `<id:linked-roles:${linkedRolesId}>` : '<id:linked-roles>';
break;
}
default:
content = `<id:${navigationType}>`;
break;
}
return (
<span key={id} className={markupStyles.mention}>
{content}
</span>
);
}
default:
return <span key={id}>{'<unknown-mention>'}</span>;
}
});

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {TextNode} from '../parser/types/nodes';
import {MarkdownContext, type RendererProps} from '.';
export const TextRenderer = observer(function TextRenderer({
node,
id,
options,
}: RendererProps<TextNode>): React.ReactElement {
let content = node.content;
if (options.context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
content = content.replace(/\n/g, ' ').replace(/\s+/g, ' ');
}
return <span key={id}>{content}</span>;
});

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ClockIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {DateTime} from 'luxon';
import {observer} from 'mobx-react-lite';
import type {ReactElement} from 'react';
import {useEffect, useState} from 'react';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import WindowStore from '~/stores/WindowStore';
import markupStyles from '~/styles/Markup.module.css';
import timestampRendererStyles from '~/styles/TimestampRenderer.module.css';
import {TimestampStyle} from '../parser/types/enums';
import type {TimestampNode} from '../parser/types/nodes';
import {formatTimestamp} from '../utils/date-formatter';
import type {RendererProps} from '.';
export const TimestampRenderer = observer(function TimestampRenderer({
node,
id,
options,
}: RendererProps<TimestampNode>): ReactElement {
const {timestamp, style} = node;
const i18n = options.i18n;
const totalMillis = timestamp * 1000;
const date = DateTime.fromMillis(totalMillis);
const now = DateTime.now();
const isPast = date < now;
const isFuture = date > now;
const isToday = date.hasSame(now, 'day');
const tooltipFormat = "EEEE, LLLL d, yyyy 'at' h:mm:ss a ZZZZ";
const fullDateTime = date.toFormat(tooltipFormat);
const isRelativeStyle = style === TimestampStyle.RelativeTime;
const isWindowFocused = WindowStore.focused;
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
const relativeTime = date.toRelative();
useEffect(() => {
if (!isRelativeStyle || !isWindowFocused) {
return;
}
const refreshDisplay = () => {
setRelativeDisplayTime((previous) => {
const nextValue = formatTimestamp(timestamp, style, i18n);
return previous === nextValue ? previous : nextValue;
});
};
refreshDisplay();
const intervalId = setInterval(refreshDisplay, 1000);
return () => clearInterval(intervalId);
}, [isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
const tooltipContent = (
<div className={timestampRendererStyles.tooltipContainer}>
<div className={timestampRendererStyles.tooltipFullDateTime}>{fullDateTime}</div>
<div className={timestampRendererStyles.tooltipRelativeTime}>{relativeTime}</div>
</div>
);
const displayTime = isRelativeStyle ? relativeDisplayTime : formatTimestamp(timestamp, style, i18n);
const timestampClasses = clsx(
markupStyles.timestamp,
isPast && !isToday && timestampRendererStyles.timestampPast,
isFuture && timestampRendererStyles.timestampFuture,
isToday && timestampRendererStyles.timestampToday,
);
return (
<Tooltip key={id} text={() => tooltipContent} position="top" delay={200} maxWidth="xl">
<time className={timestampClasses} dateTime={date.toISO() ?? ''}>
<ClockIcon className={timestampRendererStyles.clockIcon} />
{displayTime}
</time>
</Tooltip>
);
});

View File

@@ -0,0 +1,231 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {DateTime} from 'luxon';
import {shouldUse12HourFormat} from '~/utils/DateUtils';
import {getCurrentLocale} from '~/utils/LocaleUtils';
import {TimestampStyle} from '../parser/types/enums';
function isToday(date: DateTime, now: DateTime): boolean {
return date.hasSame(now, 'day');
}
function isYesterday(date: DateTime, now: DateTime): boolean {
const yesterday = now.minus({days: 1});
return date.hasSame(yesterday, 'day');
}
function formatRelativeTime(timestamp: number, i18n: I18n): string {
const locale = getCurrentLocale();
const date = DateTime.fromSeconds(timestamp).setLocale(locale);
const now = DateTime.now().setLocale(locale);
if (isToday(date, now)) {
const timeString = date.toLocaleString({
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
return i18n._(msg`Today at ${timeString}`);
}
if (isYesterday(date, now)) {
const timeString = date.toLocaleString({
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
return i18n._(msg`Yesterday at ${timeString}`);
}
const diff = date.diff(now).shiftTo('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds');
const {years, months, weeks, days, hours, minutes, seconds} = diff.toObject();
if (years && Math.abs(years) > 0) {
const absYears = Math.abs(Math.round(years));
return date > now
? absYears === 1
? i18n._(msg`next year`)
: i18n._(msg`in ${absYears} years`)
: absYears === 1
? i18n._(msg`last year`)
: i18n._(msg`${absYears} years ago`);
}
if (months && Math.abs(months) > 0) {
const absMonths = Math.abs(Math.round(months));
return date > now
? absMonths === 1
? i18n._(msg`next month`)
: i18n._(msg`in ${absMonths} months`)
: absMonths === 1
? i18n._(msg`last month`)
: i18n._(msg`${absMonths} months ago`);
}
if (weeks && Math.abs(weeks) > 0) {
const absWeeks = Math.abs(Math.round(weeks));
return date > now
? absWeeks === 1
? i18n._(msg`next week`)
: i18n._(msg`in ${absWeeks} weeks`)
: absWeeks === 1
? i18n._(msg`last week`)
: i18n._(msg`${absWeeks} weeks ago`);
}
if (days && Math.abs(days) > 0) {
const absDays = Math.abs(Math.round(days));
return date > now
? absDays === 1
? i18n._(msg`tomorrow`)
: absDays === 2
? i18n._(msg`in two days`)
: i18n._(msg`in ${absDays} days`)
: absDays === 1
? i18n._(msg`yesterday`)
: absDays === 2
? i18n._(msg`two days ago`)
: i18n._(msg`${absDays} days ago`);
}
if (hours && Math.abs(hours) > 0) {
const absHours = Math.abs(Math.round(hours));
return date > now
? absHours === 1
? i18n._(msg`in one hour`)
: i18n._(msg`in ${absHours} hours`)
: absHours === 1
? i18n._(msg`one hour ago`)
: i18n._(msg`${absHours} hours ago`);
}
if (minutes && Math.abs(minutes) > 0) {
const absMinutes = Math.abs(Math.round(minutes));
return date > now
? absMinutes === 1
? i18n._(msg`in one minute`)
: i18n._(msg`in ${absMinutes} minutes`)
: absMinutes === 1
? i18n._(msg`one minute ago`)
: i18n._(msg`${absMinutes} minutes ago`);
}
const absSeconds = Math.abs(Math.round(seconds ?? 1));
return date > now
? absSeconds === 0
? i18n._(msg`now`)
: absSeconds === 1
? i18n._(msg`in one second`)
: i18n._(msg`in ${absSeconds} seconds`)
: absSeconds === 0
? i18n._(msg`just now`)
: absSeconds === 1
? i18n._(msg`one second ago`)
: i18n._(msg`${absSeconds} seconds ago`);
}
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
const locale = getCurrentLocale();
const date = DateTime.fromMillis(timestamp * 1000).setLocale(locale);
switch (style) {
case TimestampStyle.ShortTime:
return date.toLocaleString({
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.LongTime:
return date.toLocaleString({
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.ShortDate:
return date.toLocaleString(DateTime.DATE_SHORT);
case TimestampStyle.LongDate:
return date.toLocaleString({
month: 'long',
day: 'numeric',
year: 'numeric',
});
case TimestampStyle.ShortDateTime:
return date.toLocaleString({
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.LongDateTime:
return date.toLocaleString({
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.ShortDateShortTime:
return date.toLocaleString({
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.ShortDateMediumTime:
return date.toLocaleString({
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
case TimestampStyle.RelativeTime:
return formatRelativeTime(timestamp, i18n);
default:
return date.toLocaleString({
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: shouldUse12HourFormat(locale),
});
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore from '~/stores/EmojiStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as EmojiUtils from '~/utils/EmojiUtils';
import {EmojiKind} from '../parser/types/enums';
import type {EmojiNode} from '../parser/types/nodes';
interface EmojiRenderData {
url: string | null;
name: string;
isAnimated: boolean;
id?: string;
}
export function getEmojiRenderData(
emojiNode: EmojiNode,
guildId?: string,
disableAnimatedEmoji = false,
): EmojiRenderData {
const {kind} = emojiNode;
const emojiName = `:${kind.name}:`;
if (kind.kind === EmojiKind.Standard) {
return {
url: EmojiUtils.getTwemojiURL(kind.codepoints),
name: emojiName,
isAnimated: false,
};
}
const {id, animated} = kind;
const shouldAnimate = !disableAnimatedEmoji && animated;
const channel = guildId ? ChannelStore.getChannel(guildId) : undefined;
const disambiguatedEmoji = EmojiStore.getDisambiguatedEmojiContext(channel?.guildId).getById(id);
const finalEmojiName = `:${disambiguatedEmoji?.name || kind.name}:`;
return {
url: AvatarUtils.getEmojiURL({id, animated: shouldAnimate}),
name: finalEmojiName,
isAnimated: shouldAnimate,
id,
};
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {NodeType} from '../parser/types/enums';
import type {Node, TextNode} from '../parser/types/nodes';
export function shouldRenderJumboEmojis(nodes: Array<Node>): boolean {
if (UserSettingsStore.getMessageDisplayCompact()) {
return false;
}
const emojiCount = nodes.filter((node) => {
return (
node.type === NodeType.Emoji ||
(node.type === NodeType.Text && UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content))
);
}).length;
return (
emojiCount > 0 &&
emojiCount <= 6 &&
nodes.every((node) => {
return (
node.type === NodeType.Emoji ||
(node.type === NodeType.Text &&
((node as TextNode).content.trim() === '' || UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content)))
);
})
);
}