refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,154 @@
/*
* 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 '@fluxer/markdown_parser/src/types/Enums';
import type {FormattingNode, Node, TextNode} from '@fluxer/markdown_parser/src/types/Nodes';
import {
addTextNode,
combineAdjacentTextNodes,
flattenSameType,
isFormattingNode,
mergeTextNodes,
} from '@fluxer/markdown_parser/src/utils/AstUtils';
import {describe, expect, test} from 'vitest';
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);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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 {FormattingContext} from '@fluxer/markdown_parser/src/parser/FormattingContext';
import {beforeEach, describe, expect, test} from 'vitest';
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,862 @@
/*
* 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 '@fluxer/markdown_parser/src/parser/Parser';
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import type {TextNode} from '@fluxer/markdown_parser/src/types/Nodes';
import {describe, expect, test} from 'vitest';
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);
});
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_'},
]);
});
test('nested double marker inside single marker creates multiple nodes', () => {
const input = '*outer **inner** content*';
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.Emphasis)).toBe(true);
});
test('empty formatting 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('formatting with single underscore skip when alphanumeric before', () => {
const input = 'abc_def_ghi';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'abc_def_ghi'}]);
});
test('empty strong formatting', () => {
const input = '****';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '****'}]);
});
test('inline code with multiple backticks', () => {
const input = '``code with `backtick` inside``';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.InlineCode, content: 'code with `backtick` inside'}]);
});
test('inline code with triple backticks treated as text without code block flag', () => {
const input = '```inline triple```';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '```inline triple```'}]);
});
test('inline code with unmatched backtick count parses as inline code', () => {
const input = '``not closed`';
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.InlineCode)).toBe(true);
});
test('spoiler parsing disabled by flag', () => {
const input = '||spoiler content||';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '||spoiler content||'}]);
});
test('empty emphasis formatting with empty children', () => {
const input = '** **';
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: ' '}],
},
]);
});
test('triple marker emphasis with underscore', () => {
const input = '___bold italic with underscore___';
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 italic with underscore'}],
},
],
},
]);
});
test('single underscore with double underscore inside', () => {
const input = '_text __underline__ more_';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Emphasis,
children: [
{type: NodeType.Text, content: 'text '},
{type: NodeType.Underline, children: [{type: NodeType.Text, content: 'underline'}]},
{type: NodeType.Text, content: ' more'},
],
},
]);
});
test('spoiler with empty content at marker position', () => {
const input = '||||';
const flags = ParserFlags.ALLOW_SPOILERS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Spoiler, children: []}]);
});
test('nested spoiler with formatting', () => {
const input = '||**bold in 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: 'bold in spoiler'}]}],
},
]);
});
test('inline code backtick skipping with consecutive backticks', () => {
const input = '`code with `` inside`';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.InlineCode, content: 'code with `` inside'}]);
});
test('formatting context prevents same formatting type nesting', () => {
const input = '**already **nested** bold**';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast.length).toBeGreaterThan(0);
});
test('underscore emphasis at word boundaries only', () => {
const input = 'word_in_middle vs _start and end_';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'word_in_middle vs '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'start and end'}]},
]);
});
});
});

File diff suppressed because it is too large Load Diff

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 {Parser} from '@fluxer/markdown_parser/src/parser/Parser';
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import type {CodeBlockNode, ListNode, TextNode} from '@fluxer/markdown_parser/src/types/Nodes';
import {describe, expect, test} from 'vitest';
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 is normalised', () => {
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: 2},
{children: [{type: NodeType.Text, content: 'Fifth item'}], ordinal: 3},
],
});
});
test('list with all same number is normalised', () => {
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: 2},
{children: [{type: NodeType.Text, content: 'Item three'}], ordinal: 3},
],
});
});
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 is normalised', () => {
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: 2},
{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,442 @@
/*
* 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 '@fluxer/markdown_parser/src/parser/Parser';
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {describe, expect, test} from 'vitest';
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: '!'},
]);
});
describe('Guild navigation edge cases', () => {
test('guild nav browse', () => {
const input = '<id:browse>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Mention, kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.Browse}},
]);
});
test('guild nav guide', () => {
const input = '<id:guide>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Mention, kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.Guide}},
]);
});
test('guild nav linked-roles without id', () => {
const input = '<id:linked-roles>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Mention,
kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.LinkedRoles, id: undefined},
},
]);
});
test('guild nav with unknown type returns text', () => {
const input = '<id:unknown>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:unknown>'}]);
});
test('guild nav with too many parts returns text', () => {
const input = '<id:customize:extra:parts>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:customize:extra:parts>'}]);
});
test('guild nav with non-id prefix returns text', () => {
const input = '<notid: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: '<notid:customize>'}]);
});
test('guild nav customize with extra parts returns text', () => {
const input = '<id:customize:123>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:customize:123>'}]);
});
test('guild nav browse with extra parts returns text', () => {
const input = '<id:browse:123>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:browse:123>'}]);
});
test('guild nav with short inner content returns text', () => {
const input = '<id:>';
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:>'}]);
});
test('guild nav with malformed id prefix returns text', () => {
const input = '<ix: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: '<ix:customize>'}]);
});
test('guild nav without flags disabled returns text', () => {
const input = '<id:customize>';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<id:customize>'}]);
});
});
describe('Command mention edge cases', () => {
test('command with subcommand only (2 segments)', () => {
const input = '</app sub:1234567890>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: 'app',
subcommandGroup: undefined,
subcommand: 'sub',
id: '1234567890',
},
},
]);
});
test('command without colon returns text', () => {
const input = '</airhorn>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '</airhorn>'}]);
});
test('command with non-numeric id returns text', () => {
const input = '</airhorn:abc>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '</airhorn:abc>'}]);
});
test('command with empty id returns text', () => {
const input = '</airhorn:>';
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '</airhorn:>'}]);
});
});
describe('Mention type edge cases', () => {
test('user mention with non-numeric id returns text', () => {
const input = '<@abc>';
const flags = ParserFlags.ALLOW_USER_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<@abc>'}]);
});
test('role mention with non-numeric id returns text', () => {
const input = '<@&abc>';
const flags = ParserFlags.ALLOW_ROLE_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<@&abc>'}]);
});
test('channel mention with non-numeric id returns text', () => {
const input = '<#abc>';
const flags = ParserFlags.ALLOW_CHANNEL_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<#abc>'}]);
});
test('mention without closing bracket returns text', () => {
const input = '<@1234567890';
const flags = ParserFlags.ALLOW_USER_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<@1234567890'}]);
});
test('very short mention returns text', () => {
const input = '<';
const flags = ParserFlags.ALLOW_USER_MENTIONS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '<'}]);
});
});
});

View File

@@ -0,0 +1,186 @@
/*
* 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 '@fluxer/markdown_parser/src/parser/Parser';
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {describe, expect, test} from 'vitest';
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 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).';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_MASKED_LINKS;
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: '.'},
]);
});
test('escaped hyphen prevents list parsing without rendering the backslash', () => {
const input = 'test\n\\- test';
const flags = ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: 'test\n- test'}]);
});
test('hash-prefixed non-heading lines still parse autolinks', () => {
const input = '#f https://example.com';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_AUTOLINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '#f '},
{type: NodeType.Link, text: undefined, url: 'https://example.com/', escaped: false},
]);
});
test('hash-prefixed non-heading lines preserve newlines in paragraphs', () => {
const input = 'Hello\n#f https://example.com';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_AUTOLINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'Hello\n'},
{type: NodeType.Text, content: '#f '},
{type: NodeType.Link, text: undefined, url: 'https://example.com/', escaped: false},
]);
});
});

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 {isAlphaNumericChar, matchMarker, startsWithUrl} from '@fluxer/markdown_parser/src/utils/StringUtils';
import {describe, expect, test} from 'vitest';
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,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 {Parser} from '@fluxer/markdown_parser/src/parser/Parser';
import {NodeType, ParserFlags, TableAlignment} from '@fluxer/markdown_parser/src/types/Enums';
import type {InlineCodeNode, TableNode, TextNode} from '@fluxer/markdown_parser/src/types/Nodes';
import {describe, expect, test} from 'vitest';
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,143 @@
/*
* 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 EmojiProvider,
setEmojiParserConfig,
type UnicodeEmoji,
} from '@fluxer/markdown_parser/src/parsers/EmojiParsers';
import emojiRegex from 'emoji-regex';
interface EmojiData {
surrogate: string;
names: Array<string>;
hasDiversity?: boolean;
skins?: Array<{surrogate: string}>;
}
const EMOJI_DATA: Map<string, EmojiData> = new Map([
['smile', {surrogate: '😄', names: ['smile', 'grinning_face_with_smiling_eyes']}],
[
'wave',
{
surrogate: '👋',
names: ['wave', 'waving_hand'],
hasDiversity: true,
skins: [{surrogate: '👋🏻'}, {surrogate: '👋🏼'}, {surrogate: '👋🏽'}, {surrogate: '👋🏾'}, {surrogate: '👋🏿'}],
},
],
['heart', {surrogate: '❤️', names: ['heart', 'red_heart']}],
[
'thumbsup',
{
surrogate: '👍',
names: ['thumbsup', 'thumbs_up', '+1'],
hasDiversity: true,
skins: [{surrogate: '👍🏻'}, {surrogate: '👍🏼'}, {surrogate: '👍🏽'}, {surrogate: '👍🏾'}, {surrogate: '👍🏿'}],
},
],
['blush', {surrogate: '😊', names: ['blush', 'smiling_face_with_smiling_eyes']}],
['grinning', {surrogate: '😀', names: ['grinning', 'grinning_face']}],
['smiley', {surrogate: '😃', names: ['smiley', 'smiling_face_with_open_mouth']}],
['grin', {surrogate: '😁', names: ['grin', 'beaming_face_with_smiling_eyes']}],
[
'foot',
{
surrogate: '🦶',
names: ['foot', 'leg'],
hasDiversity: true,
skins: [{surrogate: '🦶🏻'}, {surrogate: '🦶🏼'}, {surrogate: '🦶🏽'}, {surrogate: '🦶🏾'}, {surrogate: '🦶🏿'}],
},
],
['face_holding_back_tears', {surrogate: '🥹', names: ['face_holding_back_tears']}],
['white_check_mark', {surrogate: '✅', names: ['white_check_mark', 'check_mark_button']}],
['x', {surrogate: '❌', names: ['x', 'cross_mark']}],
['leftwards_arrow_with_hook', {surrogate: '↩️', names: ['leftwards_arrow_with_hook']}],
['rightwards_arrow_with_hook', {surrogate: '↪️', names: ['rightwards_arrow_with_hook']}],
['arrow_heading_up', {surrogate: '⤴️', names: ['arrow_heading_up']}],
['family_mwgb', {surrogate: '👨‍👩‍👧‍👦', names: ['family_mwgb', 'family_man_woman_girl_boy']}],
[
'mx_claus',
{
surrogate: '🧑‍🎄',
names: ['mx_claus'],
hasDiversity: true,
skins: [
{surrogate: '🧑🏻‍🎄'},
{surrogate: '🧑🏼‍🎄'},
{surrogate: '🧑🏽‍🎄'},
{surrogate: '🧑🏾‍🎄'},
{surrogate: '🧑🏿‍🎄'},
],
},
],
]);
const SKIN_TONE_SURROGATES: ReadonlyArray<string> = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const SURROGATE_TO_NAME: Map<string, string> = new Map();
const NAME_TO_EMOJI: Map<string, UnicodeEmoji> = new Map();
const SKIN_TONE_EMOJI: Map<string, UnicodeEmoji> = new Map();
for (const [mainName, data] of EMOJI_DATA) {
SURROGATE_TO_NAME.set(data.surrogate, mainName);
for (const name of data.names) {
NAME_TO_EMOJI.set(name, {surrogates: data.surrogate});
}
if (data.hasDiversity && data.skins) {
data.skins.forEach((skin, index) => {
const skinToneSurrogate = SKIN_TONE_SURROGATES[index];
for (const name of data.names) {
const skinKey = `${name}:${skinToneSurrogate}`;
SKIN_TONE_EMOJI.set(skinKey, {surrogates: skin.surrogate});
SURROGATE_TO_NAME.set(skin.surrogate, `${name}::skin-tone-${index + 1}`);
}
});
}
}
const testEmojiProvider: EmojiProvider = {
getSurrogateName(surrogate: string): string | null {
return SURROGATE_TO_NAME.get(surrogate) || null;
},
findEmojiByName(name: string): UnicodeEmoji | null {
return NAME_TO_EMOJI.get(name) || null;
},
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null {
const skinKey = `${baseName}:${skinToneSurrogate}`;
return SKIN_TONE_EMOJI.get(skinKey) || null;
},
};
export function setupTestEmojiProvider(): void {
setEmojiParserConfig({
emojiProvider: testEmojiProvider,
emojiRegex: emojiRegex(),
skinToneSurrogates: SKIN_TONE_SURROGATES,
});
}
export function clearTestEmojiProvider(): void {
setEmojiParserConfig({
emojiProvider: undefined,
emojiRegex: undefined,
skinToneSurrogates: undefined,
});
}

View File

@@ -0,0 +1,307 @@
/*
* 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 {clearTestEmojiProvider, setupTestEmojiProvider} from '@fluxer/markdown_parser/src/__tests__/TestEmojiSetup';
import {Parser} from '@fluxer/markdown_parser/src/parser/Parser';
import {EmojiKind, NodeType, ParserFlags, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
beforeAll(() => {
setupTestEmojiProvider();
});
afterAll(() => {
clearTestEmojiProvider();
});
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,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 '@fluxer/markdown_parser/src/types/MarkdownConstants';
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,35 @@
/*
* 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 {parseMarkdownAst} from '@fluxer/markdown_parser/src/parser/ParserEngine';
import type {Node} from '@fluxer/markdown_parser/src/types/Nodes';
export class Parser {
private readonly input: string;
private readonly parserFlags: number;
constructor(input: string, flags: number) {
this.input = input;
this.parserFlags = flags;
}
parse(): {nodes: Array<Node>} {
return parseMarkdownAst(this.input, this.parserFlags);
}
}

View File

@@ -0,0 +1,208 @@
/*
* 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 {createParserInput} from '@fluxer/markdown_parser/src/parser/ParserInput';
import * as BlockParsers from '@fluxer/markdown_parser/src/parsers/BlockParsers';
import * as EmojiParsers from '@fluxer/markdown_parser/src/parsers/EmojiParsers';
import * as InlineParsers from '@fluxer/markdown_parser/src/parsers/InlineParsers';
import * as ListParsers from '@fluxer/markdown_parser/src/parsers/ListParsers';
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {Node} from '@fluxer/markdown_parser/src/types/Nodes';
import * as ASTUtils from '@fluxer/markdown_parser/src/utils/AstUtils';
interface ParserRuntimeState {
lines: Array<string>;
currentLineIndex: number;
totalLineCount: number;
parserFlags: number;
nodeCount: number;
}
export function parseMarkdownAst(input: string, parserFlags: number): {nodes: Array<Node>} {
const parserInput = createParserInput(input);
const state: ParserRuntimeState = {
lines: parserInput.lines,
currentLineIndex: 0,
totalLineCount: parserInput.totalLineCount,
parserFlags,
nodeCount: 0,
};
return parseWithRuntimeState(state);
}
function parseWithRuntimeState(state: ParserRuntimeState): {nodes: Array<Node>} {
const ast: Array<Node> = [];
if (state.totalLineCount === 0) {
return {nodes: ast};
}
const blockParserDependencies = createBlockParserDependencies(state);
while (state.currentLineIndex < state.totalLineCount && state.nodeCount <= MAX_AST_NODES) {
const line = state.lines[state.currentLineIndex];
if (line.length > MAX_LINE_LENGTH) {
state.lines[state.currentLineIndex] = line.slice(0, MAX_LINE_LENGTH);
}
const trimmedLine = line.trimStart();
if (trimmedLine === '') {
const blankLineCount = countBlankLines(state.lines, state.currentLineIndex, state.totalLineCount);
if (ast.length > 0 && state.currentLineIndex + blankLineCount < state.totalLineCount) {
const nextLine = state.lines[state.currentLineIndex + blankLineCount];
const nextTrimmed = nextLine.trimStart();
const isNextHeading = nextTrimmed
? BlockParsers.parseHeading(nextTrimmed, blockParserDependencies.parseInline) !== 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});
state.nodeCount++;
}
}
state.currentLineIndex += blankLineCount;
continue;
}
const blockResult = BlockParsers.parseBlock(
state.lines,
state.currentLineIndex,
state.parserFlags,
state.nodeCount,
blockParserDependencies,
);
if (blockResult.node) {
ast.push(blockResult.node);
if (blockResult.extraNodes) {
for (const extraNode of blockResult.extraNodes) {
ast.push(extraNode);
}
}
state.currentLineIndex = blockResult.newLineIndex;
state.nodeCount = blockResult.newNodeCount;
continue;
}
parseInlineLine(ast, state, blockParserDependencies.parseInline);
state.currentLineIndex++;
}
ASTUtils.flattenAST(ast);
for (const node of ast) {
EmojiParsers.applyTextPresentation(node);
}
return {nodes: ast};
}
function createBlockParserDependencies(state: ParserRuntimeState): BlockParsers.BlockParserDependencies {
return {
parseInline(text: string): Array<Node> {
return InlineParsers.parseInline(text, state.parserFlags);
},
parseNested(text: string, parserFlags: number): Array<Node> {
return parseMarkdownAst(text, parserFlags).nodes;
},
};
}
function countBlankLines(lines: Array<string>, startLine: number, totalLineCount: number): number {
let count = 0;
let current = startLine;
while (current < totalLineCount && lines[current].trim() === '') {
count++;
current++;
}
return count;
}
function parseInlineLine(
ast: Array<Node>,
state: ParserRuntimeState,
parseInline: (text: string) => Array<Node>,
): void {
let text = state.lines[state.currentLineIndex];
let linesConsumed = 1;
while (state.currentLineIndex + linesConsumed < state.totalLineCount) {
const nextLine = state.lines[state.currentLineIndex + linesConsumed];
const trimmedNext = nextLine.trimStart();
if (isBlockStart(trimmedNext, state.parserFlags)) {
break;
}
if (trimmedNext === '') {
break;
}
text += `\n${nextLine}`;
linesConsumed++;
}
if (state.currentLineIndex + linesConsumed < state.totalLineCount) {
const nextLine = state.lines[state.currentLineIndex + linesConsumed];
const trimmedNext = nextLine.trimStart();
const isNextLineHeading = isHeadingStart(trimmedNext, state.parserFlags);
const isNextLineBlockquote = trimmedNext.startsWith('>');
if (trimmedNext === '' || (!isNextLineHeading && !isNextLineBlockquote)) {
text += '\n';
}
}
const inlineNodes = parseInline(text);
for (const node of inlineNodes) {
ast.push(node);
state.nodeCount++;
if (state.nodeCount > MAX_AST_NODES) break;
}
state.currentLineIndex += linesConsumed - 1;
}
function isBlockStart(line: string, parserFlags: number): boolean {
return !!(
line.startsWith('#') ||
(parserFlags & ParserFlags.ALLOW_SUBTEXT && line.startsWith('-#')) ||
(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS && line.startsWith('```')) ||
(parserFlags & ParserFlags.ALLOW_LISTS && ListParsers.matchListItem(line) != null) ||
(parserFlags & (ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) &&
(line.startsWith('>') || line.startsWith('>>> ')))
);
}
const MAX_HEADING_LEVEL = 4;
function isHeadingStart(trimmedLine: string, parserFlags: number): boolean {
if (!(parserFlags & ParserFlags.ALLOW_HEADINGS)) return false;
if (!trimmedLine.startsWith('#')) return false;
let level = 0;
while (level < trimmedLine.length && level < MAX_HEADING_LEVEL && trimmedLine[level] === '#') {
level++;
}
return level >= 1 && level <= MAX_HEADING_LEVEL && trimmedLine[level] === ' ';
}

View File

@@ -0,0 +1,45 @@
/*
* 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_LINES} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
export interface ParserInput {
lines: Array<string>;
totalLineCount: number;
}
export function createParserInput(input: string): ParserInput {
if (!input || input === '') {
return {lines: [], totalLineCount: 0};
}
const lines = input['split']('\n');
if (lines.length > MAX_LINES) {
lines.length = MAX_LINES;
}
if (lines.length === 1 && lines[0] === '') {
return {lines: [], totalLineCount: 0};
}
return {
lines,
totalLineCount: lines.length,
};
}

View File

@@ -0,0 +1,815 @@
/*
* 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 '@fluxer/markdown_parser/src/parser/Parser';
import * as InlineParsers from '@fluxer/markdown_parser/src/parsers/InlineParsers';
import * as ListParsers from '@fluxer/markdown_parser/src/parsers/ListParsers';
import * as TableParsers from '@fluxer/markdown_parser/src/parsers/TableParsers';
import {AlertType, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {
AlertNode,
CodeBlockNode,
HeadingNode,
Node,
SpoilerNode,
SubtextNode,
TextNode,
} from '@fluxer/markdown_parser/src/types/Nodes';
import {flattenChildren} from '@fluxer/markdown_parser/src/utils/AstUtils';
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
interface BlockParseResult {
node: Node | null;
newLineIndex: number;
newNodeCount: number;
extraNodes?: Array<Node>;
}
export interface BlockParserDependencies {
parseInline: (text: string) => Array<Node>;
parseNested: (text: string, parserFlags: number) => 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(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
_dependencies: BlockParserDependencies,
): 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 textNode: TextNode = {type: NodeType.Text, content: line};
const result: BlockParseResult = {
node: 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 textNode: TextNode = {type: NodeType.Text, content: line};
const result: BlockParseResult = {
node: 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;
}
}
// Not a heading, treat it as inline text so links/formatting still parse.
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
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,364 @@
/*
* 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 {EmojiKind, NodeType} from '@fluxer/markdown_parser/src/types/Enums';
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
export interface UnicodeEmoji {
surrogates: string;
}
export interface EmojiProvider {
getSurrogateName(surrogate: string): string | null;
findEmojiByName(name: string): UnicodeEmoji | null;
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null;
}
export interface EmojiParserConfig {
emojiProvider?: EmojiProvider;
emojiRegex?: RegExp;
skinToneSurrogates?: ReadonlyArray<string>;
convertToCodePoints?: (emoji: string) => string;
}
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, UnicodeEmoji | null>();
let globalEmojiConfig: EmojiParserConfig | null = null;
export function setEmojiParserConfig(config: EmojiParserConfig): void {
globalEmojiConfig = config;
}
export function getEmojiParserConfig(): EmojiParserConfig | null {
return globalEmojiConfig;
}
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;
}
function defaultConvertToCodePoints(emoji: string): string {
const containsZWJ = emoji.includes('\u200D');
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
return Array.from(processedEmoji)
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
.join('-');
}
export function parseStandardEmoji(text: string, start: number): ParserResult | null {
if (!globalEmojiConfig?.emojiProvider || !globalEmojiConfig?.emojiRegex) {
return 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;
}
const emojiRegex = globalEmojiConfig.emojiRegex;
emojiRegex.lastIndex = 0;
const match = emojiRegex.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 = globalEmojiConfig.emojiProvider.getSurrogateName(candidate);
EMOJI_NAME_CACHE.set(candidate, name);
}
if (!name) {
return null;
}
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
const codepoints = convertToCodePoints(candidate);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: candidate,
codepoints,
name,
},
},
advance: candidate.length,
};
}
return null;
}
const SKIN_TONE_SUFFIX_REGEX = /^:skin-tone-([1-5]):/;
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 baseName = text.substring(1, endPos);
const specialSymbol = SPECIAL_SHORTCODES[baseName];
if (specialSymbol) {
return {
node: {type: NodeType.Text, content: specialSymbol},
advance: endPos + 1,
};
}
if (!globalEmojiConfig?.emojiProvider) {
return null;
}
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
return null;
}
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
if (emoji === undefined) {
emoji = globalEmojiConfig.emojiProvider.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;
let totalAdvance = endPos + 1;
const afterEmoji = text.slice(endPos + 1);
const skinToneMatch = SKIN_TONE_SUFFIX_REGEX.exec(afterEmoji);
if (skinToneMatch && globalEmojiConfig.skinToneSurrogates) {
const skinTone = Number.parseInt(skinToneMatch[1], 10);
const skinToneKey = `${baseName}:tone-${skinTone}`;
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
if (skinToneEmoji === undefined) {
const skinToneSurrogate = globalEmojiConfig.skinToneSurrogates[skinTone - 1];
skinToneEmoji = globalEmojiConfig.emojiProvider.findEmojiWithSkinTone(baseName, skinToneSurrogate);
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
}
if (skinToneEmoji) {
finalEmoji = skinToneEmoji;
totalAdvance += skinToneMatch[0].length;
}
}
if (!finalEmoji) {
return null;
}
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
const codepoints = convertToCodePoints(finalEmoji.surrogates);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: finalEmoji.surrogates,
codepoints,
name: baseName,
},
},
advance: totalAdvance,
};
}
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: Node): 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 && 'children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
applyTextPresentation(child);
}
}
}

View File

@@ -0,0 +1,856 @@
/*
* 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 '@fluxer/markdown_parser/src/parser/FormattingContext';
import * as EmojiParsers from '@fluxer/markdown_parser/src/parsers/EmojiParsers';
import * as LinkParsers from '@fluxer/markdown_parser/src/parsers/LinkParsers';
import * as MentionParsers from '@fluxer/markdown_parser/src/parsers/MentionParsers';
import * as TimestampParsers from '@fluxer/markdown_parser/src/parsers/TimestampParsers';
import {MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
import * as ASTUtils from '@fluxer/markdown_parser/src/utils/AstUtils';
import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils';
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 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 shortcodeEmojiResult = EmojiParsers.parseEmojiShortcode(remainingText);
if (shortcodeEmojiResult) {
if (accumulatedText.length > 0) {
ASTUtils.addTextNode(nodes, accumulatedText);
accumulatedText = '';
}
nodes.push(shortcodeEmojiResult.node);
position += shortcodeEmojiResult.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 === SLASH || nextCharCode === LETTER_I) {
if (
nextCharCode === AT_SIGN &&
position + 2 < textLength &&
text.charCodeAt(position + 2) === 38 &&
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;
}
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 checkPos = position;
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
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};
}
type NodeWithChildren = Extract<Node, {children: Array<Node>}>;
type FormattingNodeType = NodeWithChildren['type'];
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 FormattingNodeType,
children: [],
...(isBlock ? {isBlock} : {}),
} as NodeWithChildren;
}
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 FormattingNodeType,
children: innerNodes,
...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}),
} as NodeWithChildren;
}

View File

@@ -0,0 +1,513 @@
/*
* 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, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_LINK_URL_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils';
import * as URLUtils from '@fluxer/markdown_parser/src/utils/UrlUtils';
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\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);
if (isEmailSpoofing) {
return null;
}
const urlInfo = extractUrl(text, bracketPosition + 2);
if (!urlInfo) return null;
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
return null;
}
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
if (isLinkTextUrlWithProtocol) {
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
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 {
return null;
}
let end = prefixLength;
const textLength = text.length;
let parenthesesDepth = 0;
while (end < textLength) {
const currentChar = text[end];
if (currentChar === '(') {
parenthesesDepth++;
end++;
} else if (currentChar === ')') {
if (parenthesesDepth > 0) {
parenthesesDepth--;
end++;
} else {
break;
}
} else if (StringUtils.isUrlTerminationChar(currentChar)) {
break;
} else {
end++;
}
if (end - prefixLength > MAX_LINK_URL_LENGTH) {
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 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,452 @@
/*
* 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 {parseCodeBlock} from '@fluxer/markdown_parser/src/parsers/BlockParsers';
import {NodeType} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_AST_NODES} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {ListItem, ListNode, Node} from '@fluxer/markdown_parser/src/types/Nodes';
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;
const normalisedOrdinal = getNormalisedOrdinal(items, ordinal, isOrdered);
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,
normalisedOrdinal,
);
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(' ');
}
function getNormalisedOrdinal(
items: Array<ListItem>,
ordinal: number | undefined,
isOrdered: boolean,
): number | undefined {
if (!isOrdered) return undefined;
if (items.length === 0) return ordinal ?? 1;
const startOrdinal = items[0]?.ordinal ?? ordinal ?? 1;
return startOrdinal + items.length;
}
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,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 '@fluxer/markdown_parser/src/types/Enums';
import type {MentionNode, ParserResult} from '@fluxer/markdown_parser/src/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,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 '@fluxer/markdown_parser/src/types/Enums';
import type {Node, TableCellNode, TableNode, TableRowNode} from '@fluxer/markdown_parser/src/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,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 '@fluxer/markdown_parser/src/types/Enums';
import type {ParserResult} from '@fluxer/markdown_parser/src/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,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 type {ValueOf} from '@fluxer/constants/src/ValueOf';
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 = ValueOf<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 = ValueOf<typeof NodeType>;
export const AlertType = {
Note: 'Note',
Tip: 'Tip',
Important: 'Important',
Warning: 'Warning',
Caution: 'Caution',
} as const;
export type AlertType = ValueOf<typeof AlertType>;
export const TableAlignment = {
Left: 'Left',
Center: 'Center',
Right: 'Right',
None: 'None',
} as const;
export type TableAlignment = ValueOf<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 = ValueOf<typeof TimestampStyle>;
export const GuildNavKind = {
Customize: 'Customize',
Browse: 'Browse',
Guide: 'Guide',
LinkedRoles: 'LinkedRoles',
} as const;
export type GuildNavKind = ValueOf<typeof GuildNavKind>;
export const MentionKind = {
User: 'User',
Channel: 'Channel',
Role: 'Role',
Command: 'Command',
GuildNavigation: 'GuildNavigation',
Everyone: 'Everyone',
Here: 'Here',
} as const;
export type MentionKind = ValueOf<typeof MentionKind>;
export const EmojiKind = {
Standard: 'Standard',
Custom: 'Custom',
} as const;
export type EmojiKind = ValueOf<typeof EmojiKind>;

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,168 @@
/*
* 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, NodeType, TableAlignment, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
interface BaseNode {
type: NodeType;
}
export interface TextNode extends BaseNode {
type: 'Text';
content: string;
}
export interface BlockquoteNode extends BaseNode {
type: 'Blockquote';
children: Array<Node>;
}
export interface FormattingNode extends BaseNode {
type: 'Strong' | 'Emphasis' | 'Underline' | 'Strikethrough' | 'Spoiler' | 'Sequence';
children: Array<Node>;
}
export interface HeadingNode extends BaseNode {
type: 'Heading';
level: number;
children: Array<Node>;
}
export interface SubtextNode extends BaseNode {
type: 'Subtext';
children: Array<Node>;
}
export interface ListNode extends BaseNode {
type: 'List';
ordered: boolean;
items: Array<ListItem>;
}
export interface ListItem {
children: Array<Node>;
ordinal?: number;
}
export interface CodeBlockNode extends BaseNode {
type: 'CodeBlock';
language?: string;
content: string;
}
export interface InlineCodeNode extends BaseNode {
type: 'InlineCode';
content: string;
}
export interface LinkNode extends BaseNode {
type: 'Link';
text?: Node;
url: string;
escaped: boolean;
}
export interface MentionNode extends BaseNode {
type: 'Mention';
kind: MentionType;
}
export interface TimestampNode extends BaseNode {
type: 'Timestamp';
timestamp: number;
style: TimestampStyle;
}
export interface EmojiNode extends BaseNode {
type: 'Emoji';
kind: EmojiType;
}
export interface SequenceNode extends BaseNode {
type: 'Sequence';
children: Array<Node>;
}
export interface TableNode extends BaseNode {
type: 'Table';
header: TableRowNode;
alignments: Array<TableAlignment>;
rows: Array<TableRowNode>;
}
export interface TableRowNode extends BaseNode {
type: 'TableRow';
cells: Array<TableCellNode>;
}
export interface TableCellNode extends BaseNode {
type: 'TableCell';
children: Array<Node>;
}
export interface AlertNode extends BaseNode {
type: 'Alert';
alertType: AlertType;
children: Array<Node>;
}
export interface SpoilerNode extends BaseNode {
type: '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;
export type MentionType =
| {kind: 'User'; id: string}
| {kind: 'Channel'; id: string}
| {kind: 'Role'; id: string}
| {kind: 'Command'; name: string; subcommandGroup?: string; subcommand?: string; id: string}
| {kind: 'GuildNavigation'; navigationType: 'Customize' | 'Browse' | 'Guide'}
| {kind: 'GuildNavigation'; navigationType: 'LinkedRoles'; id?: string}
| {kind: 'Everyone'}
| {kind: 'Here'};
export type EmojiType =
| {kind: 'Standard'; raw: string; codepoints: string; name: string}
| {kind: 'Custom'; name: string; id: string; animated: boolean};
export interface ParserResult {
node: Node;
advance: number;
}

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 '@fluxer/markdown_parser/src/types/Enums';
import type {
AlertNode,
BlockquoteNode,
FormattingNode,
HeadingNode,
LinkNode,
ListNode,
Node,
SequenceNode,
SubtextNode,
TableCellNode,
TableNode,
TableRowNode,
TextNode,
} from '@fluxer/markdown_parser/src/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<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,92 @@
/*
* 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/>.
*/
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("'");
}
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,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/>.
*/
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 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:)/;
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
);
}