refactor progress
This commit is contained in:
154
packages/markdown_parser/src/__tests__/AstUtils.test.tsx
Normal file
154
packages/markdown_parser/src/__tests__/AstUtils.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1279
packages/markdown_parser/src/__tests__/BlockParsers.test.tsx
Normal file
1279
packages/markdown_parser/src/__tests__/BlockParsers.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1093
packages/markdown_parser/src/__tests__/EmojiParsers.test.tsx
Normal file
1093
packages/markdown_parser/src/__tests__/EmojiParsers.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
862
packages/markdown_parser/src/__tests__/InlineParsers.test.tsx
Normal file
862
packages/markdown_parser/src/__tests__/InlineParsers.test.tsx
Normal 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'}]},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
2000
packages/markdown_parser/src/__tests__/LinkParsers.test.tsx
Normal file
2000
packages/markdown_parser/src/__tests__/LinkParsers.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
658
packages/markdown_parser/src/__tests__/ListParsers.test.tsx
Normal file
658
packages/markdown_parser/src/__tests__/ListParsers.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
442
packages/markdown_parser/src/__tests__/MentionParsers.test.tsx
Normal file
442
packages/markdown_parser/src/__tests__/MentionParsers.test.tsx
Normal 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: '<'}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
packages/markdown_parser/src/__tests__/Parser.test.tsx
Normal file
186
packages/markdown_parser/src/__tests__/Parser.test.tsx
Normal 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},
|
||||
]);
|
||||
});
|
||||
});
|
||||
120
packages/markdown_parser/src/__tests__/StringUtils.test.tsx
Normal file
120
packages/markdown_parser/src/__tests__/StringUtils.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
packages/markdown_parser/src/__tests__/TableParsers.test.tsx
Normal file
306
packages/markdown_parser/src/__tests__/TableParsers.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/markdown_parser/src/__tests__/TestEmojiSetup.tsx
Normal file
143
packages/markdown_parser/src/__tests__/TestEmojiSetup.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
307
packages/markdown_parser/src/__tests__/TimestampParsers.test.tsx
Normal file
307
packages/markdown_parser/src/__tests__/TimestampParsers.test.tsx
Normal 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>'}]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user