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>'}]);
});
});